diff --git a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts index 6f28962270cd..539744a648b6 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts @@ -47,6 +47,7 @@ export enum ModifierKey { Shift = 0b10, Alt = 0b100, Meta = 0b1000, + Any = 'Any', } export type ModifierInputs = ModifierKey | ModifierKey[]; @@ -99,5 +100,10 @@ export function getModifiers(event: EventWithModifiers): number { export function hasModifiers(event: EventWithModifiers, modifiers: ModifierInputs): boolean { const eventModifiers = getModifiers(event); const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers]; + + if (modifiersList.includes(ModifierKey.Any)) { + return true; + } + return modifiersList.some(modifiers => eventModifiers === modifiers); } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts index a5a3ff22fe44..200ca8d3537b 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; +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 can be navigated to. */ @@ -41,45 +41,47 @@ export class ListNavigation { /** The last index that was active. */ prevActiveIndex = signal(0); + /** The current active item. */ + activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]); + constructor(readonly inputs: ListNavigationInputs) {} /** Navigates to the given item. */ - goto(item: T) { - if (this.isFocusable(item)) { + goto(item?: T): boolean { + if (item && this.isFocusable(item)) { this.prevActiveIndex.set(this.inputs.activeIndex()); const index = this.inputs.items().indexOf(item); this.inputs.activeIndex.set(index); + return true; } + return false; } /** Navigates to the next item in the list. */ - next() { - this._advance(1); + next(): boolean { + return this._advance(1); } /** Navigates to the previous item in the list. */ - prev() { - this._advance(-1); + prev(): boolean { + return this._advance(-1); } /** Navigates to the first item in the list. */ - first() { + first(): boolean { const item = this.inputs.items().find(i => this.isFocusable(i)); - - if (item) { - this.goto(item); - } + return item ? this.goto(item) : false; } /** Navigates to the last item in the list. */ - last() { + last(): boolean { const items = this.inputs.items(); for (let i = items.length - 1; i >= 0; i--) { if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; + return this.goto(items[i]); } } + return false; } /** Returns true if the given item can be navigated to. */ @@ -88,7 +90,7 @@ export class ListNavigation { } /** Advances to the next or previous focusable item in the list based on the given delta. */ - private _advance(delta: 1 | -1) { + private _advance(delta: 1 | -1): boolean { const items = this.inputs.items(); const itemCount = items.length; const startIndex = this.inputs.activeIndex(); @@ -100,9 +102,10 @@ export class ListNavigation { // when the index goes out of bounds. for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) { if (this.isFocusable(items[i])) { - this.goto(items[i]); - return; + return this.goto(items[i]); } } + + return false; } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index 2a97c628e7c0..da9f28cf5a7f 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -157,6 +157,36 @@ describe('List Selection', () => { }); }); + describe('#toggleOne', () => { + it('should select an unselected item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.toggleOne(); // [0] + expect(selection.inputs.value()).toEqual([0]); + }); + + it('should deselect a selected item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.select(); // [0] + selection.toggleOne(); // [] + expect(selection.inputs.value().length).toBe(0); + }); + + it('should only leave one item selected', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.select(); // [0] + nav.next(); + selection.toggleOne(); // [1] + expect(selection.inputs.value()).toEqual([1]); + }); + }); + describe('#selectAll', () => { it('should select all items', () => { const items = getItems([0, 1, 2, 3, 4]); @@ -185,7 +215,71 @@ describe('List Selection', () => { }); }); - describe('#selectFromAnchor', () => { + describe('#toggleAll', () => { + it('should select all items', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]); + }); + + it('should deselect all if all items are selected', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + selection.selectAll(); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([]); + }); + + it('should ignore disabled items when determining if all items are selected', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[0].disabled.set(true); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([1, 2, 3, 4]); + selection.toggleAll(); + expect(selection.inputs.value()).toEqual([]); + }); + }); + + describe('#selectOne', () => { + it('should select a single item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.selectOne(); // [0] + nav.next(); + selection.selectOne(); // [1] + expect(selection.inputs.value()).toEqual([1]); + }); + + it('should not select disabled items', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[0].disabled.set(true); + + selection.select(); // [] + expect(selection.inputs.value()).toEqual([]); + }); + + it('should do nothing to already selected items', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.selectOne(); // [0] + selection.selectOne(); // [0] + + expect(selection.inputs.value()).toEqual([0]); + }); + }); + + describe('#selectRange', () => { it('should select all items from an anchor at a lower index', () => { const items = getItems([0, 1, 2, 3, 4]); const nav = getNavigation(items); @@ -194,7 +288,7 @@ describe('List Selection', () => { selection.select(); // [0] nav.next(); nav.next(); - selection.selectFromPrevSelectedItem(); // [0, 1, 2] + selection.selectRange(); // [0, 1, 2] expect(selection.inputs.value()).toEqual([0, 1, 2]); }); @@ -209,10 +303,98 @@ describe('List Selection', () => { selection.select(); // [3] nav.prev(); nav.prev(); - selection.selectFromPrevSelectedItem(); // [3, 1, 2] + selection.selectRange(); // [3, 2, 1] + + expect(selection.inputs.value()).toEqual([3, 2, 1]); + }); + + it('should deselect items within the range when the range is changed', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + nav.next(); + nav.next(); + selection.select(); // [2] + expect(selection.inputs.value()).toEqual([2]); - // TODO(wagnermaciel): Order the values when inserting them. - expect(selection.inputs.value()).toEqual([3, 1, 2]); + nav.next(); + nav.next(); + selection.selectRange(); // [2, 3, 4] + expect(selection.inputs.value()).toEqual([2, 3, 4]); + + nav.first(); + selection.selectRange(); // [2, 1, 0] + expect(selection.inputs.value()).toEqual([2, 1, 0]); + }); + + it('should not select a disabled item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[1].disabled.set(true); + + selection.select(); // [0] + expect(selection.inputs.value()).toEqual([0]); + + nav.next(); + selection.selectRange(); // [0] + expect(selection.inputs.value()).toEqual([0]); + + nav.next(); + selection.selectRange(); // [0, 2] + expect(selection.inputs.value()).toEqual([0, 2]); + }); + + it('should not deselect a disabled item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + selection.select(items()[1]); + items()[1].disabled.set(true); + + selection.select(); // [0] + expect(selection.inputs.value()).toEqual([1, 0]); + + nav.next(); + nav.next(); + selection.selectRange(); // [0, 1, 2] + expect(selection.inputs.value()).toEqual([1, 0, 2]); + + nav.prev(); + nav.prev(); + selection.selectRange(); // [0] + expect(selection.inputs.value()).toEqual([1, 0]); + }); + }); + + describe('#beginRangeSelection', () => { + it('should set where a range is starting from', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + + nav.next(); + nav.next(); + selection.beginRangeSelection(); + expect(selection.inputs.value()).toEqual([]); + nav.next(); + nav.next(); + selection.selectRange(); // [2, 3, 4] + expect(selection.inputs.value()).toEqual([2, 3, 4]); + }); + + it('should be able to select a range starting on a disabled item', () => { + const items = getItems([0, 1, 2, 3, 4]); + const nav = getNavigation(items); + const selection = getSelection(items, nav); + items()[0].disabled.set(true); + selection.beginRangeSelection(0); + nav.next(); + nav.next(); + selection.selectRange(); + expect(selection.inputs.value()).toEqual([1, 2]); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index c51a65996d8d..4dc4c1fc9820 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -36,8 +36,11 @@ export interface ListSelectionInputs, V> { /** Controls selection for a list of items. */ export class ListSelection, V> { - /** The value of the most recently selected item. */ - previousValue = signal(undefined); + /** The start index to use for range selection. */ + rangeStartIndex = signal(0); + + /** The end index to use for range selection. */ + rangeEndIndex = signal(0); /** The navigation controller of the parent list. */ navigation: ListNavigation; @@ -47,8 +50,8 @@ export class ListSelection, V> { } /** Selects the item at the current active index. */ - select(item?: T) { - item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + select(item?: T, opts = {anchor: true}) { + item = item ?? this.inputs.navigation.activeItem(); if (item.disabled() || this.inputs.value().includes(item.value())) { return; @@ -58,14 +61,16 @@ export class ListSelection, V> { this.deselectAll(); } - // TODO: Need to discuss when to drop this. - this._anchor(); + const index = this.inputs.items().findIndex(i => i === item); + if (opts.anchor) { + this.beginRangeSelection(index); + } this.inputs.value.update(values => values.concat(item.value())); } /** Deselects the item at the current active index. */ deselect(item?: T) { - item = item ?? this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + item = item ?? this.inputs.navigation.activeItem(); if (!item.disabled()) { this.inputs.value.update(values => values.filter(value => value !== item.value())); @@ -74,13 +79,13 @@ export class ListSelection, V> { /** Toggles the item at the current active index. */ toggle() { - const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + const item = this.inputs.navigation.activeItem(); this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); } /** Toggles only the item at the current active index. */ toggleOne() { - const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; + const item = this.inputs.navigation.activeItem(); this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne(); } @@ -91,10 +96,10 @@ export class ListSelection, V> { } for (const item of this.inputs.items()) { - this.select(item); + this.select(item, {anchor: false}); } - this._anchor(); + this.beginRangeSelection(); } /** Deselects all items in the list. */ @@ -104,15 +109,19 @@ export class ListSelection, V> { } } - /** Selects the items in the list starting at the last selected item. */ - selectFromPrevSelectedItem() { - const previousValue = this.inputs.items().findIndex(i => this.previousValue() === i.value()); - this._selectFromIndex(previousValue); - } - - /** Selects the items in the list starting at the last active item. */ - selectFromActive() { - this._selectFromIndex(this.inputs.navigation.prevActiveIndex()); + /** + * Selects all items in the list or deselects all + * items in the list if all items are already selected. + */ + toggleAll() { + const selectableValues = this.inputs + .items() + .filter(i => !i.disabled()) + .map(i => i.value()); + + selectableValues.every(i => this.inputs.value().includes(i)) + ? this.deselectAll() + : this.selectAll(); } /** Sets the selection to only the current active item. */ @@ -121,38 +130,46 @@ export class ListSelection, V> { this.select(); } - /** Toggles the items in the list starting at the last selected item. */ - toggleFromPrevSelectedItem() { - const prevIndex = this.inputs.items().findIndex(i => this.previousValue() === i.value()); - const currIndex = this.inputs.navigation.inputs.activeIndex(); - const currValue = this.inputs.items()[currIndex].value(); - const items = this._getItemsFromIndex(prevIndex); + /** + * Selects all items in the list up to the anchor item. + * + * Deselects all items that were previously within the + * selected range that are now outside of the selected range + */ + selectRange(opts = {anchor: true}) { + const isStartOfRange = this.navigation.prevActiveIndex() === this.rangeStartIndex(); + + if (isStartOfRange && opts.anchor) { + this.beginRangeSelection(this.navigation.prevActiveIndex()); + } - const operation = this.inputs.value().includes(currValue) - ? this.deselect.bind(this) - : this.select.bind(this); + const itemsInRange = this._getItemsFromIndex(this.rangeStartIndex()); + const itemsOutOfRange = this._getItemsFromIndex(this.rangeEndIndex()).filter( + i => !itemsInRange.includes(i), + ); - for (const item of items) { - operation(item); + for (const item of itemsOutOfRange) { + this.deselect(item); } - } - /** Sets the anchor to the current active index. */ - private _anchor() { - const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()]; - this.previousValue.set(item.value()); - } - - /** Selects the items in the list starting at the given index. */ - private _selectFromIndex(index: number) { - const items = this._getItemsFromIndex(index); + for (const item of itemsInRange) { + this.select(item, {anchor: false}); + } - for (const item of items) { - this.select(item); + if (itemsInRange.length) { + const item = itemsInRange.pop(); + const index = this.inputs.items().findIndex(i => i === item); + this.rangeEndIndex.set(index); } } - /** Returns all items from the given index to the current active index. */ + /** Marks the given index as the start of a range selection. */ + beginRangeSelection(index: number = this.navigation.inputs.activeIndex()) { + this.rangeStartIndex.set(index); + this.rangeEndIndex.set(index); + } + + /** Returns the items in the list starting from the given index. */ private _getItemsFromIndex(index: number) { if (index === -1) { return []; @@ -165,6 +182,11 @@ export class ListSelection, V> { for (let i = lower; i <= upper; i++) { items.push(this.inputs.items()[i]); } + + if (this.inputs.navigation.inputs.activeIndex() < index) { + return items.reverse(); + } + return items; } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts index 4f82a8f15027..7ce0e56f0c0e 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -74,7 +74,6 @@ describe('List Typeahead', () => { items()[1].disabled.set(true); (navigation.inputs.skipDisabled as WritableSignalLike).set(true); typeahead.search('i'); - console.log(typeahead.inputs.navigation.inputs.items().map(i => i.disabled())); expect(navigation.inputs.activeIndex()).toBe(2); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index 36d3a65898b3..728e1e9709ca 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -50,13 +50,13 @@ export class ListTypeahead { } /** Performs a typeahead search, appending the given character to the search string. */ - search(char: string) { + search(char: string): boolean { if (char.length !== 1) { - return; + return false; } if (!this.isTyping() && char === ' ') { - return; + return false; } if (this._startIndex() === undefined) { @@ -75,6 +75,8 @@ export class ListTypeahead { this._query.set(''); this._startIndex.set(undefined); }, this.inputs.typeaheadDelay() * 1000); + + return true; } /** diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index 12701bc18606..a9c465dea200 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -13,9 +13,12 @@ import {createKeyboardEvent} from '@angular/cdk/testing/private'; import {ModifierKeys} from '@angular/cdk/testing'; type TestInputs = ListboxInputs; -type TestOption = OptionPattern; +type TestOption = OptionPattern & { + disabled: WritableSignal; +}; type TestListbox = ListboxPattern; +const a = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 65, 'A', mods); const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); @@ -24,6 +27,7 @@ const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); +const shift = () => createKeyboardEvent('keydown', 16, 'Shift', {shift: true}); describe('Listbox Pattern', () => { function getListbox(inputs: Partial & Pick) { @@ -56,7 +60,7 @@ describe('Listbox Pattern', () => { listbox: signal(listbox), element: signal(element), }); - }); + }) as TestOption[]; } function getPatterns(values: string[], inputs: Partial = {}) { @@ -253,13 +257,16 @@ describe('Listbox Pattern', () => { describe('explicit focus & multi select', () => { let listbox: TestListbox; + let options: TestOption[]; beforeEach(() => { - listbox = getDefaultPatterns({ + const patterns = getDefaultPatterns({ value: signal([]), selectionMode: signal('explicit'), multi: signal(true), - }).listbox; + }); + listbox = patterns.listbox; + options = patterns.options(); }); it('should select an option on Space', () => { @@ -279,35 +286,62 @@ describe('Listbox Pattern', () => { expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); }); - it('should toggle the selected state of the next option on Shift + ArrowDown', () => { + it('should select a range of options on Shift + ArrowDown/ArrowUp', () => { + listbox.onKeydown(shift()); listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); listbox.onKeydown(down({shift: true})); - expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); }); - it('should toggle the selected state of the next option on Shift + ArrowUp', () => { - listbox.onKeydown(down()); - listbox.onKeydown(down()); + it('should not allow wrapping while Shift is held down', () => { + listbox.onKeydown(shift()); listbox.onKeydown(up({shift: true})); - listbox.onKeydown(up({shift: true})); - expect(listbox.inputs.value()).toEqual(['Apricot', 'Apple']); + expect(listbox.inputs.value()).toEqual([]); }); - it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => { + it('should select a range of options on Shift + Space (or Enter)', () => { listbox.onKeydown(down()); listbox.onKeydown(space()); // Apricot listbox.onKeydown(down()); listbox.onKeydown(down()); + listbox.onKeydown(shift()); listbox.onKeydown(space({shift: true})); expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana', 'Blackberry']); }); + it('should deselect options outside the range on subsequent on Shift + Space (or Enter)', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(space()); + expect(listbox.inputs.value()).toEqual(['Banana']); + + listbox.onKeydown(down()); + listbox.onKeydown(down()); + listbox.onKeydown(shift()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry']); + + listbox.onKeydown(up()); + listbox.onKeydown(up()); + listbox.onKeydown(up()); + listbox.onKeydown(up()); + listbox.onKeydown(shift()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); + }); + it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => { listbox.onKeydown(down()); listbox.onKeydown(down()); listbox.onKeydown(down()); + listbox.onKeydown(shift()); listbox.onKeydown(home({control: true, shift: true})); - expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana', 'Blackberry']); + expect(listbox.inputs.value()).toEqual(['Blackberry', 'Banana', 'Apricot', 'Apple']); }); it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => { @@ -316,6 +350,7 @@ describe('Listbox Pattern', () => { listbox.onKeydown(down()); listbox.onKeydown(down()); listbox.onKeydown(down()); + listbox.onKeydown(shift()); listbox.onKeydown(end({control: true, shift: true})); expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); }); @@ -330,6 +365,7 @@ describe('Listbox Pattern', () => { listbox.onKeydown(enter()); expect(listbox.inputs.value()).toEqual([]); + listbox.onKeydown(shift()); listbox.onKeydown(up({shift: true})); expect(listbox.inputs.value()).toEqual([]); @@ -342,17 +378,57 @@ describe('Listbox Pattern', () => { listbox.onKeydown(home({control: true, shift: true})); expect(listbox.inputs.value()).toEqual([]); }); + + it('should not change the selected state of disabled options on Shift + ArrowUp / ArrowDown', () => { + (listbox.inputs.skipDisabled as WritableSignal).set(false); + options[1].disabled.set(true); + listbox.onKeydown(shift()); + listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Banana']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); + + it('should select all options on Ctrl + A', () => { + expect(listbox.inputs.value()).toEqual([]); + listbox.onKeydown(a({control: true})); + expect(listbox.inputs.value()).toEqual([ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + ]); + }); + + it('should deselect all options on Ctrl + A if all options are selected', () => { + expect(listbox.inputs.value()).toEqual([]); + listbox.onKeydown(a({control: true})); + listbox.onKeydown(a({control: true})); + expect(listbox.inputs.value()).toEqual([]); + }); }); describe('follows focus & multi select', () => { let listbox: TestListbox; + let options: TestOption[]; beforeEach(() => { - listbox = getDefaultPatterns({ + const patterns = getDefaultPatterns({ value: signal(['Apple']), multi: signal(true), selectionMode: signal('follow'), - }).listbox; + }); + listbox = patterns.listbox; + options = patterns.options(); }); it('should select an option on navigation', () => { @@ -385,36 +461,61 @@ describe('Listbox Pattern', () => { expect(listbox.inputs.value()).toEqual(['Apple', 'Banana']); }); - it('should toggle the selected state of the next option on Shift + ArrowDown', () => { + it('should select a range of options on Shift + ArrowDown/ArrowUp', () => { + listbox.onKeydown(shift()); listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); listbox.onKeydown(down({shift: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); }); - it('should toggle the selected state of the next option on Shift + ArrowUp', () => { - listbox.onKeydown(down()); - listbox.onKeydown(down()); + it('should not allow wrapping while Shift is held down', () => { + listbox.selection.deselectAll(); + listbox.onKeydown(shift()); listbox.onKeydown(up({shift: true})); - listbox.onKeydown(up({shift: true})); - expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); + expect(listbox.inputs.value()).toEqual([]); }); - it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => { + it('should select a range of options on Shift + Space (or Enter)', () => { + listbox.onKeydown(down()); listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); - listbox.onKeydown(down()); // Blackberry + listbox.onKeydown(shift()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana', 'Blackberry']); + }); + + it('should deselect options outside the range on subsequent on Shift + Space (or Enter)', () => { + listbox.onKeydown(down()); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual(['Banana']); + listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); + listbox.onKeydown(shift()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry']); + + listbox.onKeydown(up({control: true})); + listbox.onKeydown(up({control: true})); + listbox.onKeydown(up({control: true})); + listbox.onKeydown(up({control: true})); + listbox.onKeydown(shift()); listbox.onKeydown(space({shift: true})); - expect(listbox.inputs.value()).toEqual(['Blackberry', 'Blueberry', 'Cantaloupe']); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); }); it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => { listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); listbox.onKeydown(down()); + listbox.onKeydown(shift()); listbox.onKeydown(home({control: true, shift: true})); - expect(listbox.inputs.value()).toEqual(['Blackberry', 'Apple', 'Apricot', 'Banana']); + expect(listbox.inputs.value()).toEqual(['Blackberry', 'Banana', 'Apricot', 'Apple']); }); it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => { @@ -423,6 +524,7 @@ describe('Listbox Pattern', () => { listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); listbox.onKeydown(down()); + listbox.onKeydown(shift()); listbox.onKeydown(end({control: true, shift: true})); expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); }); @@ -439,6 +541,24 @@ describe('Listbox Pattern', () => { listbox.onKeydown(space({control: true})); expect(listbox.inputs.value()).toEqual(['Apple']); }); + + it('should not select disabled options', () => { + options[2].disabled.set(true); + (listbox.inputs.skipDisabled as WritableSignal).set(false); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual(['Apricot']); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual([]); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual(['Blackberry']); + }); + + it('should deselect all except one option on Ctrl + A if all options are selected', () => { + listbox.onKeydown(a({control: true})); + listbox.onKeydown(a({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); }); }); @@ -509,19 +629,22 @@ describe('Listbox Pattern', () => { selectionMode: signal('explicit'), }); listbox.onPointerdown(click(options, 2)); + listbox.onKeydown(shift()); listbox.onPointerdown(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); }); - it('should deselect options from anchor on shift + click', () => { + it('should deselect options outside the range on subsequent shift + clicks', () => { const {listbox, options} = getDefaultPatterns({ multi: signal(true), selectionMode: signal('explicit'), }); listbox.onPointerdown(click(options, 2)); - listbox.onPointerdown(click(options, 5)); - listbox.onPointerdown(click(options, 2, {shift: true})); - expect(listbox.inputs.value()).toEqual([]); + listbox.onKeydown(shift()); + listbox.onPointerdown(click(options, 5, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); + listbox.onPointerdown(click(options, 0, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); }); }); @@ -563,30 +686,67 @@ describe('Listbox Pattern', () => { expect(listbox.inputs.value()).toEqual([]); }); - it('should select options from anchor on shift + click', () => { + it('should select a range of options on shift + click', () => { const {listbox, options} = getDefaultPatterns({ multi: signal(true), selectionMode: signal('follow'), }); listbox.onPointerdown(click(options, 2)); + listbox.onKeydown(shift()); listbox.onPointerdown(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); }); - it('should deselect options from anchor on shift + click', () => { + it('should deselect options outside the range on subsequent shift + clicks', () => { const {listbox, options} = getDefaultPatterns({ multi: signal(true), selectionMode: signal('follow'), }); listbox.onPointerdown(click(options, 2)); - listbox.onPointerdown(click(options, 5, {control: true})); + listbox.onKeydown(shift()); + listbox.onPointerdown(click(options, 5, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); + listbox.onPointerdown(click(options, 0, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); + }); + + it('should select a range up to but not including a disabled option on shift + click', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + skipDisabled: signal(false), + selectionMode: signal('follow'), + }); + options()[2].disabled.set(true); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(shift()); listbox.onPointerdown(click(options, 2, {shift: true})); - expect(listbox.inputs.value()).toEqual([]); + expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); + expect(listbox.inputs.activeIndex()).toEqual(2); + }); + + it('should do nothing on click if the option is disabled', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + skipDisabled: signal(true), + selectionMode: signal('follow'), + }); + options()[2].disabled.set(true); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onPointerdown(click(options, 2)); + expect(listbox.inputs.value()).toEqual(['Apple']); }); }); it('should only navigate when readonly', () => { - const {listbox, options} = getDefaultPatterns({readonly: signal(true)}); + const {listbox, options} = getDefaultPatterns({ + readonly: signal(true), + selectionMode: signal('follow'), + }); listbox.onPointerdown(click(options, 0)); expect(listbox.inputs.value()).toEqual([]); listbox.onPointerdown(click(options, 1)); @@ -594,5 +754,35 @@ describe('Listbox Pattern', () => { listbox.onPointerdown(click(options, 2)); expect(listbox.inputs.value()).toEqual([]); }); + + it('should maintain the range selection between pointer and keyboard', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 2)); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + + listbox.onKeydown(shift()); + listbox.onKeydown(space({shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry']); + listbox.onPointerdown(click(options, 0, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); + }); + + it('should select a range from the currently focused option', () => { + const {listbox, options} = getDefaultPatterns({ + multi: signal(true), + selectionMode: signal('follow'), + }); + listbox.onPointerdown(click(options, 0)); + expect(listbox.inputs.value()).toEqual(['Apple']); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(down({control: true})); + listbox.onKeydown(shift()); + listbox.onPointerdown(click(options, 4, {shift: true})); + expect(listbox.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry', 'Blueberry']); + }); }); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index ca600cf5a317..fbc54050ce9e 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -14,19 +14,15 @@ import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/li import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; -import {computed} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; /** The selection operations that the listbox can perform. */ interface SelectOptions { - select?: boolean; toggle?: boolean; - toggleOne?: boolean; selectOne?: boolean; - selectAll?: boolean; - selectFromAnchor?: boolean; - selectFromActive?: boolean; - toggleFromAnchor?: boolean; + selectRange?: boolean; + anchor?: boolean; } /** Represents the required inputs for a listbox. */ @@ -76,6 +72,9 @@ export class ListboxPattern { /** Whether the listbox selection follows focus. */ followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + /** Whether the listbox should wrap. Used to disable wrapping while range selecting. */ + wrap = signal(true); + /** The key used to navigate to the previous item in the list. */ prevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { @@ -98,6 +97,20 @@ export class ListboxPattern { /** The regexp used to decide if a key should trigger typeahead. */ typeaheadRegexp = /^.$/; // TODO: Ignore spaces? + /** + * The uncommitted index for selecting a range of options. + * + * NOTE: This is subtly distinct from the "rangeStartIndex" in the ListSelection behavior. + * The anchorIndex does not necessarily represent the start of a range, but represents the most + * recent index where the user showed intent to begin a range selection. Usually, this is wherever + * the user most recently pressed the "Shift" key, but if the user presses shift + space to select + * from the anchor, the user is not intending to start a new range from this index. + * + * In other words, "rangeStartIndex" is only set when a user commits to starting a range selection + * while "anchorIndex" is set whenever a user indicates they may be starting a range selection. + */ + anchorIndex = signal(0); + /** The keydown event manager for the listbox. */ keydown = computed(() => { const manager = new KeyboardEventManager(); @@ -131,39 +144,47 @@ export class ListboxPattern { if (this.inputs.multi()) { manager - .on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true})) - .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) - .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) - .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this._updateSelection({selectAll: true})) + .on(Modifier.Any, 'Shift', () => this.anchorIndex.set(this.inputs.activeIndex())) + .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => - this.first({selectFromActive: true}), + this.first({selectRange: true, anchor: false}), ) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => - this.last({selectFromActive: true}), + this.last({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, 'Enter', () => + this._updateSelection({selectRange: true, anchor: false}), ) .on(Modifier.Shift, this.dynamicSpaceKey, () => - this._updateSelection({selectFromAnchor: true}), + this._updateSelection({selectRange: true, anchor: false}), ); } if (!this.followFocus() && this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => this._updateSelection({toggle: true})); - manager.on('Enter', () => this._updateSelection({toggle: true})); + manager + .on(this.dynamicSpaceKey, () => this.selection.toggle()) + .on('Enter', () => this.selection.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selection.toggleAll()); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => this._updateSelection({toggleOne: true})); - manager.on('Enter', () => this._updateSelection({toggleOne: true})); + manager.on(this.dynamicSpaceKey, () => this.selection.toggleOne()); + manager.on('Enter', () => this.selection.toggleOne()); } if (this.inputs.multi() && this.followFocus()) { manager .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) - .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this._updateSelection({toggle: true})) - .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this._updateSelection({toggle: true})) - .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) // TODO: Not in spec but prob should be. - .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()); // TODO: Not in spec but prob should be. + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selection.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selection.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { + this.selection.toggleAll(); + this.selection.select(); // Ensure the currect option remains selected. + }); } return manager; @@ -177,6 +198,10 @@ export class ListboxPattern { return manager.on(e => this.goto(e)); } + if (this.multi()) { + manager.on(Modifier.Shift, e => this.goto(e, {selectRange: true})); + } + if (!this.multi() && this.followFocus()) { return manager.on(e => this.goto(e, {selectOne: true})); } @@ -188,14 +213,11 @@ export class ListboxPattern { if (this.multi() && this.followFocus()) { return manager .on(e => this.goto(e, {selectOne: true})) - .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})) - .on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true})); + .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})); } if (this.multi() && !this.followFocus()) { - return manager - .on(e => this.goto(e, {toggle: true})) - .on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true})); + return manager.on(e => this.goto(e, {toggle: true})); } return manager; @@ -207,7 +229,10 @@ export class ListboxPattern { this.orientation = inputs.orientation; this.multi = inputs.multi; - this.navigation = new ListNavigation(inputs); + this.navigation = new ListNavigation({ + ...inputs, + wrap: computed(() => this.wrap() && this.inputs.wrap()), + }); this.selection = new ListSelection({...inputs, navigation: this.navigation}); this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation}); this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); @@ -228,75 +253,73 @@ export class ListboxPattern { /** Navigates to the first option in the listbox. */ first(opts?: SelectOptions) { - this.navigation.first(); - this.focusManager.focus(); - this._updateSelection(opts); + this._navigate(opts, () => this.navigation.first()); } /** Navigates to the last option in the listbox. */ last(opts?: SelectOptions) { - this.navigation.last(); - this.focusManager.focus(); - this._updateSelection(opts); + this._navigate(opts, () => this.navigation.last()); } /** Navigates to the next option in the listbox. */ next(opts?: SelectOptions) { - this.navigation.next(); - this.focusManager.focus(); - this._updateSelection(opts); + this._navigate(opts, () => this.navigation.next()); } /** Navigates to the previous option in the listbox. */ prev(opts?: SelectOptions) { - this.navigation.prev(); - this.focusManager.focus(); - this._updateSelection(opts); + this._navigate(opts, () => this.navigation.prev()); } /** Navigates to the given item in the listbox. */ goto(event: PointerEvent, opts?: SelectOptions) { const item = this._getItem(event); + this._navigate(opts, () => this.navigation.goto(item)); + } - if (item) { - this.navigation.goto(item); + /** Handles typeahead search navigation for the listbox. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeahead.search(char)); + } + + /** + * Safely performs a navigation operation. + * + * Handles conditionally disabling wrapping for when a navigation + * operation is occurring while the user is selecting a range of options. + * + * Handles boilerplate calling of focus & selection operations. Also ensures these + * additional operations are only called if the navigation operation moved focus to a new option. + */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this.wrap.set(false); + this.selection.rangeStartIndex.set(this.anchorIndex()); + } + + const moved = operation(); + + if (moved) { this.focusManager.focus(); this._updateSelection(opts); } - } - /** Handles typeahead search navigation for the listbox. */ - search(char: string, opts?: SelectOptions) { - this.typeahead.search(char); - this.focusManager.focus(); - this._updateSelection(opts); + this.wrap.set(true); } /** Handles updating selection for the listbox. */ - private _updateSelection(opts?: SelectOptions) { - if (opts?.select) { - this.selection.select(); - } - if (opts?.toggle) { + private _updateSelection(opts: SelectOptions = {anchor: true}) { + if (opts.toggle) { this.selection.toggle(); } - if (opts?.toggleOne) { - this.selection.toggleOne(); - } - if (opts?.selectOne) { + if (opts.selectOne) { this.selection.selectOne(); } - if (opts?.selectAll) { - this.selection.selectAll(); - } - if (opts?.selectFromAnchor) { - this.selection.selectFromPrevSelectedItem(); - } - if (opts?.selectFromActive) { - this.selection.selectFromActive(); + if (opts.selectRange) { + this.selection.selectRange(); } - if (opts?.toggleFromAnchor) { - this.selection.toggleFromPrevSelectedItem(); + if (!opts.anchor) { + this.anchorIndex.set(this.selection.rangeStartIndex()); } } @@ -305,7 +328,7 @@ export class ListboxPattern { return; } - const element = e.target.closest('[role="option"]'); // TODO: Use a different identifier. + const element = e.target.closest('[role="option"]'); return this.inputs.items().find(i => i.element() === element); } } diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css index b09935f9dfe6..14c0ee9a8996 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css @@ -6,7 +6,7 @@ } .example-listbox { - gap: 8px; + gap: 4px; margin: 0; padding: 8px; max-height: 50vh; @@ -16,6 +16,7 @@ list-style: none; flex-direction: column; overflow: scroll; + user-select: none; } .example-listbox[aria-orientation='horizontal'] { @@ -40,21 +41,43 @@ padding: 16px; display: flex; cursor: pointer; + position: relative; align-items: center; border-radius: var(--mat-sys-corner-extra-small); } -.example-option:hover, +.example-option[aria-disabled='false']:hover, .example-option[tabindex='0'] { outline: 1px solid var(--mat-sys-outline); background: var(--mat-sys-surface-container); } -.example-option:focus-within { +.example-option[aria-disabled='false']:focus-within { outline: 2px solid var(--mat-sys-primary); background: var(--mat-sys-surface-container); } -.example-option[aria-selected='true'] { +.example-option[aria-disabled='false'][aria-selected='true'] { background-color: var(--mat-sys-secondary-container); } + +.example-option[aria-disabled='true']:focus-within, +.example-option[aria-disabled='true'][tabindex='0'] { + outline: 2px solid var(--mat-sys-outline); +} + +.example-option[aria-disabled='true'] span { + opacity: 0.3; +} + +.example-option[aria-disabled='true']::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: var(--mat-sys-corner-extra-small); + background-color: var(--mat-sys-on-surface); + opacity: var(--mat-sys-focus-state-layer-opacity); +} diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html index d36fca977622..1bcb00409425 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html @@ -5,6 +5,15 @@ Readonly Skip Disabled + + Disabled Options + + @for (fruit of fruits; track fruit) { + {{fruit}} + } + + + Orientation @@ -46,8 +55,19 @@ @for (fruit of fruits; track fruit) { -
  • - + @let optionDisabled = disabledOptions.includes(fruit); + +
  • + {{ fruit }}
  • } diff --git a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts index 458568c232e5..31a2ba1a5442 100644 --- a/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts +++ b/src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts @@ -27,8 +27,10 @@ export class CdkListboxExample { focusMode: 'roving' | 'activedescendant' = 'roving'; selectionMode: 'explicit' | 'follow' = 'explicit'; + disabledOptions: string[] = ['Banana', 'Cantaloupe']; + wrap = new FormControl(true, {nonNullable: true}); - multi = new FormControl(false, {nonNullable: true}); + multi = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); readonly = new FormControl(false, {nonNullable: true}); skipDisabled = new FormControl(true, {nonNullable: true});