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,8 +671,10 @@ export interface SimpleComboboxInputs extends ExpansionItem {
disabled: SignalLike<boolean>;
element: SignalLike<HTMLElement>;
inlineSuggestion: SignalLike<string | undefined>;
openOnInput: SignalLike<boolean>;
popup: SignalLike<SimpleComboboxPopupPattern | undefined>;
softDisabled?: SignalLike<boolean>;
trigger?: SignalLike<HTMLElement | undefined>;
value: WritableSignalLike<string>;
}

Expand Down
4 changes: 3 additions & 1 deletion goldens/aria/simple-combobox/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ export class Combobox extends DeferredContentAware implements OnInit {
readonly inlineSuggestion: _angular_core.InputSignal<string | undefined>;
// (undocumented)
ngOnInit(): void;
readonly openOnInput: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly _pattern: SimpleComboboxPattern;
readonly _popup: _angular_core.WritableSignal<ComboboxPopup | undefined>;
_registerPopup(popup: ComboboxPopup): void;
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly tabIndex: _angular_core.InputSignalWithTransform<number | undefined, string | number | undefined>;
readonly trigger: _angular_core.InputSignal<HTMLElement | 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; }; "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; }; "openOnInput": { "alias": "openOnInput"; "required": false; "isSignal": true; }; "trigger": { "alias": "trigger"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; "value": "valueChange"; }, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Combobox, never>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/aria/private/simple-combobox/simple-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe('SimpleComboboxPattern', () => {
disabled,
expanded,
expandable: signal(true),
openOnInput: signal(true),
trigger: signal(undefined),
});

return {
Expand Down
19 changes: 15 additions & 4 deletions src/aria/private/simple-combobox/simple-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export interface SimpleComboboxInputs extends ExpansionItem {

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

/** Whether the combobox opens automatically on text input. */
openOnInput: SignalLike<boolean>;

/** Optional trigger element associated with the combobox. */
trigger?: SignalLike<HTMLElement | undefined>;
}

/** Controls the state of a simple combobox. */
Expand Down Expand Up @@ -98,7 +104,6 @@ export class SimpleComboboxPattern {
);

/** The keydown event manager for the combobox. */
// TODO(tjshiu): Allow combo keys in combobox (#33101).
keydown = computed(() => {
const manager = new KeyboardEventManager();

Expand Down Expand Up @@ -202,7 +207,9 @@ export class SimpleComboboxPattern {
if (!(event.target instanceof HTMLInputElement)) return;
if (this.disabled()) return;

this.inputs.expanded.set(true);
if (this.inputs.openOnInput()) {
this.inputs.expanded.set(true);
}
this.value.set(event.target.value);
this.isDeleting.set(event instanceof InputEvent && !!event.inputType.match(/^delete/));
}
Expand Down Expand Up @@ -244,10 +251,14 @@ export class SimpleComboboxPattern {

/** Closes the popup when focus leaves the combobox and popup. */
closePopupOnBlurEffect() {
const expanded = this.isExpanded();
if (!this.isExpanded() || this.inputs.alwaysExpanded()) return;

const comboboxFocused = this.isFocused();
const popupFocused = !!this.inputs.popup()?.isFocused();
if (expanded && !this.inputs.alwaysExpanded() && !comboboxFocused && !popupFocused) {
const triggerEl = this.inputs.trigger?.();
const triggerFocused = !!triggerEl?.contains(document.activeElement);

if (!comboboxFocused && !popupFocused && !triggerFocused) {
this.inputs.expanded.set(false);
}
}
Expand Down
147 changes: 147 additions & 0 deletions src/aria/simple-combobox/simple-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,82 @@ describe('Combobox', () => {
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});
});

describe('Custom triggering via openOnInput', () => {
it('should automatically open on text input by default', () => {
setupCombobox(ComboboxListboxCustomTriggerExample);
focus();
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
input('A');
expect(inputElement.getAttribute('aria-expanded')).toBe('true');
});

it('should not open on text input when openOnInput is false', () => {
setupCombobox(ComboboxListboxCustomTriggerExample);
(fixture.componentInstance as any).openOnInput.set(false);
fixture.detectChanges();

focus();
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
input('A');
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
});
});

describe('Trigger element focus zone and toggling', () => {
it('should open the popup when clicking the trigger element from a closed state', () => {
setupCombobox(ComboboxListboxTriggerZoneExample);
expect(inputElement.getAttribute('aria-expanded')).toBe('false');

const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
btn.focus();
btn.click();
fixture.detectChanges();

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

it('should close the popup when clicking the trigger element from an open state', () => {
setupCombobox(ComboboxListboxTriggerZoneExample);

const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
btn.focus();
btn.click();
fixture.detectChanges();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');

btn.click();
fixture.detectChanges();

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

it('should not close the popup on focusout when focus moves to the bound trigger element', () => {
setupCombobox(ComboboxListboxTriggerZoneExample);

down();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');

const btn = fixture.nativeElement.querySelector('#test-trigger-btn');
btn.focus();
blur(btn);

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

it('should close the popup on focusout if focus leaves the combobox to an unrelated element', () => {
setupCombobox(ComboboxListboxTriggerZoneExample);

down();
expect(inputElement.getAttribute('aria-expanded')).toBe('true');

const unrelatedElement = document.createElement('div');
blur(unrelatedElement);

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

describe('Selection', () => {
describe('with manual filtering', () => {
beforeEach(() => setupCombobox(ComboboxListboxExample));
Expand Down Expand Up @@ -1705,3 +1781,74 @@ class ComboboxListboxHighlightExample {
this.popupExpanded.set(false);
}
}

@Component({
template: `
<div>
<input
ngCombobox
#combobox="ngCombobox"
placeholder="Search..."
[(value)]="searchString"
[(expanded)]="popupExpanded"
[openOnInput]="openOnInput()"
(click)="popupExpanded.set(true)"
/>

<ng-template ngComboboxPopup [combobox]="combobox">
<div ngComboboxWidget #listbox="ngListbox" ngListbox id="listbox" focusMode="activedescendant" selectionMode="explicit" [(value)]="value" (click)="onCommit()" (keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()">
@for (option of options(); track option) {
<div ngOption [value]="option" [label]="option">
<span>{{option}}</span>
</div>
}
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
})
class ComboboxListboxCustomTriggerExample {
openOnInput = signal(true);
popupExpanded = signal(false);
searchString = signal('');
value = signal<string[]>([]);

options = computed(() =>
states.filter(state => state.toLowerCase().startsWith(this.searchString().toLowerCase())),
);

onCommit() {
const val = this.value();
if (val.length > 0) {
this.searchString.set(val[0]);
}
this.popupExpanded.set(false);
}
}

@Component({
template: `
<div>
<input
ngCombobox
#combobox="ngCombobox"
[trigger]="triggerEl"
[(expanded)]="popupExpanded"
[openOnInput]="false"
aria-label="Search"
/>
<button #triggerEl id="test-trigger-btn" (click)="popupExpanded.set(!popupExpanded())">Toggle</button>

<ng-template ngComboboxPopup [combobox]="combobox">
<div ngComboboxWidget ngListbox id="listbox" focusMode="activedescendant">
<div ngOption value="Alabama" label="Alabama">Alabama</div>
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
})
class ComboboxListboxTriggerZoneExample {
popupExpanded = signal(false);
}
8 changes: 8 additions & 0 deletions src/aria/simple-combobox/simple-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,17 @@ export class Combobox extends DeferredContentAware implements OnInit {
/** An inline suggestion to be displayed in the input. */
readonly inlineSuggestion = input<string | undefined>(undefined);

/** Whether the combobox opens automatically on text input. */
readonly openOnInput = input(true, {transform: booleanAttribute});

/** Optional trigger element associated with the combobox. */
readonly trigger = input<HTMLElement | undefined>(undefined);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel these two APIs are for niche use cases that need more discussion. Let's discuss in the sync.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah - I can see that: It's related to this: #32493

I added an example for dev-app preview, which adds a trigger button.


/** The combobox ui pattern. */
readonly _pattern = new SimpleComboboxPattern({
...this,
openOnInput: () => this.openOnInput(),
trigger: () => this.trigger(),
element: () => this.element,
expandable: () => true,
popup: computed(() => this._popup()?._pattern),
Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/simple-combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {SimpleComboboxAutocompleteAutoSelectExample} from './simple-combobox-aut
export {SimpleComboboxAutocompleteDisabledExample} from './simple-combobox-autocomplete-disabled/simple-combobox-autocomplete-disabled-example';
export {SimpleComboboxAutocompleteHighlightExample} from './simple-combobox-autocomplete-highlight/simple-combobox-autocomplete-highlight-example';
export {SimpleComboboxAutocompleteManualExample} from './simple-combobox-autocomplete-manual/simple-combobox-autocomplete-manual-example';
export {SimpleComboboxCustomTriggerExample} from './simple-combobox-custom-trigger/simple-combobox-custom-trigger-example';
export {SimpleComboboxMultiselectDialogExample} from './simple-combobox-multiselect-dialog/simple-combobox-multiselect-dialog-example';

// Force watcher update
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<div class="example-combobox-container">
<div #origin class="example-combobox-input-container">
<span class="material-symbols-outlined example-icon example-search-icon">search</span>
<input ngCombobox #combobox="ngCombobox" [trigger]="btnEl" class="example-combobox-input" placeholder="Search states..."
[(value)]="searchString" [(expanded)]="popupExpanded" [openOnInput]="false" />
<button #btnEl (click)="togglePopup()" aria-label="Toggle popup" class="example-combobox-button example-button">
<span class="material-symbols-outlined">{{ popupExpanded() ? 'arrow_drop_up' : 'arrow_drop_down' }}</span>
</button>
</div>

<div aria-live="polite" class="cdk-visually-hidden">
{{options().length === 0 ? 'No results found for ' + searchString() : ''}}
</div>

<ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="popupExpanded()" [cdkConnectedOverlayDisableClose]="true">
<ng-template ngComboboxPopup [combobox]="combobox">
<div class="example-popup">
@if (options().length === 0) {
<div class="example-no-results">No results found</div>
}
<div #listbox="ngListbox" ngListbox ngComboboxWidget class="example-listbox" focusMode="activedescendant"
[tabindex]="-1" selectionMode="explicit" [(value)]="selectedOption" (click)="onCommit()"
(keydown.enter)="onCommit()" [activeDescendant]="listbox.activeDescendant()"
[class.example-empty]="options().length === 0">
@for (option of options(); track option) {
<div class="example-option example-selectable example-stateful" ngOption [value]="option" [label]="option">
<span>{{option}}</span>
<span aria-hidden="true" class="material-symbols-outlined example-icon example-selected-icon">check</span>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
Loading
Loading