Skip to content

Commit fae8734

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

11 files changed

Lines changed: 266 additions & 97 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: 93 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -525,111 +525,115 @@ 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 NOT set readonly when disabled and softDisabled is true', 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(false);
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 make the input unfocusable when softDisabled is false', 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.getAttribute('aria-disabled')).toBe('true');
587+
});
590588

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

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();
594+
expect(inputElement.getAttribute('tabindex')).toBe('0');
595+
});
600596

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

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();
601+
expect(inputElement.getAttribute('tabindex')).toBe('0');
602+
});
610603

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

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

618-
expect(inputElement.getAttribute('tabindex')).toBe('0');
615+
expect(inputElement.getAttribute('tabindex')).toBe('-1');
616+
});
619617
});
618+
});
620619

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

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);
632+
it('should automatically report as expanded when alwaysExpanded is true', async () => {
633+
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
634+
fixture.componentInstance.alwaysExpanded.set(true);
630635
await fixture.whenStable();
631-
632-
expect(inputElement.getAttribute('tabindex')).toBe('-1');
636+
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
633637
});
634638
});
635639
});
@@ -1341,7 +1345,8 @@ function getTreeNodes(): TreeNode[] {
13411345
placeholder="Search..."
13421346
[(value)]="searchString"
13431347
[(expanded)]="popupExpanded"
1344-
[disabled]="readonly()"
1348+
[readonly]="readonly()"
1349+
[disabled]="disabled()"
13451350
(focusout)="onBlur()"
13461351
/>
13471352
@@ -1394,6 +1399,7 @@ class ComboboxTreeExample {
13941399
readonly tree = viewChild(Tree);
13951400

13961401
readonly = signal(false);
1402+
disabled = signal(false);
13971403
popupExpanded = signal(false);
13981404
searchString = signal('');
13991405
value = signal<string[]>([]);
@@ -1591,7 +1597,8 @@ class ComboboxGridExample {
15911597
placeholder="Search..."
15921598
[(value)]="searchString"
15931599
(input)="onInput()"
1594-
[disabled]="readonly()"
1600+
[readonly]="readonly()"
1601+
[disabled]="disabled()"
15951602
(focusout)="onBlur()"
15961603
(click)="combobox.expanded.set(true)"
15971604
/>
@@ -1611,6 +1618,7 @@ class ComboboxGridExample {
16111618
})
16121619
class ComboboxListboxAutoSelectExample {
16131620
readonly = signal(false);
1621+
disabled = signal(false);
16141622
popupExpanded = signal(false);
16151623
searchString = signal('');
16161624
value = signal<string[]>([]);
@@ -1656,7 +1664,8 @@ class ComboboxListboxAutoSelectExample {
16561664
[(value)]="searchString"
16571665
[(expanded)]="popupExpanded"
16581666
[inlineSuggestion]="value()[0] || options()[0]"
1659-
[disabled]="readonly()"
1667+
[readonly]="readonly()"
1668+
[disabled]="disabled()"
16601669
(click)="popupExpanded.set(true)"
16611670
/>
16621671
@@ -1676,6 +1685,7 @@ class ComboboxListboxAutoSelectExample {
16761685
class ComboboxListboxHighlightExample {
16771686
readonly combobox = viewChild(Combobox);
16781687
readonly = signal(false);
1688+
disabled = signal(false);
16791689
popupExpanded = signal(false);
16801690
searchString = signal('');
16811691
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),

0 commit comments

Comments
 (0)