Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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: 2 additions & 1 deletion goldens/aria/combobox/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ export class Combobox extends DeferredContentAware implements OnInit {
ngOnInit(): void;
readonly _pattern: ComboboxPattern;
readonly _popup: _angular_core.WritableSignal<ComboboxPopup | undefined>;
readonly readonly: _angular_core.InputSignalWithTransform<boolean, unknown>;
_registerPopup(popup: ComboboxPopup): void;
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
_unregisterPopup(): void;
readonly value: _angular_core.ModelSignal<string>;
// (undocumented)
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>;
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>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Combobox, never>;
}
Expand Down
5 changes: 5 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface ComboboxInputs extends ExpansionItem {
element: SignalLike<HTMLElement>;
inlineSuggestion: SignalLike<string | undefined>;
popup: SignalLike<ComboboxPopupPattern | undefined>;
readonly: SignalLike<boolean>;
softDisabled?: SignalLike<boolean>;
value: WritableSignalLike<string>;
}
Expand All @@ -75,6 +76,7 @@ export interface ComboboxInputs extends ExpansionItem {
export class ComboboxPattern {
constructor(inputs: ComboboxInputs);
readonly activeDescendant: _angular_core.Signal<string | undefined>;
readonly ariaReadonly: _angular_core.Signal<"true" | null>;
readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">;
click: _angular_core.Signal<ClickEventManager<PointerEvent>>;
closePopupOnBlurEffect(): void;
Expand All @@ -91,13 +93,16 @@ export class ComboboxPattern {
readonly keyboardEventRelay: _angular_core.WritableSignal<KeyboardEvent | undefined>;
keyboardEventRelayEffect(): void;
keydown: _angular_core.Signal<KeyboardEventManager<KeyboardEvent>>;
readonly nativeDisabled: _angular_core.Signal<"" | null>;
readonly nativeReadonly: _angular_core.Signal<"" | null>;
onClick(event: PointerEvent): void;
onFocusin(): void;
onFocusout(event: FocusEvent): void;
onInput(event: Event): void;
onKeydown(event: KeyboardEvent): void;
readonly popupId: _angular_core.Signal<string | undefined>;
readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>;
readonly readonly: () => boolean;
readonly softDisabled: () => boolean;
readonly value: WritableSignalLike<string>;
}
Expand Down
177 changes: 94 additions & 83 deletions src/aria/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,111 +525,116 @@ describe('Combobox', () => {
});
});

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

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

it('should close on escape', async () => {
await focus();
await down();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
await escape();
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});
});
it('should suppress typing when readonly', async () => {
await focus();
inputElement.value = 'New';
inputElement.dispatchEvent(new Event('input'));
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

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

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

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

it('should automatically report as expanded when alwaysExpanded is true', async () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
fixture.componentInstance.alwaysExpanded.set(true);
await fixture.whenStable();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});
});
expect(inputElement.disabled).toBe(false);
expect(inputElement.getAttribute('disabled')).toBeNull();
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});

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

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

expect(inputElement.disabled).toBe(false);
expect(inputElement.getAttribute('disabled')).toBeNull();
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});
it('should block interactions when disabled', async () => {
fixture.componentInstance.disabled.set(true);
await fixture.whenStable();

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

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

it('should block interactions when disabled', async () => {
fixture.componentInstance.disabled.set(true);
await fixture.whenStable();
expect(inputElement.disabled).toBe(true);
expect(inputElement.getAttribute('disabled')).toBe('');
expect(inputElement.hasAttribute('readonly')).toBe(true);
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});

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

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

expect(inputElement.disabled).toBe(true);
expect(inputElement.getAttribute('disabled')).toBe('');
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});
it('should respect user-defined tabindex when not disabled', async () => {
fixture.componentInstance.tabIndex.set(0);
await fixture.whenStable();

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

expect(inputElement.getAttribute('tabindex')).toBe('0');
});
it('should default to tabindex 0 when not disabled', async () => {
await fixture.whenStable();
expect(inputElement.getAttribute('tabindex')).toBe('0');
});

it('should respect user-defined tabindex when not disabled', async () => {
fixture.componentInstance.tabIndex.set(0);
await fixture.whenStable();
it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', async () => {
fixture.componentInstance.disabled.set(true);
fixture.componentInstance.softDisabled.set(false);
fixture.componentInstance.tabIndex.set(0);
await fixture.whenStable();

expect(inputElement.getAttribute('tabindex')).toBe('0');
expect(inputElement.getAttribute('tabindex')).toBe('-1');
});
});
});

it('should default to tabindex 0 when not disabled', async () => {
describe('Always Expanded', () => {
beforeEach(async () => await setupCombobox());

it('should not close on escape when alwaysExpanded is true', async () => {
fixture.componentInstance.alwaysExpanded.set(true);
await fixture.whenStable();
expect(inputElement.getAttribute('tabindex')).toBe('0');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');

await escape();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});

it('should force tabindex to -1 when hard-disabled, ignoring user-defined tabindex', async () => {
fixture.componentInstance.disabled.set(true);
fixture.componentInstance.softDisabled.set(false);
fixture.componentInstance.tabIndex.set(0);
it('should automatically report as expanded when alwaysExpanded is true', async () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
fixture.componentInstance.alwaysExpanded.set(true);
await fixture.whenStable();

expect(inputElement.getAttribute('tabindex')).toBe('-1');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});
});
});
Expand Down Expand Up @@ -1341,7 +1346,8 @@ function getTreeNodes(): TreeNode[] {
placeholder="Search..."
[(value)]="searchString"
[(expanded)]="popupExpanded"
[disabled]="readonly()"
[readonly]="readonly()"
[disabled]="disabled()"
(focusout)="onBlur()"
/>

Expand Down Expand Up @@ -1394,6 +1400,7 @@ class ComboboxTreeExample {
readonly tree = viewChild(Tree);

readonly = signal(false);
disabled = signal(false);
popupExpanded = signal(false);
searchString = signal('');
value = signal<string[]>([]);
Expand Down Expand Up @@ -1591,7 +1598,8 @@ class ComboboxGridExample {
placeholder="Search..."
[(value)]="searchString"
(input)="onInput()"
[disabled]="readonly()"
[readonly]="readonly()"
[disabled]="disabled()"
(focusout)="onBlur()"
(click)="combobox.expanded.set(true)"
/>
Expand All @@ -1611,6 +1619,7 @@ class ComboboxGridExample {
})
class ComboboxListboxAutoSelectExample {
readonly = signal(false);
disabled = signal(false);
popupExpanded = signal(false);
searchString = signal('');
value = signal<string[]>([]);
Expand Down Expand Up @@ -1656,7 +1665,8 @@ class ComboboxListboxAutoSelectExample {
[(value)]="searchString"
[(expanded)]="popupExpanded"
[inlineSuggestion]="value()[0] || options()[0]"
[disabled]="readonly()"
[readonly]="readonly()"
[disabled]="disabled()"
(click)="popupExpanded.set(true)"
/>

Expand All @@ -1676,6 +1686,7 @@ class ComboboxListboxAutoSelectExample {
class ComboboxListboxHighlightExample {
readonly combobox = viewChild(Combobox);
readonly = signal(false);
disabled = signal(false);
popupExpanded = signal(false);
searchString = signal('');
value = signal<string[]>([]);
Expand Down
9 changes: 7 additions & 2 deletions src/aria/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ import type {ComboboxPopup} from './combobox-popup';
'role': 'combobox',
'[attr.aria-autocomplete]': '_pattern.autocomplete()',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.aria-readonly]': '_pattern.ariaReadonly()',
'[attr.aria-expanded]': '_pattern.isExpanded()',
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
'[attr.aria-controls]': '_pattern.popupId()',
'[attr.aria-haspopup]': '_pattern.popupType()',
'[attr.tabindex]':
'disabled() && !softDisabled() ? -1 : (tabIndex() !== undefined ? tabIndex() : 0)',
'[attr.disabled]': 'disabled() && !softDisabled() ? "" : null',
'[attr.readonly]': 'disabled() && _pattern.isEditable() ? "" : null',
'[attr.disabled]': '_pattern.nativeDisabled()',
'[attr.readonly]': '_pattern.nativeReadonly()',
'(keydown)': '_pattern.onKeydown($event)',
'(focusin)': '_pattern.onFocusin()',
'(focusout)': '_pattern.onFocusout($event)',
Expand All @@ -93,6 +94,9 @@ export class Combobox extends DeferredContentAware implements OnInit {
/** Whether the combobox is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** Whether the combobox is readonly. */
readonly readonly = input(false, {transform: booleanAttribute});

/** Whether the combobox is soft disabled (remains focusable). */
readonly softDisabled = input(true, {transform: booleanAttribute});

Expand All @@ -117,6 +121,7 @@ export class Combobox extends DeferredContentAware implements OnInit {
/** The combobox ui pattern. */
readonly _pattern = new ComboboxPattern({
...this,
readonly: () => this.readonly(),
element: () => this.element,
expandable: () => true,
popup: computed(() => this._popup()?._pattern),
Expand Down
16 changes: 16 additions & 0 deletions src/aria/private/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ describe('ComboboxPattern', () => {
function setup(
inputs: Partial<{
disabled: boolean;
readonly: boolean;
alwaysExpanded: boolean;
inlineSuggestion: string;
popupType: 'listbox' | 'tree' | 'grid' | 'dialog';
Expand All @@ -16,6 +17,7 @@ describe('ComboboxPattern', () => {
const expanded = signal(false);
const alwaysExpanded = signal(inputs.alwaysExpanded ?? false);
const disabled = signal(inputs.disabled ?? false);
const readonly = signal(inputs.readonly ?? false);
const inlineSuggestion = signal<string | undefined>(inputs.inlineSuggestion);

// Mock a generic popup pattern
Expand All @@ -38,6 +40,7 @@ describe('ComboboxPattern', () => {
popup: signal(popup),
inlineSuggestion,
disabled,
readonly,
expanded,
expandable: signal(true),
});
Expand All @@ -50,6 +53,7 @@ describe('ComboboxPattern', () => {
alwaysExpanded,
inlineSuggestion,
disabled,
readonly,
popup,
controlTarget,
};
Expand Down Expand Up @@ -271,4 +275,16 @@ describe('ComboboxPattern', () => {
expect(pattern.keyboardEventRelay()).toBe(shiftHome);
});
});

describe('Readonly', () => {
it('should ignore input when readonly', () => {
const {pattern, expanded, value} = setup({readonly: true});
const inputEl = document.createElement('input');
inputEl.value = 'abc';
pattern.onInput({target: inputEl} as unknown as Event);

expect(expanded()).toBe(false);
expect(value()).toBe('');
});
});
});
Loading
Loading