diff --git a/goldens/aria/listbox/index.api.md b/goldens/aria/listbox/index.api.md index c7b3f0eb7dcb..2f22705159dd 100644 --- a/goldens/aria/listbox/index.api.md +++ b/goldens/aria/listbox/index.api.md @@ -4,6 +4,7 @@ ```ts +import * as _angular_aria_private_public_api from '@angular/aria/private/public-api'; import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; import { ComboboxDialogPattern } from '@angular/aria/private'; @@ -11,8 +12,6 @@ import { ComboboxListboxControls } from '@angular/aria/private'; import { ComboboxPattern } from '@angular/aria/private'; import { ComboboxTreeControls } from '@angular/aria/private'; import * as i1 from '@angular/aria/private'; -import { ListboxPattern } from '@angular/aria/private'; -import { OptionPattern } from '@angular/aria/private'; import { WritableSignal } from '@angular/core'; // @public @@ -23,7 +22,7 @@ export class Listbox { focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; gotoFirst(): void; readonly id: _angular_core.InputSignal; - protected items: _angular_core.Signal; + protected items: _angular_core.Signal<_angular_aria_private_public_api.OptionPattern[]>; multi: _angular_core.InputSignalWithTransform; // (undocumented) _onFocus(): void; diff --git a/src/aria/listbox/index.ts b/src/aria/listbox/index.ts index a21d11c2d475..d601e9cbfe33 100644 --- a/src/aria/listbox/index.ts +++ b/src/aria/listbox/index.ts @@ -6,4 +6,5 @@ * found in the LICENSE file at https://angular.dev/license */ -export {Listbox, Option} from './listbox'; +export {Listbox} from './listbox'; +export {Option} from './option'; diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 5bba549e061a..caa6e3f24e7a 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -1,5 +1,6 @@ import {Component, DebugElement, signal} from '@angular/core'; -import {Listbox, Option} from './listbox'; +import {Listbox} from './listbox'; +import {Option} from './option'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Direction} from '@angular/cdk/bidi'; diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index b6ea4696adf4..11c07689bea2 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -13,18 +13,19 @@ import { contentChildren, Directive, ElementRef, - forwardRef, inject, input, model, signal, untracked, } from '@angular/core'; -import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '@angular/aria/private'; import {Directionality} from '@angular/cdk/bidi'; import {toSignal} from '@angular/core/rxjs-interop'; import {_IdGenerator} from '@angular/cdk/a11y'; +import {ComboboxListboxPattern, ListboxPattern} from '../private'; import {ComboboxPopup} from '../combobox'; +import {Option} from './option'; +import {LISTBOX} from './tokens'; /** * Represents a container used to display a list of items for a user to select from. @@ -62,6 +63,7 @@ import {ComboboxPopup} from '../combobox'; '(focusin)': '_onFocus()', }, hostDirectives: [ComboboxPopup], + providers: [{provide: LISTBOX, useExisting: Listbox}], }) export class Listbox { /** A unique identifier for the listbox. */ @@ -82,13 +84,7 @@ export class Listbox { private readonly _directionality = inject(Directionality); /** The Options nested inside of the Listbox. */ - private readonly _options = contentChildren( - // We need a `forwardRef` here, because the option class is declared further down - // in the same file. When the reference is written to Angular's metadata this can - // cause an attempt to access the class before it's defined. - forwardRef(() => Option), - {descendants: true}, - ); + private readonly _options = contentChildren(Option, {descendants: true}); /** A signal wrapper for directionality. */ protected textDirection = toSignal(this._directionality.change, { @@ -214,77 +210,3 @@ export class Listbox { this._pattern.listBehavior.first(); } } - -/** - * A selectable option in an `ngListbox`. - * - * This directive should be applied to an element (e.g., `
  • `, `
    `) within an - * `ngListbox`. The `value` input is used to identify the option, and the `label` input provides - * the accessible name for the option. - * - * ```html - *
  • - * Item Name - *
  • - * ``` - * - * @developerPreview 21.0 - */ -@Directive({ - selector: '[ngOption]', - exportAs: 'ngOption', - host: { - 'role': 'option', - '[attr.data-active]': 'active()', - '[attr.id]': '_pattern.id()', - '[attr.tabindex]': '_pattern.tabIndex()', - '[attr.aria-selected]': '_pattern.selected()', - '[attr.aria-disabled]': '_pattern.disabled()', - }, -}) -export class Option { - /** A reference to the host element. */ - private readonly _elementRef = inject(ElementRef); - - /** A reference to the host element. */ - readonly element = this._elementRef.nativeElement as HTMLElement; - - /** Whether the option is currently active (focused). */ - active = computed(() => this._pattern.active()); - - /** The parent Listbox. */ - private readonly _listbox = inject(Listbox); - - /** A unique identifier for the option. */ - readonly id = input(inject(_IdGenerator).getId('ng-option-', true)); - - // TODO(wagnermaciel): See if we want to change how we handle this since textContent is not - // reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216. - /** The text used by the typeahead search. */ - protected searchTerm = computed(() => this.label() ?? this.element.textContent); - - /** The parent Listbox UIPattern. */ - private readonly _listboxPattern = computed(() => this._listbox._pattern); - - /** The value of the option. */ - value = input.required(); - - /** Whether an item is disabled. */ - disabled = input(false, {transform: booleanAttribute}); - - /** The text used by the typeahead search. */ - label = input(); - - /** Whether the option is selected. */ - readonly selected = computed(() => this._pattern.selected()); - - /** The Option UIPattern. */ - readonly _pattern = new OptionPattern({ - ...this, - id: this.id, - value: this.value, - listbox: this._listboxPattern, - element: () => this.element, - searchTerm: () => this.searchTerm() ?? '', - }); -} diff --git a/src/aria/listbox/option.ts b/src/aria/listbox/option.ts new file mode 100644 index 000000000000..8bd28e817186 --- /dev/null +++ b/src/aria/listbox/option.ts @@ -0,0 +1,83 @@ +/** + * @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 {booleanAttribute, computed, Directive, ElementRef, inject, input} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {OptionPattern} from '../private'; +import {LISTBOX} from './tokens'; + +/** + * A selectable option in an `ngListbox`. + * + * This directive should be applied to an element (e.g., `
  • `, `
    `) within an + * `ngListbox`. The `value` input is used to identify the option, and the `label` input provides + * the accessible name for the option. + * + * ```html + *
  • + * Item Name + *
  • + * ``` + * + * @developerPreview 21.0 + */ +@Directive({ + selector: '[ngOption]', + exportAs: 'ngOption', + host: { + 'role': 'option', + '[attr.data-active]': 'active()', + '[attr.id]': '_pattern.id()', + '[attr.tabindex]': '_pattern.tabIndex()', + '[attr.aria-selected]': '_pattern.selected()', + '[attr.aria-disabled]': '_pattern.disabled()', + }, +}) +export class Option { + /** A reference to the host element. */ + readonly element = inject(ElementRef).nativeElement as HTMLElement; + + /** Whether the option is currently active (focused). */ + active = computed(() => this._pattern.active()); + + /** The parent Listbox. */ + private readonly _listbox = inject(LISTBOX); + + /** A unique identifier for the option. */ + readonly id = input(inject(_IdGenerator).getId('ng-option-', true)); + + // TODO(wagnermaciel): See if we want to change how we handle this since textContent is not + // reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216. + /** The text used by the typeahead search. */ + protected searchTerm = computed(() => this.label() ?? this.element.textContent); + + /** The parent Listbox UIPattern. */ + private readonly _listboxPattern = computed(() => this._listbox._pattern); + + /** The value of the option. */ + value = input.required(); + + /** Whether an item is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The text used by the typeahead search. */ + label = input(); + + /** Whether the option is selected. */ + readonly selected = computed(() => this._pattern.selected()); + + /** The Option UIPattern. */ + readonly _pattern = new OptionPattern({ + ...this, + id: this.id, + value: this.value, + listbox: this._listboxPattern, + element: () => this.element, + searchTerm: () => this.searchTerm() ?? '', + }); +} diff --git a/src/aria/listbox/tokens.ts b/src/aria/listbox/tokens.ts new file mode 100644 index 000000000000..0caf8ad6d1aa --- /dev/null +++ b/src/aria/listbox/tokens.ts @@ -0,0 +1,12 @@ +/** + * @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 {InjectionToken} from '@angular/core'; +import type {Listbox} from './listbox'; + +export const LISTBOX = new InjectionToken>('LISTBOX');