Skip to content
Open
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
2 changes: 2 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ export interface SimpleComboboxInputs extends ExpansionItem {
element: SignalLike<HTMLElement>;
inlineSuggestion: SignalLike<string | undefined>;
popup: SignalLike<SimpleComboboxPopupPattern | undefined>;
softDisabled: SignalLike<boolean>;
value: WritableSignalLike<string>;
}

Expand Down Expand Up @@ -701,6 +702,7 @@ export class SimpleComboboxPattern {
onKeydown(event: KeyboardEvent): void;
readonly popupId: _angular_core.Signal<string | undefined>;
readonly popupType: _angular_core.Signal<"listbox" | "tree" | "grid" | "dialog" | undefined>;
readonly softDisabled: () => boolean;
readonly value: WritableSignalLike<string>;
}

Expand Down
3 changes: 2 additions & 1 deletion goldens/aria/simple-combobox/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ export class Combobox extends DeferredContentAware implements OnInit {
readonly _pattern: SimpleComboboxPattern;
readonly _popup: _angular_core.WritableSignal<ComboboxPopup | undefined>;
_registerPopup(popup: ComboboxPopup): void;
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
_unregisterPopup(): void;
readonly value: _angular_core.ModelSignal<string>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Combobox, "[ngCombobox]", ["ngCombobox"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "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; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "alwaysExpanded": { "alias": "alwaysExpanded"; "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
6 changes: 6 additions & 0 deletions src/aria/private/simple-combobox/simple-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export interface SimpleComboboxInputs extends ExpansionItem {

/** Whether the combobox is disabled. */
disabled: SignalLike<boolean>;

/** Whether the combobox is soft disabled. */
softDisabled: SignalLike<boolean>;
}

/** Controls the state of a simple combobox. */
Expand All @@ -46,6 +49,9 @@ export class SimpleComboboxPattern {
/** Whether the combobox is disabled. */
readonly disabled = () => this.inputs.disabled();

/** Whether the combobox is soft disabled. */
readonly softDisabled = () => this.inputs.softDisabled();

/** An inline suggestion to be displayed in the input. */
readonly inlineSuggestion = () => this.inputs.inlineSuggestion();

Expand Down
34 changes: 34 additions & 0 deletions src/aria/simple-combobox/simple-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,36 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});
});

describe('Disabled', () => {
beforeEach(() => setupCombobox());

it('should keep the input focusable by default when disabled', () => {
fixture.componentInstance.disabled.set(true);
fixture.detectChanges();

expect(inputElement.disabled).toBe(false);
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});

it('should block interactions when disabled', () => {
fixture.componentInstance.disabled.set(true);
fixture.detectChanges();

focus();
keydown('ArrowDown');
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});

it('should make the input unfocusable when softDisabled is false', () => {
fixture.componentInstance.disabled.set(true);
fixture.componentInstance.softDisabled.set(false);
fixture.detectChanges();

expect(inputElement.disabled).toBe(true);
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
});
});
});

describe('with Tree', () => {
Expand Down Expand Up @@ -1145,6 +1175,8 @@ describe('Combobox', () => {
[(value)]="searchString"
[(expanded)]="popupExpanded"
[readonly]="readonly()"
[disabled]="disabled()"
[softDisabled]="softDisabled()"
[alwaysExpanded]="alwaysExpanded()"
(focusout)="onBlur()"
/>
Expand All @@ -1168,6 +1200,8 @@ describe('Combobox', () => {
})
class ComboboxListboxExample {
readonly = signal(false);
disabled = signal(false);
softDisabled = signal(true);
alwaysExpanded = signal(false);
popupExpanded = signal(false);
searchString = signal('');
Expand Down
5 changes: 5 additions & 0 deletions src/aria/simple-combobox/simple-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import type {ComboboxPopup} from './simple-combobox-popup';
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
'[attr.aria-controls]': '_pattern.popupId()',
'[attr.aria-haspopup]': '_pattern.popupType()',
'[attr.tabindex]': 'disabled() && !softDisabled() ? -1 : null',
'[attr.disabled]': 'disabled() && !softDisabled() ? "" : null',
'(keydown)': '_pattern.onKeydown($event)',
'(focusin)': '_pattern.onFocusin()',
'(focusout)': '_pattern.onFocusout($event)',
Expand All @@ -73,6 +75,9 @@ export class Combobox extends DeferredContentAware implements OnInit {
/** Whether the combobox is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

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

/** Whether the combobox should always remain expanded. */
readonly alwaysExpanded = input(false, {transform: booleanAttribute});

Expand Down
2 changes: 1 addition & 1 deletion src/components-examples/aria/autocomplete/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ ng_project(
"//:node_modules/@angular/common",
"//:node_modules/@angular/core",
"//:node_modules/@angular/forms",
"//src/aria/combobox",
"//src/aria/listbox",
"//src/aria/simple-combobox",
"//src/cdk/overlay",
],
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
<div ngCombobox filterMode="auto-select">
<div class="example-combobox-container">
<div #origin class="example-autocomplete">
<span class="example-search-icon material-symbols-outlined" translate="no">search</span>
<input
ngCombobox
#combobox="ngCombobox"
aria-label="Label dropdown"
placeholder="Select a country"
ngComboboxInput
[(value)]="searchString"
[(expanded)]="popupExpanded"
/>
<button
class="example-clear-button"
Expand All @@ -20,17 +23,14 @@
{{countries().length === 0 ? 'No results found for ' + query() : ''}}
</div>

<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true">
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup">
@if (countries().length === 0) {
<div class="example-no-results">No results found</div>
}

<div ngListbox>
<div ngListbox ngComboboxWidget focusMode="activedescendant" [tabIndex]="-1" [(value)]="selectedOption" (click)="onCommit()" (keydown.enter)="onCommit()">
@for (country of countries(); track country) {
<div ngOption [value]="country" [label]="country" [disabled]="country === 'Brazil'">
<span class="example-option-label">{{country}}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {COUNTRIES} from '../countries';
import {OverlayModule} from '@angular/cdk/overlay';
Expand All @@ -30,60 +25,52 @@ import {FormsModule} from '@angular/forms';
selector: 'autocomplete-auto-select-example',
templateUrl: 'autocomplete-auto-select-example.html',
styleUrl: '../autocomplete.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
FormsModule,
],
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutocompleteAutoSelectExample {
/** The selected value of the combobox. */
listbox = viewChild<Listbox<string>>(Listbox);

/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);

/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
readonly listbox = viewChild(Listbox);
readonly combobox = viewChild(Combobox);

/** A reference to the ng aria combobox input. */
comboboxInput = viewChild<ComboboxInput>(ComboboxInput);
popupExpanded = signal(false);
searchString = signal('');
selectedOption = signal<string[]>([]);

/** The query string used to filter the list of countries. */
query = computed(() => this.comboboxInput()?.value() || '');
query = computed(() => this.searchString());

/** The list of countries filtered by the query. */
countries = computed(() =>
COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())),
);

constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
if (this.combobox()?.expanded()) {
const option = this.options().find(opt => opt.active());
option?.element.scrollIntoView({block: 'nearest'});
}
this.listbox()?.scrollActiveItemIntoView();
});
}

/** Clears the query and the listbox value. */
clear(): void {
this.comboboxInput()?.value.set('');
this.listbox?.()?.value.set([]);
this.searchString.set('');
this.selectedOption.set([]);
}

onCommit() {
const selectedOption = this.selectedOption();
if (selectedOption.length > 0) {
this.searchString.set(selectedOption[0]);
}
this.popupExpanded.set(false);
this.combobox()?.element.focus();
}

/** Handles keydown events on the clear button. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.clear();
this.combobox?.()?.close();
this.popupExpanded.set(false);
event.stopPropagation();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
<div ngCombobox disabled>
<div class="example-combobox-container">
<div #origin class="example-autocomplete">
<span class="example-search-icon material-symbols-outlined" translate="no">search</span>
<input
ngCombobox
#combobox="ngCombobox"
aria-label="Label dropdown"
placeholder="Select a country"
ngComboboxInput
[(value)]="searchString"
[(expanded)]="popupExpanded"
disabled
readonly
/>
</div>

<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true">
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup">
@if (countries().length === 0) {
<div class="example-no-results">No results found</div>
}

<div ngListbox>
<div ngListbox ngComboboxWidget focusMode="activedescendant" [tabIndex]="-1" [(value)]="selectedOption">
@for (country of countries(); track country) {
<div ngOption [value]="country" [label]="country" [disabled]="country === 'Brazil'">
<span class="example-option-label">{{country}}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Combobox, ComboboxPopup, ComboboxWidget} from '@angular/aria/simple-combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
Expand All @@ -20,7 +15,6 @@ import {
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {COUNTRIES} from '../countries';
import {OverlayModule} from '@angular/cdk/overlay';
Expand All @@ -31,40 +25,29 @@ import {FormsModule} from '@angular/forms';
selector: 'autocomplete-disabled-example',
templateUrl: 'autocomplete-disabled-example.html',
styleUrl: '../autocomplete.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
FormsModule,
],
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option, OverlayModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutocompleteDisabledExample {
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** The selected value of the combobox. */
readonly listbox = viewChild(Listbox);
readonly combobox = viewChild(Combobox);

/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
popupExpanded = signal(false);
searchString = signal('United States of America');
selectedOption = signal<string[]>([]);

/** The query string used to filter the list of countries. */
query = signal('United States of America');
query = computed(() => this.searchString());

/** The list of countries filtered by the query. */
countries = computed(() =>
COUNTRIES.filter(country => country.toLowerCase().startsWith(this.query().toLowerCase())),
);

constructor() {
// Scrolls to the active item when the active option changes.
afterRenderEffect(() => {
if (this.combobox()?.expanded()) {
const option = this.options().find(opt => opt.active());
option?.element.scrollIntoView({block: 'nearest'});
}
this.listbox()?.scrollActiveItemIntoView();
});
}
}
Loading
Loading