Skip to content

Commit b01ffb8

Browse files
committed
fix(aria/combobox): allow setting readonly
1 parent 03685a9 commit b01ffb8

20 files changed

Lines changed: 394 additions & 361 deletions

File tree

goldens/aria/combobox/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ export class Combobox extends DeferredContentAware implements OnInit {
2424
ngOnInit(): void;
2525
readonly _pattern: ComboboxPattern;
2626
readonly _popup: _angular_core.WritableSignal<ComboboxPopup | undefined>;
27+
readonly readonly: _angular_core.InputSignalWithTransform<boolean, unknown>;
2728
_registerPopup(popup: ComboboxPopup): void;
2829
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
2930
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
3031
_unregisterPopup(): void;
3132
readonly value: _angular_core.ModelSignal<string>;
3233
// (undocumented)
33-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Combobox, "[ngCombobox]", ["ngCombobox"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabindex"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "inlineSuggestion": { "alias": "inlineSuggestion"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; "value": "valueChange"; }, never, never, true, never>;
34+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Combobox, "[ngCombobox]", ["ngCombobox"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "required": false; "isSignal": true; }; "tabIndex": { "alias": "tabindex"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "inlineSuggestion": { "alias": "inlineSuggestion"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; "value": "valueChange"; }, never, never, true, never>;
3435
// (undocumented)
3536
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Combobox, never>;
3637
}

goldens/aria/private/index.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface ComboboxInputs extends ExpansionItem {
6767
element: SignalLike<HTMLElement>;
6868
inlineSuggestion: SignalLike<string | undefined>;
6969
popup: SignalLike<ComboboxPopupPattern | undefined>;
70+
readonly: SignalLike<boolean>;
7071
softDisabled?: SignalLike<boolean>;
7172
value: WritableSignalLike<string>;
7273
}
@@ -75,6 +76,7 @@ export interface ComboboxInputs extends ExpansionItem {
7576
export class ComboboxPattern {
7677
constructor(inputs: ComboboxInputs);
7778
readonly activeDescendant: _angular_core.Signal<string | undefined>;
79+
readonly ariaReadonly: _angular_core.Signal<"true" | null>;
7880
readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">;
7981
click: _angular_core.Signal<ClickEventManager<PointerEvent>>;
8082
closePopupOnBlurEffect(): void;
@@ -91,13 +93,16 @@ export class ComboboxPattern {
9193
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent | undefined>;
9294
keyboardEventRelayEffect(): void;
9395
keydown: _angular_core.Signal<KeyboardEventManager<KeyboardEvent>>;
96+
readonly nativeDisabled: _angular_core.Signal<"" | null>;
97+
readonly nativeReadonly: _angular_core.Signal<"" | null>;
9498
onClick(event: PointerEvent): void;
9599
onFocusin(): void;
96100
onFocusout(event: FocusEvent): void;
97101
onInput(event: Event): void;
98102
onKeydown(event: KeyboardEvent): void;
99103
readonly popupId: _angular_core.Signal<string | undefined>;
100104
readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>;
105+
readonly readonly: () => boolean;
101106
readonly softDisabled: () => boolean;
102107
readonly value: WritableSignalLike<string>;
103108
}

src/aria/combobox/combobox.spec.ts

Lines changed: 94 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -525,111 +525,116 @@ describe('Combobox', () => {
525525
});
526526
});
527527

528-
describe('Readonly', () => {
529-
beforeEach(async () => await setupCombobox(ComboboxListboxExample, {readonly: true}));
528+
describe('Interactivity and Disablement (disabled, softDisabled, readonly)', () => {
529+
describe('Readonly', () => {
530+
beforeEach(async () => await setupCombobox(ComboboxListboxExample, {readonly: true}));
530531

531-
it('should close on selection', async () => {
532-
await focus();
533-
await down();
534-
await click(getOption('Alabama')!);
535-
expect(inputElement.value).toBe('Alabama');
536-
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
537-
});
532+
it('should set native readonly attribute on editable inputs without assigning disabled', () => {
533+
expect(inputElement.hasAttribute('readonly')).toBe(true);
534+
expect(inputElement.hasAttribute('disabled')).toBe(false);
535+
});
538536

539-
it('should close on escape', async () => {
540-
await focus();
541-
await down();
542-
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
543-
await escape();
544-
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
545-
});
546-
});
537+
it('should suppress typing when readonly', async () => {
538+
await focus();
539+
inputElement.value = 'New';
540+
inputElement.dispatchEvent(new Event('input'));
541+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
542+
});
547543

548-
describe('Always Expanded', () => {
549-
beforeEach(async () => await setupCombobox());
544+
it('should block expansion on arrow down when readonly', async () => {
545+
await focus();
546+
await down();
547+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
548+
});
549+
});
550550

551-
it('should not close on escape when alwaysExpanded is true', async () => {
552-
fixture.componentInstance.alwaysExpanded.set(true);
553-
await fixture.whenStable();
554-
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
551+
describe('Disabled (Soft and Hard)', () => {
552+
beforeEach(async () => await setupCombobox());
555553

556-
await escape();
557-
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
558-
});
554+
it('should keep the input focusable by default when disabled', async () => {
555+
fixture.componentInstance.disabled.set(true);
556+
await fixture.whenStable();
559557

560-
it('should automatically report as expanded when alwaysExpanded is true', async () => {
561-
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
562-
fixture.componentInstance.alwaysExpanded.set(true);
563-
await fixture.whenStable();
564-
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
565-
});
566-
});
558+
expect(inputElement.disabled).toBe(false);
559+
expect(inputElement.getAttribute('disabled')).toBeNull();
560+
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
561+
});
567562

568-
describe('Disabled', () => {
569-
beforeEach(async () => await setupCombobox());
563+
it('should assign readonly when disabled and softDisabled is true on editable inputs', async () => {
564+
fixture.componentInstance.disabled.set(true);
565+
await fixture.whenStable();
570566

571-
it('should keep the input focusable by default when disabled', async () => {
572-
fixture.componentInstance.disabled.set(true);
573-
await fixture.whenStable();
567+
expect(inputElement.hasAttribute('readonly')).toBe(true);
568+
});
574569

575-
expect(inputElement.disabled).toBe(false);
576-
expect(inputElement.getAttribute('disabled')).toBeNull();
577-
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
578-
});
570+
it('should block interactions when disabled', async () => {
571+
fixture.componentInstance.disabled.set(true);
572+
await fixture.whenStable();
579573

580-
it('should make the input read-only when disabled and softDisabled is true', async () => {
581-
fixture.componentInstance.disabled.set(true);
582-
await fixture.whenStable();
574+
await focus();
575+
await keydown('ArrowDown');
576+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
577+
});
583578

584-
expect(inputElement.getAttribute('readonly')).toBe('');
585-
});
579+
it('should assign disabled, readonly, and aria-disabled when hard-disabled', async () => {
580+
fixture.componentInstance.disabled.set(true);
581+
fixture.componentInstance.softDisabled.set(false);
582+
await fixture.whenStable();
586583

587-
it('should block interactions when disabled', async () => {
588-
fixture.componentInstance.disabled.set(true);
589-
await fixture.whenStable();
584+
expect(inputElement.disabled).toBe(true);
585+
expect(inputElement.getAttribute('disabled')).toBe('');
586+
expect(inputElement.hasAttribute('readonly')).toBe(true);
587+
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
588+
});
590589

591-
await focus();
592-
await keydown('ArrowDown');
593-
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
594-
});
590+
it('should respect user-defined tabindex when softDisabled is true', async () => {
591+
fixture.componentInstance.disabled.set(true);
592+
fixture.componentInstance.tabIndex.set(0);
593+
await fixture.whenStable();
595594

596-
it('should make the input unfocusable when softDisabled is false', async () => {
597-
fixture.componentInstance.disabled.set(true);
598-
fixture.componentInstance.softDisabled.set(false);
599-
await fixture.whenStable();
595+
expect(inputElement.getAttribute('tabindex')).toBe('0');
596+
});
600597

601-
expect(inputElement.disabled).toBe(true);
602-
expect(inputElement.getAttribute('disabled')).toBe('');
603-
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
604-
});
598+
it('should respect user-defined tabindex when not disabled', async () => {
599+
fixture.componentInstance.tabIndex.set(0);
600+
await fixture.whenStable();
605601

606-
it('should respect user-defined tabindex when softDisabled is true', async () => {
607-
fixture.componentInstance.disabled.set(true);
608-
fixture.componentInstance.tabIndex.set(0);
609-
await fixture.whenStable();
602+
expect(inputElement.getAttribute('tabindex')).toBe('0');
603+
});
610604

611-
expect(inputElement.getAttribute('tabindex')).toBe('0');
612-
});
605+
it('should default to tabindex 0 when not disabled', async () => {
606+
await fixture.whenStable();
607+
expect(inputElement.getAttribute('tabindex')).toBe('0');
608+
});
613609

614-
it('should respect user-defined tabindex when not disabled', async () => {
615-
fixture.componentInstance.tabIndex.set(0);
616-
await fixture.whenStable();
610+
it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', async () => {
611+
fixture.componentInstance.disabled.set(true);
612+
fixture.componentInstance.softDisabled.set(false);
613+
fixture.componentInstance.tabIndex.set(0);
614+
await fixture.whenStable();
617615

618-
expect(inputElement.getAttribute('tabindex')).toBe('0');
616+
expect(inputElement.getAttribute('tabindex')).toBe('-1');
617+
});
619618
});
619+
});
620620

621-
it('should default to tabindex 0 when not disabled', async () => {
621+
describe('Always Expanded', () => {
622+
beforeEach(async () => await setupCombobox());
623+
624+
it('should not close on escape when alwaysExpanded is true', async () => {
625+
fixture.componentInstance.alwaysExpanded.set(true);
622626
await fixture.whenStable();
623-
expect(inputElement.getAttribute('tabindex')).toBe('0');
627+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
628+
629+
await escape();
630+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
624631
});
625632

626-
it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', async () => {
627-
fixture.componentInstance.disabled.set(true);
628-
fixture.componentInstance.softDisabled.set(false);
629-
fixture.componentInstance.tabIndex.set(0);
633+
it('should automatically report as expanded when alwaysExpanded is true', async () => {
634+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
635+
fixture.componentInstance.alwaysExpanded.set(true);
630636
await fixture.whenStable();
631-
632-
expect(inputElement.getAttribute('tabindex')).toBe('-1');
637+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
633638
});
634639
});
635640
});
@@ -1341,7 +1346,8 @@ function getTreeNodes(): TreeNode[] {
13411346
placeholder="Search..."
13421347
[(value)]="searchString"
13431348
[(expanded)]="popupExpanded"
1344-
[disabled]="readonly()"
1349+
[readonly]="readonly()"
1350+
[disabled]="disabled()"
13451351
(focusout)="onBlur()"
13461352
/>
13471353
@@ -1394,6 +1400,7 @@ class ComboboxTreeExample {
13941400
readonly tree = viewChild(Tree);
13951401

13961402
readonly = signal(false);
1403+
disabled = signal(false);
13971404
popupExpanded = signal(false);
13981405
searchString = signal('');
13991406
value = signal<string[]>([]);
@@ -1591,7 +1598,8 @@ class ComboboxGridExample {
15911598
placeholder="Search..."
15921599
[(value)]="searchString"
15931600
(input)="onInput()"
1594-
[disabled]="readonly()"
1601+
[readonly]="readonly()"
1602+
[disabled]="disabled()"
15951603
(focusout)="onBlur()"
15961604
(click)="combobox.expanded.set(true)"
15971605
/>
@@ -1611,6 +1619,7 @@ class ComboboxGridExample {
16111619
})
16121620
class ComboboxListboxAutoSelectExample {
16131621
readonly = signal(false);
1622+
disabled = signal(false);
16141623
popupExpanded = signal(false);
16151624
searchString = signal('');
16161625
value = signal<string[]>([]);
@@ -1656,7 +1665,8 @@ class ComboboxListboxAutoSelectExample {
16561665
[(value)]="searchString"
16571666
[(expanded)]="popupExpanded"
16581667
[inlineSuggestion]="value()[0] || options()[0]"
1659-
[disabled]="readonly()"
1668+
[readonly]="readonly()"
1669+
[disabled]="disabled()"
16601670
(click)="popupExpanded.set(true)"
16611671
/>
16621672
@@ -1676,6 +1686,7 @@ class ComboboxListboxAutoSelectExample {
16761686
class ComboboxListboxHighlightExample {
16771687
readonly combobox = viewChild(Combobox);
16781688
readonly = signal(false);
1689+
disabled = signal(false);
16791690
popupExpanded = signal(false);
16801691
searchString = signal('');
16811692
value = signal<string[]>([]);

src/aria/combobox/combobox.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,15 @@ import type {ComboboxPopup} from './combobox-popup';
6363
'role': 'combobox',
6464
'[attr.aria-autocomplete]': '_pattern.autocomplete()',
6565
'[attr.aria-disabled]': '_pattern.disabled()',
66+
'[attr.aria-readonly]': '_pattern.ariaReadonly()',
6667
'[attr.aria-expanded]': '_pattern.isExpanded()',
6768
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
6869
'[attr.aria-controls]': '_pattern.popupId()',
6970
'[attr.aria-haspopup]': '_pattern.popupType()',
7071
'[attr.tabindex]':
7172
'disabled() && !softDisabled() ? -1 : (tabIndex() !== undefined ? tabIndex() : 0)',
72-
'[attr.disabled]': 'disabled() && !softDisabled() ? "" : null',
73-
'[attr.readonly]': 'disabled() && _pattern.isEditable() ? "" : null',
73+
'[attr.disabled]': '_pattern.nativeDisabled()',
74+
'[attr.readonly]': '_pattern.nativeReadonly()',
7475
'(keydown)': '_pattern.onKeydown($event)',
7576
'(focusin)': '_pattern.onFocusin()',
7677
'(focusout)': '_pattern.onFocusout($event)',
@@ -93,6 +94,9 @@ export class Combobox extends DeferredContentAware implements OnInit {
9394
/** Whether the combobox is disabled. */
9495
readonly disabled = input(false, {transform: booleanAttribute});
9596

97+
/** Whether the combobox is readonly. */
98+
readonly readonly = input(false, {transform: booleanAttribute});
99+
96100
/** Whether the combobox is soft disabled (remains focusable). */
97101
readonly softDisabled = input(true, {transform: booleanAttribute});
98102

@@ -117,6 +121,7 @@ export class Combobox extends DeferredContentAware implements OnInit {
117121
/** The combobox ui pattern. */
118122
readonly _pattern = new ComboboxPattern({
119123
...this,
124+
readonly: () => this.readonly(),
120125
element: () => this.element,
121126
expandable: () => true,
122127
popup: computed(() => this._popup()?._pattern),

src/aria/private/combobox/combobox.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe('ComboboxPattern', () => {
66
function setup(
77
inputs: Partial<{
88
disabled: boolean;
9+
readonly: boolean;
910
alwaysExpanded: boolean;
1011
inlineSuggestion: string;
1112
popupType: 'listbox' | 'tree' | 'grid' | 'dialog';
@@ -16,6 +17,7 @@ describe('ComboboxPattern', () => {
1617
const expanded = signal(false);
1718
const alwaysExpanded = signal(inputs.alwaysExpanded ?? false);
1819
const disabled = signal(inputs.disabled ?? false);
20+
const readonly = signal(inputs.readonly ?? false);
1921
const inlineSuggestion = signal<string | undefined>(inputs.inlineSuggestion);
2022

2123
// Mock a generic popup pattern
@@ -38,6 +40,7 @@ describe('ComboboxPattern', () => {
3840
popup: signal(popup),
3941
inlineSuggestion,
4042
disabled,
43+
readonly,
4144
expanded,
4245
expandable: signal(true),
4346
});
@@ -50,6 +53,7 @@ describe('ComboboxPattern', () => {
5053
alwaysExpanded,
5154
inlineSuggestion,
5255
disabled,
56+
readonly,
5357
popup,
5458
controlTarget,
5559
};
@@ -271,4 +275,16 @@ describe('ComboboxPattern', () => {
271275
expect(pattern.keyboardEventRelay()).toBe(shiftHome);
272276
});
273277
});
278+
279+
describe('Readonly', () => {
280+
it('should ignore input when readonly', () => {
281+
const {pattern, expanded, value} = setup({readonly: true});
282+
const inputEl = document.createElement('input');
283+
inputEl.value = 'abc';
284+
pattern.onInput({target: inputEl} as unknown as Event);
285+
286+
expect(expanded()).toBe(false);
287+
expect(value()).toBe('');
288+
});
289+
});
274290
});

0 commit comments

Comments
 (0)