Skip to content

Commit eedc5a6

Browse files
authored
fix(aria/combobox): readonly behavior (#32169)
* fix(aria/combobox): readonly behavior * docs(aria/combobox): dev app ui changes * docs(aria/combobox): readonly example * fixup! docs(aria/combobox): readonly example * fixup! fix(aria/combobox): readonly behavior
1 parent 8947758 commit eedc5a6

File tree

16 files changed

+333
-36
lines changed

16 files changed

+333
-36
lines changed

src/aria/combobox/combobox.spec.ts

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,19 @@ describe('Combobox', () => {
5252
const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys);
5353
const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys);
5454

55-
function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) {
55+
function setupCombobox(
56+
opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {},
57+
) {
5658
TestBed.configureTestingModule({});
5759
fixture = TestBed.createComponent(ComboboxListboxExample);
5860
const testComponent = fixture.componentInstance;
5961

6062
if (opts.filterMode) {
6163
testComponent.filterMode.set(opts.filterMode);
6264
}
65+
if (opts.readonly) {
66+
testComponent.readonly.set(true);
67+
}
6368

6469
fixture.detectChanges();
6570
defineTestVariables();
@@ -526,6 +531,35 @@ describe('Combobox', () => {
526531
});
527532
});
528533

534+
describe('Readonly', () => {
535+
beforeEach(() => setupCombobox({readonly: true}));
536+
537+
it('should close on selection', () => {
538+
focus();
539+
down();
540+
click(getOption('Alabama')!);
541+
expect(inputElement.value).toBe('Alabama');
542+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
543+
});
544+
545+
it('should close on escape', () => {
546+
focus();
547+
down();
548+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
549+
escape();
550+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
551+
});
552+
553+
it('should clear selection on escape when closed', () => {
554+
focus();
555+
down();
556+
enter();
557+
expect(inputElement.value).toBe('Alabama');
558+
escape();
559+
expect(inputElement.value).toBe('');
560+
});
561+
});
562+
529563
// describe('with programmatic value changes', () => {
530564
// // TODO(wagnermaciel): Figure out if there's a way to automatically update the
531565
// // input value when the popup value signal is updated programmatically.
@@ -590,14 +624,19 @@ describe('Combobox', () => {
590624
const enter = (modifierKeys?: {}) => keydown('Enter', modifierKeys);
591625
const escape = (modifierKeys?: {}) => keydown('Escape', modifierKeys);
592626

593-
function setupCombobox(opts: {filterMode?: 'manual' | 'auto-select' | 'highlight'} = {}) {
627+
function setupCombobox(
628+
opts: {readonly?: boolean; filterMode?: 'manual' | 'auto-select' | 'highlight'} = {},
629+
) {
594630
TestBed.configureTestingModule({});
595631
fixture = TestBed.createComponent(ComboboxTreeExample);
596632
const testComponent = fixture.componentInstance;
597633

598634
if (opts.filterMode) {
599635
testComponent.filterMode.set(opts.filterMode);
600636
}
637+
if (opts.readonly) {
638+
testComponent.readonly.set(true);
639+
}
601640

602641
fixture.detectChanges();
603642
defineTestVariables();
@@ -1053,6 +1092,40 @@ describe('Combobox', () => {
10531092
expect(getTreeItem('August')!.getAttribute('aria-selected')).toBe('true');
10541093
});
10551094
});
1095+
1096+
describe('Readonly', () => {
1097+
beforeEach(() => setupCombobox({readonly: true}));
1098+
1099+
it('should close on selection', () => {
1100+
focus();
1101+
down();
1102+
right();
1103+
right();
1104+
enter();
1105+
expect(inputElement.value).toBe('December');
1106+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
1107+
});
1108+
1109+
it('should close on escape', () => {
1110+
focus();
1111+
down();
1112+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
1113+
escape();
1114+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
1115+
});
1116+
1117+
it('should clear selection on escape when closed', () => {
1118+
focus();
1119+
down();
1120+
right();
1121+
right();
1122+
enter();
1123+
expect(inputElement.value).toBe('December');
1124+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
1125+
escape();
1126+
expect(inputElement.value).toBe('');
1127+
});
1128+
});
10561129
});
10571130
});
10581131

@@ -1061,6 +1134,7 @@ describe('Combobox', () => {
10611134
<div
10621135
ngCombobox
10631136
#combobox="ngCombobox"
1137+
[readonly]="readonly()"
10641138
[filterMode]="filterMode()"
10651139
>
10661140
<input
@@ -1087,12 +1161,11 @@ describe('Combobox', () => {
10871161
imports: [Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer, Listbox, Option],
10881162
})
10891163
class ComboboxListboxExample {
1164+
readonly = signal(false);
1165+
searchString = signal('');
10901166
value = signal<string[]>([]);
1091-
10921167
filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual');
10931168

1094-
searchString = signal('');
1095-
10961169
options = computed(() =>
10971170
states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
10981171
);
@@ -1103,6 +1176,7 @@ class ComboboxListboxExample {
11031176
<div
11041177
ngCombobox
11051178
#combobox="ngCombobox"
1179+
[readonly]="readonly()"
11061180
[firstMatch]="firstMatch()"
11071181
[filterMode]="filterMode()"
11081182
>
@@ -1157,13 +1231,11 @@ class ComboboxListboxExample {
11571231
],
11581232
})
11591233
class ComboboxTreeExample {
1160-
value = signal<string[]>([]);
1161-
1162-
filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual');
1163-
1234+
readonly = signal(false);
11641235
searchString = signal('');
1165-
1236+
value = signal<string[]>([]);
11661237
nodes = computed(() => this.filterTreeNodes(TREE_NODES));
1238+
filterMode = signal<'manual' | 'auto-select' | 'highlight'>('manual');
11671239

11681240
firstMatch = computed<string | undefined>(() => {
11691241
const flatNodes = this.flattenTreeNodes(this.nodes());

src/aria/combobox/combobox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export class Combobox<V> {
119119
'[attr.aria-controls]': 'combobox.pattern.popupId()',
120120
'[attr.aria-haspopup]': 'combobox.pattern.hasPopup()',
121121
'[attr.aria-autocomplete]': 'combobox.pattern.autocomplete()',
122+
'[attr.readonly]': 'combobox.pattern.readonly()',
122123
},
123124
})
124125
export class ComboboxInput {

src/aria/ui-patterns/combobox/combobox.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,35 @@ describe('Combobox with Listbox Pattern', () => {
585585
});
586586
});
587587
});
588+
589+
describe('Readonly mode', () => {
590+
it('should select and close on selection', () => {
591+
const {combobox, listbox, inputEl} = getPatterns({readonly: true});
592+
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
593+
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
594+
expect(listbox.inputs.value()).toEqual(['Banana']);
595+
expect(inputEl.value).toBe('Banana');
596+
expect(combobox.expanded()).toBe(false);
597+
});
598+
599+
it('should close on escape', () => {
600+
const {combobox} = getPatterns({readonly: true});
601+
combobox.onKeydown(down());
602+
expect(combobox.expanded()).toBe(true);
603+
combobox.onKeydown(escape());
604+
expect(combobox.expanded()).toBe(false);
605+
});
606+
607+
it('should clear selection on escape when already closed', () => {
608+
const {combobox, listbox} = getPatterns({readonly: true});
609+
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
610+
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
611+
expect(listbox.inputs.value()).toEqual(['Banana']);
612+
combobox.onKeydown(escape());
613+
expect(listbox.getSelectedItem()).toBe(undefined);
614+
expect(listbox.inputs.value()).toEqual([]);
615+
});
616+
});
588617
});
589618

590619
describe('Combobox with Tree Pattern', () => {
@@ -894,4 +923,36 @@ describe('Combobox with Tree Pattern', () => {
894923
});
895924
});
896925
});
926+
927+
describe('Readonly mode', () => {
928+
it('should select and close on selection', () => {
929+
const {combobox, tree, inputEl} = getPatterns({readonly: true});
930+
combobox.onPointerup(clickInput(inputEl));
931+
expect(combobox.expanded()).toBe(true);
932+
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
933+
expect(tree.inputs.value()).toEqual(['Fruit']);
934+
expect(inputEl.value).toBe('Fruit');
935+
expect(combobox.expanded()).toBe(false);
936+
});
937+
938+
it('should close on escape', () => {
939+
const {combobox} = getPatterns({readonly: true});
940+
combobox.onKeydown(down());
941+
expect(combobox.expanded()).toBe(true);
942+
combobox.onKeydown(escape());
943+
expect(combobox.expanded()).toBe(false);
944+
});
945+
946+
it('should clear selection on escape when already closed', () => {
947+
const {combobox, tree, inputEl} = getPatterns({readonly: true});
948+
combobox.onPointerup(clickInput(inputEl));
949+
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
950+
expect(tree.inputs.value()).toEqual(['Fruit']);
951+
expect(inputEl.value).toBe('Fruit');
952+
expect(combobox.expanded()).toBe(false);
953+
combobox.onKeydown(escape());
954+
expect(tree.inputs.value()).toEqual([]);
955+
expect(inputEl.value).toBe('');
956+
});
957+
});
897958
});

src/aria/ui-patterns/combobox/combobox.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,24 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
144144
/** The ARIA role of the popup associated with the combobox. */
145145
hasPopup = computed(() => this.inputs.popupControls()?.role() || null);
146146

147-
/** Whether the combobox is interactive. */
148-
isInteractive = computed(() => !this.inputs.disabled() && !this.inputs.readonly());
147+
/** Whether the combobox is read-only. */
148+
readonly = computed(() => this.inputs.readonly() || null);
149149

150150
/** The keydown event manager for the combobox. */
151151
keydown = computed(() => {
152152
if (!this.expanded()) {
153-
return new KeyboardEventManager()
153+
const manager = new KeyboardEventManager()
154154
.on('ArrowDown', () => this.open({first: true}))
155155
.on('ArrowUp', () => this.open({last: true}))
156156
.on('Escape', () => this.close({reset: true}));
157+
158+
if (this.readonly()) {
159+
manager
160+
.on('Enter', () => this.open({selected: true}))
161+
.on(' ', () => this.open({selected: true}));
162+
}
163+
164+
return manager;
157165
}
158166

159167
const popupControls = this.inputs.popupControls();
@@ -170,6 +178,10 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
170178
.on('Escape', () => this.close({reset: true}))
171179
.on('Enter', () => this.select({commit: true, close: true}));
172180

181+
if (this.readonly()) {
182+
manager.on(' ', () => this.select({commit: true, close: true}));
183+
}
184+
173185
if (popupControls.role() === 'tree') {
174186
const treeControls = popupControls as ComboboxTreeControls<T, V>;
175187

@@ -196,7 +208,11 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
196208
}
197209

198210
if (e.target === this.inputs.inputEl()) {
199-
this.open();
211+
if (this.readonly()) {
212+
this.expanded() ? this.close() : this.open({selected: true});
213+
} else {
214+
this.open();
215+
}
200216
}
201217
}),
202218
);
@@ -205,21 +221,21 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
205221

206222
/** Handles keydown events for the combobox. */
207223
onKeydown(event: KeyboardEvent) {
208-
if (this.isInteractive()) {
224+
if (!this.inputs.disabled()) {
209225
this.keydown().handle(event);
210226
}
211227
}
212228

213229
/** Handles pointerup events for the combobox. */
214230
onPointerup(event: PointerEvent) {
215-
if (this.isInteractive()) {
231+
if (!this.inputs.disabled()) {
216232
this.pointerup().handle(event);
217233
}
218234
}
219235

220236
/** Handles input events for the combobox. */
221237
onInput(event: Event) {
222-
if (!this.isInteractive()) {
238+
if (this.inputs.disabled() || this.inputs.readonly()) {
223239
return;
224240
}
225241

@@ -253,7 +269,7 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
253269

254270
/** Handles focus out events for the combobox. */
255271
onFocusOut(event: FocusEvent) {
256-
if (this.inputs.disabled() || this.inputs.readonly()) {
272+
if (this.inputs.disabled()) {
257273
return;
258274
}
259275

@@ -385,18 +401,23 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
385401
popupControls?.clearSelection();
386402
}
387403
}
404+
405+
this.close();
406+
407+
if (!this.readonly()) {
408+
this.inputs.popupControls()?.clearSelection();
409+
}
388410
}
389411

390412
/** Opens the combobox. */
391-
open(nav?: {first?: boolean; last?: boolean}) {
413+
open(nav?: {first?: boolean; last?: boolean; selected?: boolean}) {
392414
this.expanded.set(true);
393415

394416
const inputEl = this.inputs.inputEl();
395417

396-
if (inputEl) {
418+
if (inputEl && this.inputs.filterMode() === 'highlight') {
397419
const isHighlighting = inputEl.selectionStart !== inputEl.value.length;
398420
this.inputs.inputValue?.set(inputEl.value.slice(0, inputEl.selectionStart || 0));
399-
400421
if (!isHighlighting) {
401422
this.highlightedItem.set(undefined);
402423
}
@@ -408,6 +429,10 @@ export class ComboboxPattern<T extends ListItem<V>, V> {
408429
if (nav?.last) {
409430
this.last();
410431
}
432+
if (nav?.selected) {
433+
const selectedItem = this.inputs.popupControls()?.getSelectedItem();
434+
selectedItem ? this.inputs.popupControls()?.focus(selectedItem) : this.first();
435+
}
411436
}
412437

413438
/** Navigates to the next focusable item in the combobox popup. */

src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class ComboboxAutoSelectExample {
6262

6363
if (comboboxRect) {
6464
popoverEl.style.width = `${comboboxRect.width}px`;
65-
popoverEl.style.top = `${comboboxRect.bottom}px`;
65+
popoverEl.style.top = `${comboboxRect.bottom + 4}px`;
6666
popoverEl.style.left = `${comboboxRect.left - 1}px`;
6767
}
6868

0 commit comments

Comments
 (0)