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
5 changes: 2 additions & 3 deletions goldens/aria/listbox/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@

```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';
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
Expand All @@ -23,7 +22,7 @@ export class Listbox<V> {
focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
gotoFirst(): void;
readonly id: _angular_core.InputSignal<string>;
protected items: _angular_core.Signal<any[]>;
protected items: _angular_core.Signal<_angular_aria_private_public_api.OptionPattern<any>[]>;
multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
// (undocumented)
_onFocus(): void;
Expand Down
3 changes: 2 additions & 1 deletion src/aria/listbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 2 additions & 1 deletion src/aria/listbox/listbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
88 changes: 5 additions & 83 deletions src/aria/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,6 +63,7 @@ import {ComboboxPopup} from '../combobox';
'(focusin)': '_onFocus()',
},
hostDirectives: [ComboboxPopup],
providers: [{provide: LISTBOX, useExisting: Listbox}],
})
export class Listbox<V> {
/** A unique identifier for the listbox. */
Expand All @@ -82,13 +84,7 @@ export class Listbox<V> {
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, {
Expand Down Expand Up @@ -214,77 +210,3 @@ export class Listbox<V> {
this._pattern.listBehavior.first();
}
}

/**
* A selectable option in an `ngListbox`.
*
* This directive should be applied to an element (e.g., `<li>`, `<div>`) within an
* `ngListbox`. The `value` input is used to identify the option, and the `label` input provides
* the accessible name for the option.
*
* ```html
* <li ngOption value="item-id" label="Item Name">
* Item Name
* </li>
* ```
*
* @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<V> {
/** 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<V>();

/** Whether an item is disabled. */
disabled = input(false, {transform: booleanAttribute});

/** The text used by the typeahead search. */
label = input<string>();

/** Whether the option is selected. */
readonly selected = computed(() => this._pattern.selected());

/** The Option UIPattern. */
readonly _pattern = new OptionPattern<V>({
...this,
id: this.id,
value: this.value,
listbox: this._listboxPattern,
element: () => this.element,
searchTerm: () => this.searchTerm() ?? '',
});
}
83 changes: 83 additions & 0 deletions src/aria/listbox/option.ts
Original file line number Diff line number Diff line change
@@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we use '@angular/aria/private'; or does that have no impact?

Copy link
Member Author

Choose a reason for hiding this comment

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

Imports within the same npm package need to be relative after the recent dev infra changes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Should we change listbox.ts to use the relative path then?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah yeah we should. Not sure why the build didn't break.

import {LISTBOX} from './tokens';

/**
* A selectable option in an `ngListbox`.
*
* This directive should be applied to an element (e.g., `<li>`, `<div>`) within an
* `ngListbox`. The `value` input is used to identify the option, and the `label` input provides
* the accessible name for the option.
*
* ```html
* <li ngOption value="item-id" label="Item Name">
* Item Name
* </li>
* ```
*
* @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<V> {
/** 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<V>();

/** Whether an item is disabled. */
disabled = input(false, {transform: booleanAttribute});

/** The text used by the typeahead search. */
label = input<string>();

/** Whether the option is selected. */
readonly selected = computed(() => this._pattern.selected());

/** The Option UIPattern. */
readonly _pattern = new OptionPattern<V>({
...this,
id: this.id,
value: this.value,
listbox: this._listboxPattern,
element: () => this.element,
searchTerm: () => this.searchTerm() ?? '',
});
}
12 changes: 12 additions & 0 deletions src/aria/listbox/tokens.ts
Original file line number Diff line number Diff line change
@@ -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<any>>('LISTBOX');
Loading