From 716661072bc04ac93ebf28229eb6c3f738cbfe1e Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 4 Apr 2025 12:14:48 -0400 Subject: [PATCH 1/2] feat(cdk-experimental/listbox): readonly mode --- src/cdk-experimental/listbox/listbox.ts | 3 + .../ui-patterns/listbox/listbox.spec.ts | 78 ++++++++++++++++++- .../ui-patterns/listbox/listbox.ts | 20 ++++- .../cdk-listbox/cdk-listbox-example.html | 4 +- .../cdk-listbox/cdk-listbox-example.ts | 1 + 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 1ecb801377c8..972a2f01c621 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -89,6 +89,9 @@ export class CdkListbox { /** Whether the listbox is disabled. */ disabled = input(false, {transform: booleanAttribute}); + /** Whether the listbox is readonly. */ + readonly = input(false, {transform: booleanAttribute}); + /** The values of the current selected items. */ value = model([]); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index e36169990b0f..0b70d65500ec 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; +import {signal, WritableSignal} from '@angular/core'; import {ListboxInputs, ListboxPattern} from './listbox'; import {OptionPattern} from './option'; import {createKeyboardEvent} from '@angular/cdk/testing/private'; @@ -33,6 +33,7 @@ describe('Listbox Pattern', () => { activeIndex: inputs.activeIndex ?? signal(0), typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5), wrap: inputs.wrap ?? signal(true), + readonly: inputs.readonly ?? signal(false), disabled: inputs.disabled ?? signal(false), skipDisabled: inputs.skipDisabled ?? signal(true), multi: inputs.multi ?? signal(false), @@ -148,6 +149,18 @@ describe('Listbox Pattern', () => { listbox.onKeydown(end()); expect(listbox.inputs.activeIndex()).toBe(8); }); + + it('should be able to navigate in readonly mode', () => { + const {listbox} = getDefaultPatterns(); + listbox.onKeydown(down()); + expect(listbox.inputs.activeIndex()).toBe(1); + listbox.onKeydown(up()); + expect(listbox.inputs.activeIndex()).toBe(0); + listbox.onKeydown(end()); + expect(listbox.inputs.activeIndex()).toBe(8); + listbox.onKeydown(home()); + expect(listbox.inputs.activeIndex()).toBe(0); + }); }); describe('Keyboard Selection', () => { @@ -178,6 +191,22 @@ describe('Listbox Pattern', () => { expect(listbox.inputs.activeIndex()).toBe(0); expect(listbox.inputs.value()).toEqual(['Apple']); }); + + it('should not be able to change selection when in readonly mode', () => { + const {listbox} = getDefaultPatterns({ + value: signal(['Apple']), + readonly: signal(true), + multi: signal(false), + selectionMode: signal('follow'), + }); + + expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(down()); + expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); }); describe('explicit focus & single select', () => { @@ -207,6 +236,17 @@ describe('Listbox Pattern', () => { listbox.onKeydown(enter()); expect(listbox.inputs.value()).toEqual(['Apricot']); }); + + it('should not be able to change selection when in readonly mode', () => { + const readonly = listbox.inputs.readonly as WritableSignal; + readonly.set(true); + listbox.onKeydown(space()); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(down()); + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual([]); + }); }); describe('explicit focus & multi select', () => { @@ -277,6 +317,29 @@ describe('Listbox Pattern', () => { listbox.onKeydown(end({control: true, shift: true})); expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); }); + + it('should not be able to change selection when in readonly mode', () => { + const readonly = listbox.inputs.readonly as WritableSignal; + readonly.set(true); + listbox.onKeydown(space()); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(down()); + listbox.onKeydown(enter()); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(up({shift: true})); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(down({shift: true})); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(end({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual([]); + + listbox.onKeydown(home({control: true, shift: true})); + expect(listbox.inputs.value()).toEqual([]); + }); }); describe('follows focus & multi select', () => { @@ -361,6 +424,19 @@ describe('Listbox Pattern', () => { listbox.onKeydown(end({control: true, shift: true})); expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']); }); + + it('should not be able to change selection when in readonly mode', () => { + const readonly = listbox.inputs.readonly as WritableSignal; + readonly.set(true); + listbox.onKeydown(down()); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(up()); + expect(listbox.inputs.value()).toEqual(['Apple']); + + listbox.onKeydown(space({control: true})); + expect(listbox.inputs.value()).toEqual(['Apple']); + }); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 199d3f4dbf66..40950571df90 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -34,6 +34,7 @@ export type ListboxInputs = ListNavigationInputs> & ListTypeaheadInputs & ListFocusInputs> & { disabled: SignalLike; + readonly: SignalLike; }; /** Controls the state of a listbox. */ @@ -94,6 +95,15 @@ export class ListboxPattern { keydown = computed(() => { const manager = new KeyboardEventManager(); + if (this.inputs.readonly()) { + return manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.search(e.key)); + } + if (!this.followFocus()) { manager .on(this.prevKey, () => this.prev()) @@ -150,15 +160,17 @@ export class ListboxPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); + if (this.inputs.readonly()) { + manager.on(e => this.goto(e)); + } + if (this.inputs.multi()) { - manager + return manager .on(e => this.goto(e, {toggle: true})) .on(Modifier.Shift, e => this.goto(e, {selectFromActive: true})); - } else { - manager.on(e => this.goto(e, {toggleOne: true})); } - return manager; + return manager.on(e => this.goto(e, {toggleOne: true})); }); constructor(readonly inputs: ListboxInputs) { 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 7bfd5e965cf3..d36fca977622 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 @@ -2,6 +2,7 @@ Wrap Multi Disabled + Readonly Skip Disabled @@ -33,8 +34,9 @@
    Date: Fri, 4 Apr 2025 15:10:03 -0400 Subject: [PATCH 2/2] fixup! feat(cdk-experimental/listbox): readonly mode --- src/cdk-experimental/listbox/listbox.ts | 1 + src/cdk-experimental/ui-patterns/listbox/listbox.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index 972a2f01c621..ecddfed9c224 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -42,6 +42,7 @@ import {_IdGenerator} from '@angular/cdk/a11y'; 'role': 'listbox', 'class': 'cdk-listbox', '[attr.tabindex]': 'pattern.tabindex()', + '[attr.aria-readonly]': 'pattern.readonly()', '[attr.aria-disabled]': 'pattern.disabled()', '[attr.aria-orientation]': 'pattern.orientation()', '[attr.aria-multiselectable]': 'pattern.multi()', diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 40950571df90..bba0f2444ba6 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -57,6 +57,9 @@ export class ListboxPattern { /** Whether the listbox is disabled. */ disabled: SignalLike; + /** Whether the listbox is readonly. */ + readonly: SignalLike; + /** The tabindex of the listbox. */ tabindex = computed(() => this.focusManager.getListTabindex()); @@ -95,7 +98,7 @@ export class ListboxPattern { keydown = computed(() => { const manager = new KeyboardEventManager(); - if (this.inputs.readonly()) { + if (this.readonly()) { return manager .on(this.prevKey, () => this.prev()) .on(this.nextKey, () => this.next()) @@ -160,8 +163,8 @@ export class ListboxPattern { pointerdown = computed(() => { const manager = new PointerEventManager(); - if (this.inputs.readonly()) { - manager.on(e => this.goto(e)); + if (this.readonly()) { + return manager.on(e => this.goto(e)); } if (this.inputs.multi()) { @@ -175,6 +178,7 @@ export class ListboxPattern { constructor(readonly inputs: ListboxInputs) { this.disabled = inputs.disabled; + this.readonly = inputs.readonly; this.orientation = inputs.orientation; this.multi = inputs.multi;