Skip to content

Commit

Permalink
fixup! feat(cdk-experimental/ui-patterns): listbox ui pattern
Browse files Browse the repository at this point in the history
  • Loading branch information
wagnermaciel committed Feb 24, 2025
1 parent 722b81b commit 85568f4
Show file tree
Hide file tree
Showing 13 changed files with 129 additions and 102 deletions.
12 changes: 7 additions & 5 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ import {toSignal} from '@angular/core/rxjs-interop';
})
export class CdkListbox {
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
private _dir = inject(Directionality);
private _directionality = inject(Directionality);

/** The CdkOptions nested inside of the CdkListbox. */
private _cdkOptions = contentChildren(CdkOption, {descendants: true});

/** A signal wrapper for directionality. */
protected directionality = toSignal(this._dir.change, {
initialValue: this._dir.value,
protected textDirection = toSignal(this._directionality.change, {
initialValue: this._directionality.value,
});

/** The Option UIPatterns of the child CdkOptions. */
Expand Down Expand Up @@ -88,6 +88,7 @@ export class CdkListbox {
/** Whether the listbox is disabled. */
disabled = input(false, {transform: booleanAttribute});

// TODO(wagnermaciel): Figure out how we want to expose control over the current listbox value.
/** The ids of the current selected items. */
selectedIds = model<string[]>([]);

Expand All @@ -98,11 +99,11 @@ export class CdkListbox {
pattern: ListboxPattern = new ListboxPattern({
...this,
items: this.items,
directionality: this.directionality,
textDirection: this.textDirection,
});
}

// TODO(wagnermaciel): Figure out how we actually want to do this.
// TODO(wagnermaciel): Figure out how we want to generate IDs.
let count = 0;

/** A selectable option in a CdkListbox. */
Expand All @@ -124,6 +125,7 @@ export class CdkOption {
/** The parent CdkListbox. */
private _cdkListbox = inject(CdkListbox);

// TODO(wagnermaciel): Figure out how we want to generate IDs.
/** A unique identifier for the option. */
protected id = computed(() => `${count++}`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('List Focus', () => {
wrap: signal(false),
activeIndex: signal(0),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
...args,
});
Expand All @@ -62,12 +62,11 @@ describe('List Focus', () => {
expect(tabindex()).toBe(-1);
});

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

it('should set the first items tabindex to 0', () => {
Expand Down Expand Up @@ -122,8 +121,7 @@ describe('List Focus', () => {
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});
const activeId = focus.getActiveDescendant();
expect(activeId()).toBe(items()[0].id());
expect(focus.getActiveDescendant()).toBe(items()[0].id());
});

it('should set the tabindex of all items to -1', () => {
Expand All @@ -150,10 +148,9 @@ describe('List Focus', () => {
const focus = getFocus(nav, {
focusMode: signal('activedescendant'),
});
const activeId = focus.getActiveDescendant();

nav.next();
expect(activeId()).toBe(items()[1].id());
expect(focus.getActiveDescendant()).toBe(items()[1].id());
});
});
});
22 changes: 10 additions & 12 deletions src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {computed, Signal} from '@angular/core';
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';

/** The required properties for focus items. */
/** Represents an item in a collection, such as a listbox option, than may receive focus. */
export interface ListFocusItem extends ListNavigationItem {
/** A unique identifier for the item. */
id: Signal<string>;
Expand All @@ -18,7 +18,7 @@ export interface ListFocusItem extends ListNavigationItem {
element: Signal<HTMLElement>;
}

/** The required inputs for list focus. */
/** 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: Signal<'roving' | 'activedescendant'>;
Expand All @@ -29,20 +29,18 @@ export class ListFocus<T extends ListFocusItem> {
/** The navigation controller of the parent list. */
navigation: ListNavigation<ListFocusItem>;

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

constructor(readonly inputs: ListFocusInputs<T> & {navigation: ListNavigation<T>}) {
this.navigation = inputs.navigation;
}

/** Returns the id of the current active item. */
getActiveDescendant(): Signal<string | null> {
return computed(() => {
if (this.inputs.focusMode() === 'roving') {
return null;
}
return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id();
});
}

/** Returns a signal that keeps track of the tabindex for the list. */
getListTabindex(): Signal<-1 | 0> {
return computed(() => (this.inputs.focusMode() === 'activedescendant' ? 0 : -1));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('List Navigation', () => {
wrap: signal(false),
activeIndex: signal(0),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
...args,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

import {signal, Signal, WritableSignal} from '@angular/core';

/** The required properties for navigation items. */
/** Represents an item in a collection, such as a listbox option, than can be navigated to. */
export interface ListNavigationItem {
/** Whether an item is disabled. */
disabled: Signal<boolean>;
}

/** The required inputs for list navigation. */
/** Represents the required inputs for a collection that has navigable items. */
export interface ListNavigationInputs<T extends ListNavigationItem> {
/** Whether focus should wrap when navigating. */
wrap: Signal<boolean>;
Expand All @@ -32,7 +32,7 @@ export interface ListNavigationInputs<T extends ListNavigationItem> {
orientation: Signal<'vertical' | 'horizontal'>;

/** The direction that text is read based on the users locale. */
directionality: Signal<'rtl' | 'ltr'>;
textDirection: Signal<'rtl' | 'ltr'>;
}

/** Controls navigation for a list of items. */
Expand All @@ -56,26 +56,42 @@ export class ListNavigation<T extends ListNavigationItem> {
/** Navigates to the next item in the list. */
next() {
const items = this.inputs.items();
const after = items.slice(this.inputs.activeIndex() + 1);
const before = items.slice(0, this.inputs.activeIndex());
const array = this.inputs.wrap() ? after.concat(before) : after;
const item = array.find(i => this.isFocusable(i));

if (item) {
this.goto(item);
for (let i = this.inputs.activeIndex() + 1; i < items.length; i++) {
if (this.isFocusable(items[i])) {
this.goto(items[i]);
return;
}
}

if (this.inputs.wrap()) {
for (let i = 0; i <= this.inputs.activeIndex(); i++) {
if (this.isFocusable(items[i])) {
this.goto(items[i]);
return;
}
}
}
}

/** Navigates to the previous item in the list. */
prev() {
const items = this.inputs.items();
const after = items.slice(this.inputs.activeIndex() + 1).reverse();
const before = items.slice(0, this.inputs.activeIndex()).reverse();
const array = this.inputs.wrap() ? before.concat(after) : before;
const item = array.find(i => this.isFocusable(i));

if (item) {
this.goto(item);
for (let i = this.inputs.activeIndex() - 1; i >= 0; i--) {
if (this.isFocusable(items[i])) {
this.goto(items[i]);
return;
}
}

if (this.inputs.wrap()) {
for (let i = items.length - 1; i >= this.inputs.activeIndex(); i--) {
if (this.isFocusable(items[i])) {
this.goto(items[i]);
return;
}
}
}
}

Expand All @@ -90,10 +106,12 @@ export class ListNavigation<T extends ListNavigationItem> {

/** Navigates to the last item in the list. */
last() {
const item = [...this.inputs.items()].reverse().find(i => this.isFocusable(i));

if (item) {
this.goto(item);
const items = this.inputs.items();
for (let i = items.length - 1; i >= 0; i--) {
if (this.isFocusable(items[i])) {
this.goto(items[i]);
return;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('List Selection', () => {
wrap: signal(false),
activeIndex: signal(0),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
...args,
});
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('List Selection', () => {
selection.select(); // [0]
nav.next();
nav.next();
selection.selectFromAnchor(); // [0, 1, 2]
selection.selectFromLastSelectedItem(); // [0, 1, 2]

expect(selection.inputs.selectedIds()).toEqual(['0', '1', '2']);
});
Expand All @@ -208,7 +208,7 @@ describe('List Selection', () => {
selection.select(); // [3]
nav.prev();
nav.prev();
selection.selectFromAnchor(); // [3, 1, 2]
selection.selectFromLastSelectedItem(); // [3, 1, 2]

expect(selection.inputs.selectedIds()).toEqual(['3', '1', '2']);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {signal, Signal, WritableSignal} from '@angular/core';
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';

/** The required properties for selection items. */
/** Represents an item in a collection, such as a listbox option, than can be selected. */
export interface ListSelectionItem extends ListNavigationItem {
/** A unique identifier for the item. */
id: Signal<string>;
Expand All @@ -18,7 +18,7 @@ export interface ListSelectionItem extends ListNavigationItem {
disabled: Signal<boolean>;
}

/** The required inputs for list selection. */
/** Represents the required inputs for a collection that contains selectable items. */
export interface ListSelectionInputs<T extends ListSelectionItem> {
/** The items in the list. */
items: Signal<T[]>;
Expand All @@ -35,8 +35,8 @@ export interface ListSelectionInputs<T extends ListSelectionItem> {

/** Controls selection for a list of items. */
export class ListSelection<T extends ListSelectionItem> {
/** The id of the previous selected item. */
anchorId = signal<string | null>(null);
/** The id of the last selected item. */
lastSelectedId = signal<string | undefined>(undefined);

/** The navigation controller of the parent list. */
navigation: ListNavigation<T>;
Expand Down Expand Up @@ -104,9 +104,9 @@ export class ListSelection<T extends ListSelectionItem> {
}

/** Selects the items in the list starting at the last selected item. */
selectFromAnchor() {
const anchorIndex = this.inputs.items().findIndex(i => this.anchorId() === i.id());
this._selectFromIndex(anchorIndex);
selectFromLastSelectedItem() {
const lastSelectedId = this.inputs.items().findIndex(i => this.lastSelectedId() === i.id());
this._selectFromIndex(lastSelectedId);
}

/** Selects the items in the list starting at the last active item. */
Expand Down Expand Up @@ -137,6 +137,6 @@ export class ListSelection<T extends ListSelectionItem> {
/** Sets the anchor to the current active index. */
private _anchor() {
const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
this.anchorId.set(item.id());
this.lastSelectedId.set(item.id());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('List Typeahead', () => {
activeIndex,
wrap: signal(false),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
});
const typeahead = new ListTypeahead({
Expand All @@ -62,7 +62,7 @@ describe('List Typeahead', () => {
activeIndex,
wrap: signal(false),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
});
const typeahead = new ListTypeahead({
Expand All @@ -87,7 +87,7 @@ describe('List Typeahead', () => {
activeIndex,
wrap: signal(false),
skipDisabled: signal(true),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
});
const typeahead = new ListTypeahead({
Expand All @@ -108,7 +108,7 @@ describe('List Typeahead', () => {
activeIndex,
wrap: signal(false),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
});
const typeahead = new ListTypeahead({
Expand All @@ -129,7 +129,7 @@ describe('List Typeahead', () => {
activeIndex,
wrap: signal(false),
skipDisabled: signal(false),
directionality: signal('ltr'),
textDirection: signal('ltr'),
orientation: signal('vertical'),
});
const typeahead = new ListTypeahead({
Expand Down
Loading

0 comments on commit 85568f4

Please sign in to comment.