From 1e7fc7211d144b3518ffad0a29477216bf8a04b4 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 11 Nov 2025 16:05:31 -0500 Subject: [PATCH] fix(aria/combobox): disabled state --- src/aria/combobox/combobox.ts | 15 +- src/aria/private/combobox/combobox.ts | 5 +- .../combobox-disabled-example.html | 33 +++++ .../combobox-disabled-example.ts | 132 ++++++++++++++++++ .../aria/combobox/combobox-examples.css | 9 +- .../combobox-readonly-disabled-example.html | 33 +++++ .../combobox-readonly-disabled-example.ts | 77 ++++++++++ .../aria/combobox/index.ts | 3 + src/dev-app/aria-combobox/combobox-demo.html | 10 ++ src/dev-app/aria-combobox/combobox-demo.ts | 4 + 10 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html create mode 100644 src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts create mode 100644 src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html create mode 100644 src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts diff --git a/src/aria/combobox/combobox.ts b/src/aria/combobox/combobox.ts index 8601227cbf8f..ffdaf3c1109c 100644 --- a/src/aria/combobox/combobox.ts +++ b/src/aria/combobox/combobox.ts @@ -8,6 +8,7 @@ import { afterRenderEffect, + booleanAttribute, computed, contentChild, Directive, @@ -76,7 +77,7 @@ export class Combobox { private _hasBeenFocused = signal(false); /** Whether the combobox is disabled. */ - readonly disabled = input(false); + readonly disabled = input(false, {transform: booleanAttribute}); /** Whether the combobox is read-only. */ readonly readonly = input(false); @@ -90,6 +91,7 @@ export class Combobox { // TODO: Maybe make expanded a signal that can be passed in? // Or an "always expanded" option? + /** Whether the combobox popup is always expanded. */ readonly alwaysExpanded = input(false); /** Input element connected to the combobox, if any. */ @@ -129,6 +131,16 @@ export class Combobox { } }); } + + /** Expands the combobox popup. */ + expand() { + this._pattern.open(); + } + + /** Collapses the combobox popup. */ + collapse() { + this._pattern.close(); + } } @Directive({ @@ -137,6 +149,7 @@ export class Combobox { host: { 'role': 'combobox', '[value]': 'value()', + '[attr.aria-disabled]': 'combobox._pattern.disabled()', '[attr.aria-expanded]': 'combobox._pattern.expanded()', '[attr.aria-activedescendant]': 'combobox._pattern.activeDescendant()', '[attr.aria-controls]': 'combobox._pattern.popupId()', diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 2dfd672ec352..954beaba13d5 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -137,6 +137,9 @@ export class ComboboxPattern, V> { /** Whether the combobox is expanded. */ expanded = signal(false); + /** Whether the combobox is disabled. */ + disabled = () => this.inputs.disabled(); + /** The ID of the active item in the combobox. */ activeDescendant = computed(() => { const popupControls = this.inputs.popupControls(); @@ -177,7 +180,7 @@ export class ComboboxPattern, V> { hasPopup = computed(() => this.inputs.popupControls()?.role() || null); /** Whether the combobox is read-only. */ - readonly = computed(() => this.inputs.readonly() || null); + readonly = computed(() => this.inputs.readonly() || this.inputs.disabled() || null); /** Returns the listbox controls for the combobox. */ listControls = () => { diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html new file mode 100644 index 000000000000..a791c1d1f5b4 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html @@ -0,0 +1,33 @@ +
+
+ search + +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
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 new file mode 100644 index 000000000000..bf8e2808fd40 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts @@ -0,0 +1,132 @@ +/** + * @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, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, +} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @title Disabled combobox example. */ +@Component({ + selector: 'combobox-disabled-example', + templateUrl: 'combobox-disabled-example.html', + styleUrl: '../combobox-examples.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxDisabledExample { + popover = viewChild('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(Combobox); + + searchString = signal(''); + + options = computed(() => + states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())), + ); + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} + +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-examples.css b/src/components-examples/aria/combobox/combobox-examples.css index 7a065acdd242..266f47980eb6 100644 --- a/src/components-examples/aria/combobox/combobox-examples.css +++ b/src/components-examples/aria/combobox/combobox-examples.css @@ -7,7 +7,7 @@ border-radius: var(--mat-sys-corner-extra-small); } -.example-combobox-container:has([readonly='true']) { +.example-combobox-container:has([readonly='true']:not([aria-disabled='true'])) { width: 200px; } @@ -22,7 +22,7 @@ border-radius: var(--mat-sys-corner-extra-small); } -.example-combobox-input[readonly='true'] { +.example-combobox-input[readonly='true']:not([aria-disabled='true']) { cursor: pointer; padding: 0.7rem 1rem; } @@ -182,3 +182,8 @@ ul[role='group'] { .example-tree-item[aria-selected='true'] .example-selected-icon { visibility: visible; } + +.example-combobox-container:has([aria-disabled='true']) { + opacity: 0.4; + cursor: default; +} diff --git a/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html new file mode 100644 index 000000000000..f35bb2f4f174 --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.html @@ -0,0 +1,33 @@ +
+
+ + arrow_drop_down +
+ +
+ +
+ @for (option of options(); track option) { +
+ {{option}} + +
+ } +
+
+
+
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 new file mode 100644 index 000000000000..f18260f4efac --- /dev/null +++ b/src/components-examples/aria/combobox/combobox-readonly-disabled/combobox-readonly-disabled-example.ts @@ -0,0 +1,77 @@ +/** + * @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, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, +} from '@angular/aria/combobox'; +import {Listbox, Option} from '@angular/aria/listbox'; +import { + afterRenderEffect, + ChangeDetectionStrategy, + Component, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +/** @title Disabled readonly combobox. */ +@Component({ + selector: 'combobox-readonly-disabled-example', + templateUrl: 'combobox-readonly-disabled-example.html', + styleUrl: '../combobox-examples.css', + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComboboxReadonlyDisabledExample { + popover = viewChild('popover'); + listbox = viewChild>(Listbox); + combobox = viewChild>(Combobox); + + options = () => states; + searchString = signal(''); + + constructor() { + afterRenderEffect(() => { + const popover = this.popover()!; + const combobox = this.combobox()!; + combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + + this.listbox()?.scrollActiveItemIntoView(); + }); + } + + showPopover() { + const popover = this.popover()!; + const combobox = this.combobox()!; + + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + const popoverEl = popover.nativeElement; + + if (comboboxRect) { + popoverEl.style.width = `${comboboxRect.width}px`; + popoverEl.style.top = `${comboboxRect.bottom + 4}px`; + popoverEl.style.left = `${comboboxRect.left - 1}px`; + } + + popover.nativeElement.showPopover(); + } +} + +const states = ['Option 1', 'Option 2', 'Option 3']; diff --git a/src/components-examples/aria/combobox/index.ts b/src/components-examples/aria/combobox/index.ts index 40ba42080d32..e19dfd098d76 100644 --- a/src/components-examples/aria/combobox/index.ts +++ b/src/components-examples/aria/combobox/index.ts @@ -2,8 +2,11 @@ export {ComboboxDialogExample} from './combobox-dialog/combobox-dialog-example'; export {ComboboxManualExample} from './combobox-manual/combobox-manual-example'; 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 {ComboboxReadonlyMultiselectExample} from './combobox-readonly-multiselect/combobox-readonly-multiselect-example'; +export {ComboboxReadonlyDisabledExample} from './combobox-readonly-disabled/combobox-readonly-disabled-example'; export {ComboboxTreeManualExample} from './combobox-tree-manual/combobox-tree-manual-example'; export {ComboboxTreeAutoSelectExample} from './combobox-tree-auto-select/combobox-tree-auto-select-example'; diff --git a/src/dev-app/aria-combobox/combobox-demo.html b/src/dev-app/aria-combobox/combobox-demo.html index 27a664f488e0..01048429e8d6 100644 --- a/src/dev-app/aria-combobox/combobox-demo.html +++ b/src/dev-app/aria-combobox/combobox-demo.html @@ -16,6 +16,11 @@

Combobox with auto-select filtering

Combobox with highlight filtering

+ +
+

Disabled combobox

+ +

Tree autocomplete examples

@@ -49,6 +54,11 @@

Readonly Combobox

Readonly Multiselect Combobox

+ +
+

Disabled Readonly Combobox

+ +

Combobox with dialog popup

diff --git a/src/dev-app/aria-combobox/combobox-demo.ts b/src/dev-app/aria-combobox/combobox-demo.ts index 279ed650baae..efb033b6a777 100644 --- a/src/dev-app/aria-combobox/combobox-demo.ts +++ b/src/dev-app/aria-combobox/combobox-demo.ts @@ -11,8 +11,10 @@ import { ComboboxAutoSelectExample, ComboboxHighlightExample, ComboboxManualExample, + ComboboxDisabledExample, ComboboxReadonlyExample, ComboboxReadonlyMultiselectExample, + ComboboxReadonlyDisabledExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample, ComboboxTreeManualExample, @@ -27,8 +29,10 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; ComboboxManualExample, ComboboxAutoSelectExample, ComboboxHighlightExample, + ComboboxDisabledExample, ComboboxReadonlyExample, ComboboxReadonlyMultiselectExample, + ComboboxReadonlyDisabledExample, ComboboxTreeManualExample, ComboboxTreeAutoSelectExample, ComboboxTreeHighlightExample,