Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ts_project(
exclude = ["**/*.spec.ts"],
),
deps = [
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
],
)
Expand All @@ -22,8 +22,6 @@ ts_project(
deps = [
":list-focus",
"//:node_modules/@angular/core",
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
],
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,152 +6,125 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {computed, signal} from '@angular/core';
import {SignalLike} from '../signal-like/signal-like';
import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation';
import {Signal, signal, WritableSignal} from '@angular/core';
import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus';

describe('List Focus', () => {
interface TestItem extends ListFocusItem {
tabindex: SignalLike<-1 | 0>;
}

function getItems(length: number): SignalLike<TestItem[]> {
return signal(
Array.from({length}).map((_, i) => ({
index: signal(i),
type TestItem = ListFocusItem & {
disabled: WritableSignal<boolean>;
};

type TestInputs = Partial<ListFocusInputs<ListFocusItem>> & {
numItems?: number;
};

export function getListFocus(inputs: TestInputs = {}): ListFocus<ListFocusItem> {
return new ListFocus({
activeIndex: signal(0),
disabled: signal(false),
skipDisabled: signal(false),
focusMode: signal('roving'),
items: getItems(inputs.numItems ?? 5),
...inputs,
});
}

function getItems(length: number): Signal<ListFocusItem[]> {
return signal(
Array.from({length}).map((_, i) => {
return {
id: signal(`${i}`),
tabindex: signal(-1),
disabled: signal(false),
element: signal({focus: () => {}} as HTMLElement),
})),
);
}

function getNavigation<T extends TestItem>(
items: SignalLike<T[]>,
args: Partial<ListNavigationInputs<T>> = {},
): ListNavigation<T> {
return new ListNavigation({
items,
wrap: signal(false),
activeIndex: signal(0),
skipDisabled: signal(false),
textDirection: signal('ltr'),
orientation: signal('vertical'),
...args,
});
}

function getFocus<T extends TestItem>(
navigation: ListNavigation<T>,
args: Partial<ListFocusInputs<T>> = {},
): ListFocus<T> {
return new ListFocus({
navigation,
focusMode: signal('roving'),
...args,
});
}
};
}),
);
}

describe('List Focus', () => {
describe('roving', () => {
let focusManager: ListFocus<ListFocusItem>;

beforeEach(() => {
focusManager = getListFocus({focusMode: signal('roving')});
});

it('should set the list tabindex to -1', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav);
const tabindex = computed(() => focus.getListTabindex());
expect(tabindex()).toBe(-1);
expect(focusManager.getListTabindex()).toBe(-1);
});

it('should set the activedescendant to undefined', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav);
expect(focus.getActiveDescendant()).toBeUndefined();
expect(focusManager.getActiveDescendant()).toBeUndefined();
});

it('should set the first items tabindex to 0', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav);

items().forEach(i => {
i.tabindex = computed(() => focus.getItemTabindex(i));
});

expect(items()[0].tabindex()).toBe(0);
expect(items()[1].tabindex()).toBe(-1);
expect(items()[2].tabindex()).toBe(-1);
expect(items()[3].tabindex()).toBe(-1);
expect(items()[4].tabindex()).toBe(-1);
it('should set the tabindex based on the active index', () => {
const items = focusManager.inputs.items() as TestItem[];
focusManager.inputs.activeIndex.set(2);
expect(focusManager.getItemTabindex(items[0])).toBe(-1);
expect(focusManager.getItemTabindex(items[1])).toBe(-1);
expect(focusManager.getItemTabindex(items[2])).toBe(0);
expect(focusManager.getItemTabindex(items[3])).toBe(-1);
expect(focusManager.getItemTabindex(items[4])).toBe(-1);
});
});

it('should update the tabindex of the active item when navigating', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav);

items().forEach(i => {
i.tabindex = computed(() => focus.getItemTabindex(i));
});

nav.next();
describe('activedescendant', () => {
let focusManager: ListFocus<ListFocusItem>;

expect(items()[0].tabindex()).toBe(-1);
expect(items()[1].tabindex()).toBe(0);
expect(items()[2].tabindex()).toBe(-1);
expect(items()[3].tabindex()).toBe(-1);
expect(items()[4].tabindex()).toBe(-1);
beforeEach(() => {
focusManager = getListFocus({focusMode: signal('activedescendant')});
});
});

describe('activedescendant', () => {
it('should set the list tabindex to 0', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});
const tabindex = computed(() => focus.getListTabindex());
expect(tabindex()).toBe(0);
expect(focusManager.getListTabindex()).toBe(0);
});

it('should set the activedescendant to the active items id', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});
expect(focus.getActiveDescendant()).toBe(items()[0].id());
expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[0].id());
});

it('should set the tabindex of all items to -1', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});

items().forEach(i => {
i.tabindex = computed(() => focus.getItemTabindex(i));
});

expect(items()[0].tabindex()).toBe(-1);
expect(items()[1].tabindex()).toBe(-1);
expect(items()[2].tabindex()).toBe(-1);
expect(items()[3].tabindex()).toBe(-1);
expect(items()[4].tabindex()).toBe(-1);
const items = focusManager.inputs.items() as TestItem[];
focusManager.inputs.activeIndex.set(0);
expect(focusManager.getItemTabindex(items[0])).toBe(-1);
expect(focusManager.getItemTabindex(items[1])).toBe(-1);
expect(focusManager.getItemTabindex(items[2])).toBe(-1);
expect(focusManager.getItemTabindex(items[3])).toBe(-1);
expect(focusManager.getItemTabindex(items[4])).toBe(-1);
});

it('should update the activedescendant of the list when navigating', () => {
const items = getItems(5);
const nav = getNavigation(items);
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});

nav.next();
expect(focus.getActiveDescendant()).toBe(items()[1].id());
focusManager.inputs.activeIndex.set(1);
expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[1].id());
});
});

describe('#isFocusable', () => {
it('should return true for enabled items', () => {
const focusManager = getListFocus({skipDisabled: signal(true)});
const items = focusManager.inputs.items() as TestItem[];
expect(focusManager.isFocusable(items[0])).toBeTrue();
expect(focusManager.isFocusable(items[1])).toBeTrue();
expect(focusManager.isFocusable(items[2])).toBeTrue();
});

it('should return false for disabled items', () => {
const focusManager = getListFocus({skipDisabled: signal(true)});
const items = focusManager.inputs.items() as TestItem[];
items[1].disabled.set(true);

expect(focusManager.isFocusable(items[0])).toBeTrue();
expect(focusManager.isFocusable(items[1])).toBeFalse();
expect(focusManager.isFocusable(items[2])).toBeTrue();
});

it('should return true for disabled items if skip disabled is false', () => {
const focusManager = getListFocus({skipDisabled: signal(false)});
const items = focusManager.inputs.items() as TestItem[];
items[1].disabled.set(true);

expect(focusManager.isFocusable(items[0])).toBeTrue();
expect(focusManager.isFocusable(items[1])).toBeTrue();
expect(focusManager.isFocusable(items[2])).toBeTrue();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,65 +6,103 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {SignalLike} from '../signal-like/signal-like';
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
import {computed, signal} from '@angular/core';
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';

/** Represents an item in a collection, such as a listbox option, than may receive focus. */
export interface ListFocusItem extends ListNavigationItem {
export interface ListFocusItem {
/** A unique identifier for the item. */
id: SignalLike<string>;

/** The html element that should receive focus. */
element: SignalLike<HTMLElement>;

/** Whether an item is disabled. */
disabled: SignalLike<boolean>;
}

/** Represents the required inputs for a collection that contains focusable items. */
export interface ListFocusInputs<T extends ListFocusItem> {
/** The focus strategy used by the list. */
focusMode: SignalLike<'roving' | 'activedescendant'>;

/** Whether the list is disabled. */
disabled: SignalLike<boolean>;

/** The items in the list. */
items: SignalLike<T[]>;

/** The index of the current active item. */
activeIndex: WritableSignalLike<number>;

/** Whether disabled items in the list should be skipped when navigating. */
skipDisabled: SignalLike<boolean>;
}

/** Controls focus for a list of items. */
export class ListFocus<T extends ListFocusItem> {
/** The navigation controller of the parent list. */
navigation: ListNavigation<ListFocusItem>;
/** The last index that was active. */
prevActiveIndex = signal(0);

constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
this.navigation = inputs.navigation;
/** The current active item. */
activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]);

constructor(readonly inputs: ListFocusInputs<T>) {}

/** Whether the list is in a disabled state. */
isListDisabled(): boolean {
return this.inputs.disabled() || this.inputs.items().every(i => i.disabled());
}

/** The id of the current active item. */
getActiveDescendant(): string | undefined {
if (this.inputs.focusMode() === 'roving') {
if (this.isListDisabled()) {
return undefined;
}
if (this.navigation.inputs.items().length) {
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
if (this.inputs.focusMode() === 'roving') {
return undefined;
}
return undefined;
return this.inputs.items()[this.inputs.activeIndex()].id();
}

/** The tabindex for the list. */
getListTabindex(): -1 | 0 {
if (this.isListDisabled()) {
return 0;
}
return this.inputs.focusMode() === 'activedescendant' ? 0 : -1;
}

/** Returns the tabindex for the given item. */
getItemTabindex(item: T): -1 | 0 {
if (this.inputs.disabled()) {
return -1;
}
if (this.inputs.focusMode() === 'activedescendant') {
return -1;
}
const index = this.navigation.inputs.items().indexOf(item);
return this.navigation.inputs.activeIndex() === index ? 0 : -1;
return this.activeItem() === item ? 0 : -1;
}

/** Focuses the current active item. */
focus() {
if (this.inputs.focusMode() === 'activedescendant') {
return;
/** Moves focus to the given item if it is focusable. */
focus(item: T): boolean {
if (this.isListDisabled() || !this.isFocusable(item)) {
return false;
}

this.prevActiveIndex.set(this.inputs.activeIndex());
const index = this.inputs.items().indexOf(item);
this.inputs.activeIndex.set(index);

if (this.inputs.focusMode() === 'roving') {
item.element().focus();
}

const item = this.navigation.inputs.items()[this.navigation.inputs.activeIndex()];
item.element().focus();
return true;
}

/** Returns true if the given item can be navigated to. */
isFocusable(item: T): boolean {
return !item.disabled() || !this.inputs.skipDisabled();
}
}
Loading
Loading