Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export class CdkListbox<V> {
/** 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<V[]>([]);

Expand Down
78 changes: 77 additions & 1 deletion src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<boolean>;
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', () => {
Expand Down Expand Up @@ -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<boolean>;
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', () => {
Expand Down Expand Up @@ -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<boolean>;
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']);
});
});
});
});
20 changes: 16 additions & 4 deletions src/cdk-experimental/ui-patterns/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type ListboxInputs<V> = ListNavigationInputs<OptionPattern<V>> &
ListTypeaheadInputs &
ListFocusInputs<OptionPattern<V>> & {
disabled: SignalLike<boolean>;
readonly: SignalLike<boolean>;
};

/** Controls the state of a listbox. */
Expand Down Expand Up @@ -94,6 +95,15 @@ export class ListboxPattern<V> {
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())
Expand Down Expand Up @@ -150,15 +160,17 @@ export class ListboxPattern<V> {
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<V>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<mat-checkbox [formControl]="wrap">Wrap</mat-checkbox>
<mat-checkbox [formControl]="multi">Multi</mat-checkbox>
<mat-checkbox [formControl]="disabled">Disabled</mat-checkbox>
<mat-checkbox [formControl]="readonly">Readonly</mat-checkbox>
<mat-checkbox [formControl]="skipDisabled">Skip Disabled</mat-checkbox>

<mat-form-field subscriptSizing="dynamic" appearance="outline">
Expand Down Expand Up @@ -33,8 +34,9 @@
<ul
cdkListbox
[wrap]="wrap.value"
[disabled]="disabled.value"
[multi]="multi.value"
[readonly]="readonly.value"
[disabled]="disabled.value"
[skipDisabled]="skipDisabled.value"
[orientation]="orientation"
[focusMode]="focusMode"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class CdkListboxExample {
wrap = new FormControl(true, {nonNullable: true});
multi = new FormControl(false, {nonNullable: true});
disabled = new FormControl(false, {nonNullable: true});
readonly = new FormControl(false, {nonNullable: true});
skipDisabled = new FormControl(true, {nonNullable: true});

fruits = [
Expand Down
Loading