diff --git a/goldens/aria/combobox/index.api.md b/goldens/aria/combobox/index.api.md index 6ecc5febf20e..9091f9477eed 100644 --- a/goldens/aria/combobox/index.api.md +++ b/goldens/aria/combobox/index.api.md @@ -24,13 +24,14 @@ export class Combobox extends DeferredContentAware implements OnInit { ngOnInit(): void; readonly _pattern: ComboboxPattern; readonly _popup: _angular_core.WritableSignal; + readonly readonly: _angular_core.InputSignalWithTransform; _registerPopup(popup: ComboboxPopup): void; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly tabIndex: _angular_core.InputSignalWithTransform; _unregisterPopup(): void; readonly value: _angular_core.ModelSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 9b459d94906c..61f282fd7cf7 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -67,6 +67,7 @@ export interface ComboboxInputs extends ExpansionItem { element: SignalLike; inlineSuggestion: SignalLike; popup: SignalLike; + readonly: SignalLike; softDisabled?: SignalLike; value: WritableSignalLike; } @@ -75,6 +76,7 @@ export interface ComboboxInputs extends ExpansionItem { export class ComboboxPattern { constructor(inputs: ComboboxInputs); readonly activeDescendant: _angular_core.Signal; + readonly ariaReadonly: _angular_core.Signal<"true" | null>; readonly autocomplete: _angular_core.Signal<"none" | "inline" | "list" | "both">; click: _angular_core.Signal>; closePopupOnBlurEffect(): void; @@ -91,6 +93,8 @@ export class ComboboxPattern { readonly keyboardEventRelay: _angular_core.WritableSignal; keyboardEventRelayEffect(): void; keydown: _angular_core.Signal>; + readonly nativeDisabled: _angular_core.Signal<"" | null>; + readonly nativeReadonly: _angular_core.Signal<"" | null>; onClick(event: PointerEvent): void; onFocusin(): void; onFocusout(event: FocusEvent): void; @@ -98,6 +102,7 @@ export class ComboboxPattern { onKeydown(event: KeyboardEvent): void; readonly popupId: _angular_core.Signal; readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>; + readonly readonly: () => boolean; readonly softDisabled: () => boolean; readonly value: WritableSignalLike; } diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index d6ac1f9a793a..a3ee5b66d957 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -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'); }); }); }); @@ -1341,7 +1346,8 @@ function getTreeNodes(): TreeNode[] { placeholder="Search..." [(value)]="searchString" [(expanded)]="popupExpanded" - [disabled]="readonly()" + [readonly]="readonly()" + [disabled]="disabled()" (focusout)="onBlur()" /> @@ -1394,6 +1400,7 @@ class ComboboxTreeExample { readonly tree = viewChild(Tree); readonly = signal(false); + disabled = signal(false); popupExpanded = signal(false); searchString = signal(''); value = signal([]); @@ -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)" /> @@ -1611,6 +1619,7 @@ class ComboboxGridExample { }) class ComboboxListboxAutoSelectExample { readonly = signal(false); + disabled = signal(false); popupExpanded = signal(false); searchString = signal(''); value = signal([]); @@ -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)" /> @@ -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([]); diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index ed427f2de149..704a70d457c2 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -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)', @@ -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}); @@ -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), diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index f17c2daf3678..b532f36ef8cd 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -6,6 +6,7 @@ describe('ComboboxPattern', () => { function setup( inputs: Partial<{ disabled: boolean; + readonly: boolean; alwaysExpanded: boolean; inlineSuggestion: string; popupType: 'listbox' | 'tree' | 'grid' | 'dialog'; @@ -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(inputs.inlineSuggestion); // Mock a generic popup pattern @@ -38,6 +40,7 @@ describe('ComboboxPattern', () => { popup: signal(popup), inlineSuggestion, disabled, + readonly, expanded, expandable: signal(true), }); @@ -50,6 +53,7 @@ describe('ComboboxPattern', () => { alwaysExpanded, inlineSuggestion, disabled, + readonly, popup, controlTarget, }; @@ -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(''); + }); + }); }); diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 74499e913419..1bd8ba22ce12 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -31,6 +31,9 @@ export interface ComboboxInputs extends ExpansionItem { /** Whether the combobox is disabled. */ disabled: SignalLike; + /** Whether the combobox is readonly. */ + readonly: SignalLike; + /** Whether the combobox is soft disabled. */ softDisabled?: SignalLike; } @@ -49,6 +52,9 @@ export class ComboboxPattern { /** Whether the combobox is disabled. */ readonly disabled = () => this.inputs.disabled(); + /** Whether the combobox is readonly. */ + readonly readonly = () => this.inputs.readonly(); + /** Whether the combobox is soft disabled. */ readonly softDisabled = () => this.inputs.softDisabled?.() ?? true; @@ -97,17 +103,30 @@ export class ComboboxPattern { this.element().tagName.toLowerCase() === 'textarea', ); + /** The aria-readonly attribute value for non-editable comboboxes. */ + readonly ariaReadonly = computed(() => (this.readonly() && !this.isEditable() ? 'true' : null)); + + /** The native readonly attribute value for editable comboboxes. */ + readonly nativeReadonly = computed(() => + (this.readonly() || this.disabled()) && this.isEditable() ? '' : null, + ); + + /** The native disabled attribute value for hard-disabled comboboxes. */ + readonly nativeDisabled = computed(() => (this.disabled() && !this.softDisabled() ? '' : null)); + /** The keydown event manager for the combobox. */ // TODO(tjshiu): Allow combo keys in combobox (#33101). keydown = computed(() => { const manager = new KeyboardEventManager(); if (!this.isExpanded()) { - manager.on('ArrowDown', () => this.inputs.expanded.set(true)); + if (!this.readonly()) { + manager.on('ArrowDown', () => this.inputs.expanded.set(true)); - if (!this.isEditable()) { - manager.on('Enter', () => this.inputs.expanded.set(true)); - manager.on(' ', () => this.inputs.expanded.set(true)); + if (!this.isEditable()) { + manager.on('Enter', () => this.inputs.expanded.set(true)); + manager.on(' ', () => this.inputs.expanded.set(true)); + } } return manager; @@ -134,7 +153,6 @@ export class ComboboxPattern { .on(Modifier.Shift, 'ArrowDown', e => this.keyboardEventRelay.set(e), {ignoreRepeat: false}) .on('Home', e => this.keyboardEventRelay.set(e)) .on('End', e => this.keyboardEventRelay.set(e)) - .on('Enter', e => this.keyboardEventRelay.set(e)) .on('PageUp', e => this.keyboardEventRelay.set(e)) .on('PageDown', e => this.keyboardEventRelay.set(e)) .on('Escape', () => { @@ -143,7 +161,11 @@ export class ComboboxPattern { } }); - if (!this.isEditable()) { + if (!this.readonly()) { + manager.on('Enter', e => this.keyboardEventRelay.set(e)); + } + + if (!this.isEditable() && !this.readonly()) { manager .on(' ', e => this.keyboardEventRelay.set(e)) .on([Modifier.Ctrl, Modifier.Meta], 'a', e => this.keyboardEventRelay.set(e)) @@ -162,7 +184,7 @@ export class ComboboxPattern { click = computed(() => { const manager = new ClickEventManager(); - if (this.isEditable()) return manager; + if (this.isEditable() || this.readonly()) return manager; manager.on(() => this.inputs.expanded.update(v => !v)); @@ -200,7 +222,7 @@ export class ComboboxPattern { /** Handles input events for the combobox. */ onInput(event: Event) { if (!(event.target instanceof HTMLInputElement)) return; - if (this.disabled()) return; + if (this.disabled() || this.readonly()) return; this.inputs.expanded.set(true); this.value.set(event.target.value); @@ -216,7 +238,7 @@ export class ComboboxPattern { const isFocused = untracked(() => this.isFocused()); const isExpanded = this.isExpanded(); - if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return; + if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting || this.readonly()) return; const inputEl = this.element() as HTMLInputElement; const isHighlightable = inlineSuggestion.toLowerCase().startsWith(value.toLowerCase()); diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts index 7347ae415a9b..80d88e3d9533 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts @@ -10,6 +10,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES as states} from '../states'; /** @title Combobox Auto Select */ @Component({ @@ -43,56 +44,3 @@ export class ComboboxAutoSelectExample { this.popupExpanded.set(false); } } - -const states = [ - 'Alabama', - 'Alaska', - 'Arizona', - 'Arkansas', - 'California', - 'Colorado', - 'Connecticut', - 'Delaware', - 'Florida', - 'Georgia', - 'Hawaii', - 'Idaho', - 'Illinois', - 'Indiana', - 'Iowa', - 'Kansas', - 'Kentucky', - 'Louisiana', - 'Maine', - 'Maryland', - 'Massachusetts', - 'Michigan', - 'Minnesota', - 'Mississippi', - 'Missouri', - 'Montana', - 'Nebraska', - 'Nevada', - 'New Hampshire', - 'New Jersey', - 'New Mexico', - 'New York', - 'North Carolina', - 'North Dakota', - 'Ohio', - 'Oklahoma', - 'Oregon', - 'Pennsylvania', - 'Rhode Island', - 'South Carolina', - 'South Dakota', - 'Tennessee', - 'Texas', - 'Utah', - 'Vermont', - 'Virginia', - 'Washington', - 'West Virginia', - 'Wisconsin', - 'Wyoming', -]; diff --git a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts index a59e28bd0dcb..5ff1e6c7f530 100644 --- a/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts +++ b/src/components-examples/aria/combobox/combobox-dialog/combobox-dialog-example.ts @@ -19,6 +19,7 @@ import { } from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; import {FormsModule} from '@angular/forms'; +import {STATES as states} from '../states'; /** @title Combobox with a dialog popup. */ @Component({ @@ -75,56 +76,3 @@ export class ComboboxDialogExample { this.combobox()?.element.focus(); // Focus back to main trigger! } } - -const states = [ - 'Alabama', - 'Alaska', - 'Arizona', - 'Arkansas', - 'California', - 'Colorado', - 'Connecticut', - 'Delaware', - 'Florida', - 'Georgia', - 'Hawaii', - 'Idaho', - 'Illinois', - 'Indiana', - 'Iowa', - 'Kansas', - 'Kentucky', - 'Louisiana', - 'Maine', - 'Maryland', - 'Massachusetts', - 'Michigan', - 'Minnesota', - 'Mississippi', - 'Missouri', - 'Montana', - 'Nebraska', - 'Nevada', - 'New Hampshire', - 'New Jersey', - 'New Mexico', - 'New York', - 'North Carolina', - 'North Dakota', - 'Ohio', - 'Oklahoma', - 'Oregon', - 'Pennsylvania', - 'Rhode Island', - 'South Carolina', - 'South Dakota', - 'Tennessee', - 'Texas', - 'Utah', - 'Vermont', - 'Virginia', - 'Washington', - 'West Virginia', - 'Wisconsin', - 'Wyoming', -]; diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts index 6a8e3825cb6c..c75ca5df7bf6 100644 --- a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts @@ -10,6 +10,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES as states} from '../states'; /** @title Combobox Disabled */ @Component({ @@ -49,56 +50,3 @@ export class ComboboxDisabledExample { } } } - -const states = [ - 'Alabama', - 'Alaska', - 'Arizona', - 'Arkansas', - 'California', - 'Colorado', - 'Connecticut', - 'Delaware', - 'Florida', - 'Georgia', - 'Hawaii', - 'Idaho', - 'Illinois', - 'Indiana', - 'Iowa', - 'Kansas', - 'Kentucky', - 'Louisiana', - 'Maine', - 'Maryland', - 'Massachusetts', - 'Michigan', - 'Minnesota', - 'Mississippi', - 'Missouri', - 'Montana', - 'Nebraska', - 'Nevada', - 'New Hampshire', - 'New Jersey', - 'New Mexico', - 'New York', - 'North Carolina', - 'North Dakota', - 'Ohio', - 'Oklahoma', - 'Oregon', - 'Pennsylvania', - 'Rhode Island', - 'South Carolina', - 'South Dakota', - 'Tennessee', - 'Texas', - 'Utah', - 'Vermont', - 'Virginia', - 'Washington', - 'West Virginia', - 'Wisconsin', - 'Wyoming', -]; diff --git a/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.html b/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.html new file mode 100644 index 000000000000..52f6a09f7ca7 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.html @@ -0,0 +1,27 @@ +
+
+ search + +
+ + + + +
+
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.ts b/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.ts new file mode 100644 index 000000000000..187030f4db22 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-hard-disabled/combobox-hard-disabled-example.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild, untracked} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES as states} from '../states'; + +/** @title Combobox Hard Disabled */ +@Component({ + selector: 'combobox-hard-disabled-example', + templateUrl: 'combobox-hard-disabled-example.html', + styleUrl: '../combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class ComboboxHardDisabledExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal(''); + selectedOption = signal([]); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + + afterRenderEffect(() => { + if (this.popupExpanded()) { + untracked(() => setTimeout(() => this.listbox()?.gotoFirst())); + } + }); + } + + onCommit() { + const selectedOption = this.selectedOption(); + if (selectedOption.length > 0) { + this.searchString.set(selectedOption[0]); + this.popupExpanded.set(false); + } + } +} diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts index b65aa7ed3332..64a46e2174fe 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts @@ -10,6 +10,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {afterRenderEffect, Component, computed, effect, signal, viewChild} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES} from '../states'; /** @title Combobox Highlight */ @Component({ @@ -57,55 +58,12 @@ export class ComboboxHighlightExample { } } -const states = [ - {name: 'Alabama', disabled: false}, - {name: 'Alaska', disabled: true}, - {name: 'Arizona', disabled: false}, - {name: 'Arkansas', disabled: true}, - {name: 'California', disabled: true}, - {name: 'Colorado', disabled: false}, - {name: 'Connecticut', disabled: false}, - {name: 'Delaware', disabled: false}, - {name: 'Florida', disabled: false}, - {name: 'Georgia', disabled: false}, - {name: 'Hawaii', disabled: false}, - {name: 'Idaho', disabled: false}, - {name: 'Illinois', disabled: false}, - {name: 'Indiana', disabled: false}, - {name: 'Iowa', disabled: false}, - {name: 'Kansas', disabled: false}, - {name: 'Kentucky', disabled: false}, - {name: 'Louisiana', disabled: false}, - {name: 'Maine', disabled: false}, - {name: 'Maryland', disabled: false}, - {name: 'Massachusetts', disabled: false}, - {name: 'Michigan', disabled: false}, - {name: 'Minnesota', disabled: false}, - {name: 'Mississippi', disabled: false}, - {name: 'Missouri', disabled: false}, - {name: 'Montana', disabled: false}, - {name: 'Nebraska', disabled: false}, - {name: 'Nevada', disabled: false}, - {name: 'New Hampshire', disabled: false}, - {name: 'New Jersey', disabled: false}, - {name: 'New Mexico', disabled: false}, - {name: 'New York', disabled: false}, - {name: 'North Carolina', disabled: false}, - {name: 'North Dakota', disabled: false}, - {name: 'Ohio', disabled: false}, - {name: 'Oklahoma', disabled: false}, - {name: 'Oregon', disabled: false}, - {name: 'Pennsylvania', disabled: false}, - {name: 'Rhode Island', disabled: false}, - {name: 'South Carolina', disabled: false}, - {name: 'South Dakota', disabled: false}, - {name: 'Tennessee', disabled: false}, - {name: 'Texas', disabled: false}, - {name: 'Utah', disabled: false}, - {name: 'Vermont', disabled: false}, - {name: 'Virginia', disabled: false}, - {name: 'Washington', disabled: false}, - {name: 'West Virginia', disabled: false}, - {name: 'Wisconsin', disabled: false}, - {name: 'Wyoming', disabled: false}, -]; +interface StateOption { + name: string; + disabled: boolean; +} + +const states: StateOption[] = STATES.map((name: string, index: number) => ({ + name, + disabled: index === 1 || index === 3 || index === 4, +})); diff --git a/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts b/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts index 2eb600ff543c..702bd1597b12 100644 --- a/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts +++ b/src/components-examples/aria/combobox/combobox-listbox/combobox-listbox-example.ts @@ -10,6 +10,7 @@ import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES as states} from '../states'; /** @title */ @Component({ @@ -43,56 +44,3 @@ export class ComboboxListboxExample { this.popupExpanded.set(false); } } - -const states = [ - 'Alabama', - 'Alaska', - 'Arizona', - 'Arkansas', - 'California', - 'Colorado', - 'Connecticut', - 'Delaware', - 'Florida', - 'Georgia', - 'Hawaii', - 'Idaho', - 'Illinois', - 'Indiana', - 'Iowa', - 'Kansas', - 'Kentucky', - 'Louisiana', - 'Maine', - 'Maryland', - 'Massachusetts', - 'Michigan', - 'Minnesota', - 'Mississippi', - 'Missouri', - 'Montana', - 'Nebraska', - 'Nevada', - 'New Hampshire', - 'New Jersey', - 'New Mexico', - 'New York', - 'North Carolina', - 'North Dakota', - 'Ohio', - 'Oklahoma', - 'Oregon', - 'Pennsylvania', - 'Rhode Island', - 'South Carolina', - 'South Dakota', - 'Tennessee', - 'Texas', - 'Utah', - 'Vermont', - 'Virginia', - 'Washington', - 'West Virginia', - 'Wisconsin', - 'Wyoming', -]; diff --git a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts index 048b1441bb6b..b7d65a65144f 100644 --- a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts +++ b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts @@ -11,7 +11,7 @@ import {Listbox, Option} from '@angular/aria/listbox'; import {afterRenderEffect, Component, signal, viewChild} from '@angular/core'; import {OverlayModule} from '@angular/cdk/overlay'; -/** @title Disabled readonly combobox. */ +/** @title Disabled combobox. */ @Component({ selector: 'combobox-readonly-disabled-example', templateUrl: 'combobox-readonly-disabled-example.html', diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html new file mode 100644 index 000000000000..e60fc034b7f1 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.html @@ -0,0 +1,26 @@ +
+
+ search + +
+ + + +
+
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts new file mode 100644 index 000000000000..2325a2ab69c2 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly/combobox-readonly-example.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import {afterRenderEffect, Component, computed, signal, viewChild} from '@angular/core'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {STATES as states} from '../states'; + +/** @title Combobox Readonly */ +@Component({ + selector: 'combobox-readonly-example', + templateUrl: 'combobox-readonly-example.html', + styleUrl: '../combobox-example.css', + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule], +}) +export class ComboboxReadonlyExample { + readonly listbox = viewChild(Listbox); + + popupExpanded = signal(false); + searchString = signal('California'); + selectedOption = signal(['California']); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + onCommit() { + // Readonly combobox suppresses commits + } +} diff --git a/src/components-examples/aria/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index 44a06a854d81..f2ebe1d8675d 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -6,6 +6,8 @@ export {ComboboxDatepickerExample} from './combobox-datepicker/combobox-datepick export {ComboboxAutoSelectExample} from './combobox-auto-select/combobox-auto-select-example'; export {ComboboxHighlightExample} from './combobox-highlight/combobox-highlight-example'; export {ComboboxDisabledExample} from './combobox-disabled/combobox-disabled-example'; +export {ComboboxReadonlyExample} from './combobox-readonly/combobox-readonly-example'; +export {ComboboxHardDisabledExample} from './combobox-hard-disabled/combobox-hard-disabled-example'; export {ComboboxReadonlyDisabledExample} from './combobox-readonly-disabled/combobox-readonly-disabled-example'; export {ComboboxReadonlyMultiselectExample} from './combobox-readonly-multiselect/combobox-readonly-multiselect-example'; export {ComboboxDialogExample} from './combobox-dialog/combobox-dialog-example'; diff --git a/src/components-examples/aria/combobox/states.ts b/src/components-examples/aria/combobox/states.ts new file mode 100644 index 000000000000..db209524863a --- /dev/null +++ b/src/components-examples/aria/combobox/states.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export const STATES = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 2c7113e85a19..6661f8135f8b 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -20,6 +20,16 @@

Combobox with highlight

Combobox with disabled

+ +
+

Combobox with readonly

+ +
+ +
+

Combobox with hard disabled

+ +

Tree autocomplete examples

@@ -55,7 +65,7 @@

Combobox with Multi-Select

-

Combobox with Readonly + Disabled

+

Combobox with Disabled

diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index 6a6993719cc3..b8d667c6147f 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -16,6 +16,8 @@ import { ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxDisabledExample, + ComboboxReadonlyExample, + ComboboxHardDisabledExample, ComboboxReadonlyDisabledExample, ComboboxReadonlyMultiselectExample, ComboboxDialogExample, @@ -36,6 +38,8 @@ import { ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxDisabledExample, + ComboboxReadonlyExample, + ComboboxHardDisabledExample, ComboboxReadonlyDisabledExample, ComboboxReadonlyMultiselectExample, ComboboxDialogExample,