Skip to content

Commit 6ac4c1a

Browse files
authored
feat(cdk-experimental/listbox): readonly mode (#30803)
* feat(cdk-experimental/listbox): readonly mode * fixup! feat(cdk-experimental/listbox): readonly mode
1 parent 5e45a75 commit 6ac4c1a

File tree

5 files changed

+105
-6
lines changed

5 files changed

+105
-6
lines changed

Diff for: src/cdk-experimental/listbox/listbox.ts

+4
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {_IdGenerator} from '@angular/cdk/a11y';
4242
'role': 'listbox',
4343
'class': 'cdk-listbox',
4444
'[attr.tabindex]': 'pattern.tabindex()',
45+
'[attr.aria-readonly]': 'pattern.readonly()',
4546
'[attr.aria-disabled]': 'pattern.disabled()',
4647
'[attr.aria-orientation]': 'pattern.orientation()',
4748
'[attr.aria-multiselectable]': 'pattern.multi()',
@@ -89,6 +90,9 @@ export class CdkListbox<V> {
8990
/** Whether the listbox is disabled. */
9091
disabled = input(false, {transform: booleanAttribute});
9192

93+
/** Whether the listbox is readonly. */
94+
readonly = input(false, {transform: booleanAttribute});
95+
9296
/** The values of the current selected items. */
9397
value = model<V[]>([]);
9498

Diff for: src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts

+77-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {signal} from '@angular/core';
9+
import {signal, WritableSignal} from '@angular/core';
1010
import {ListboxInputs, ListboxPattern} from './listbox';
1111
import {OptionPattern} from './option';
1212
import {createKeyboardEvent} from '@angular/cdk/testing/private';
@@ -33,6 +33,7 @@ describe('Listbox Pattern', () => {
3333
activeIndex: inputs.activeIndex ?? signal(0),
3434
typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5),
3535
wrap: inputs.wrap ?? signal(true),
36+
readonly: inputs.readonly ?? signal(false),
3637
disabled: inputs.disabled ?? signal(false),
3738
skipDisabled: inputs.skipDisabled ?? signal(true),
3839
multi: inputs.multi ?? signal(false),
@@ -148,6 +149,18 @@ describe('Listbox Pattern', () => {
148149
listbox.onKeydown(end());
149150
expect(listbox.inputs.activeIndex()).toBe(8);
150151
});
152+
153+
it('should be able to navigate in readonly mode', () => {
154+
const {listbox} = getDefaultPatterns();
155+
listbox.onKeydown(down());
156+
expect(listbox.inputs.activeIndex()).toBe(1);
157+
listbox.onKeydown(up());
158+
expect(listbox.inputs.activeIndex()).toBe(0);
159+
listbox.onKeydown(end());
160+
expect(listbox.inputs.activeIndex()).toBe(8);
161+
listbox.onKeydown(home());
162+
expect(listbox.inputs.activeIndex()).toBe(0);
163+
});
151164
});
152165

153166
describe('Keyboard Selection', () => {
@@ -178,6 +191,22 @@ describe('Listbox Pattern', () => {
178191
expect(listbox.inputs.activeIndex()).toBe(0);
179192
expect(listbox.inputs.value()).toEqual(['Apple']);
180193
});
194+
195+
it('should not be able to change selection when in readonly mode', () => {
196+
const {listbox} = getDefaultPatterns({
197+
value: signal(['Apple']),
198+
readonly: signal(true),
199+
multi: signal(false),
200+
selectionMode: signal('follow'),
201+
});
202+
203+
expect(listbox.inputs.activeIndex()).toBe(0);
204+
expect(listbox.inputs.value()).toEqual(['Apple']);
205+
206+
listbox.onKeydown(down());
207+
expect(listbox.inputs.activeIndex()).toBe(1);
208+
expect(listbox.inputs.value()).toEqual(['Apple']);
209+
});
181210
});
182211

183212
describe('explicit focus & single select', () => {
@@ -207,6 +236,17 @@ describe('Listbox Pattern', () => {
207236
listbox.onKeydown(enter());
208237
expect(listbox.inputs.value()).toEqual(['Apricot']);
209238
});
239+
240+
it('should not be able to change selection when in readonly mode', () => {
241+
const readonly = listbox.inputs.readonly as WritableSignal<boolean>;
242+
readonly.set(true);
243+
listbox.onKeydown(space());
244+
expect(listbox.inputs.value()).toEqual([]);
245+
246+
listbox.onKeydown(down());
247+
listbox.onKeydown(enter());
248+
expect(listbox.inputs.value()).toEqual([]);
249+
});
210250
});
211251

212252
describe('explicit focus & multi select', () => {
@@ -277,6 +317,29 @@ describe('Listbox Pattern', () => {
277317
listbox.onKeydown(end({control: true, shift: true}));
278318
expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']);
279319
});
320+
321+
it('should not be able to change selection when in readonly mode', () => {
322+
const readonly = listbox.inputs.readonly as WritableSignal<boolean>;
323+
readonly.set(true);
324+
listbox.onKeydown(space());
325+
expect(listbox.inputs.value()).toEqual([]);
326+
327+
listbox.onKeydown(down());
328+
listbox.onKeydown(enter());
329+
expect(listbox.inputs.value()).toEqual([]);
330+
331+
listbox.onKeydown(up({shift: true}));
332+
expect(listbox.inputs.value()).toEqual([]);
333+
334+
listbox.onKeydown(down({shift: true}));
335+
expect(listbox.inputs.value()).toEqual([]);
336+
337+
listbox.onKeydown(end({control: true, shift: true}));
338+
expect(listbox.inputs.value()).toEqual([]);
339+
340+
listbox.onKeydown(home({control: true, shift: true}));
341+
expect(listbox.inputs.value()).toEqual([]);
342+
});
280343
});
281344

282345
describe('follows focus & multi select', () => {
@@ -361,6 +424,19 @@ describe('Listbox Pattern', () => {
361424
listbox.onKeydown(end({control: true, shift: true}));
362425
expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']);
363426
});
427+
428+
it('should not be able to change selection when in readonly mode', () => {
429+
const readonly = listbox.inputs.readonly as WritableSignal<boolean>;
430+
readonly.set(true);
431+
listbox.onKeydown(down());
432+
expect(listbox.inputs.value()).toEqual(['Apple']);
433+
434+
listbox.onKeydown(up());
435+
expect(listbox.inputs.value()).toEqual(['Apple']);
436+
437+
listbox.onKeydown(space({control: true}));
438+
expect(listbox.inputs.value()).toEqual(['Apple']);
439+
});
364440
});
365441
});
366442
});

Diff for: src/cdk-experimental/ui-patterns/listbox/listbox.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type ListboxInputs<V> = ListNavigationInputs<OptionPattern<V>> &
3434
ListTypeaheadInputs &
3535
ListFocusInputs<OptionPattern<V>> & {
3636
disabled: SignalLike<boolean>;
37+
readonly: SignalLike<boolean>;
3738
};
3839

3940
/** Controls the state of a listbox. */
@@ -56,6 +57,9 @@ export class ListboxPattern<V> {
5657
/** Whether the listbox is disabled. */
5758
disabled: SignalLike<boolean>;
5859

60+
/** Whether the listbox is readonly. */
61+
readonly: SignalLike<boolean>;
62+
5963
/** The tabindex of the listbox. */
6064
tabindex = computed(() => this.focusManager.getListTabindex());
6165

@@ -94,6 +98,15 @@ export class ListboxPattern<V> {
9498
keydown = computed(() => {
9599
const manager = new KeyboardEventManager();
96100

101+
if (this.readonly()) {
102+
return manager
103+
.on(this.prevKey, () => this.prev())
104+
.on(this.nextKey, () => this.next())
105+
.on('Home', () => this.first())
106+
.on('End', () => this.last())
107+
.on(this.typeaheadRegexp, e => this.search(e.key));
108+
}
109+
97110
if (!this.followFocus()) {
98111
manager
99112
.on(this.prevKey, () => this.prev())
@@ -150,19 +163,22 @@ export class ListboxPattern<V> {
150163
pointerdown = computed(() => {
151164
const manager = new PointerEventManager();
152165

166+
if (this.readonly()) {
167+
return manager.on(e => this.goto(e));
168+
}
169+
153170
if (this.inputs.multi()) {
154-
manager
171+
return manager
155172
.on(e => this.goto(e, {toggle: true}))
156173
.on(Modifier.Shift, e => this.goto(e, {selectFromActive: true}));
157-
} else {
158-
manager.on(e => this.goto(e, {toggleOne: true}));
159174
}
160175

161-
return manager;
176+
return manager.on(e => this.goto(e, {toggleOne: true}));
162177
});
163178

164179
constructor(readonly inputs: ListboxInputs<V>) {
165180
this.disabled = inputs.disabled;
181+
this.readonly = inputs.readonly;
166182
this.orientation = inputs.orientation;
167183
this.multi = inputs.multi;
168184

Diff for: src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.html

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<mat-checkbox [formControl]="wrap">Wrap</mat-checkbox>
33
<mat-checkbox [formControl]="multi">Multi</mat-checkbox>
44
<mat-checkbox [formControl]="disabled">Disabled</mat-checkbox>
5+
<mat-checkbox [formControl]="readonly">Readonly</mat-checkbox>
56
<mat-checkbox [formControl]="skipDisabled">Skip Disabled</mat-checkbox>
67

78
<mat-form-field subscriptSizing="dynamic" appearance="outline">
@@ -33,8 +34,9 @@
3334
<ul
3435
cdkListbox
3536
[wrap]="wrap.value"
36-
[disabled]="disabled.value"
3737
[multi]="multi.value"
38+
[readonly]="readonly.value"
39+
[disabled]="disabled.value"
3840
[skipDisabled]="skipDisabled.value"
3941
[orientation]="orientation"
4042
[focusMode]="focusMode"

Diff for: src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class CdkListboxExample {
3030
wrap = new FormControl(true, {nonNullable: true});
3131
multi = new FormControl(false, {nonNullable: true});
3232
disabled = new FormControl(false, {nonNullable: true});
33+
readonly = new FormControl(false, {nonNullable: true});
3334
skipDisabled = new FormControl(true, {nonNullable: true});
3435

3536
fruits = [

0 commit comments

Comments
 (0)