Skip to content

Commit 0cf9339

Browse files
committed
refactor(aria/listbox): avoid circular references at runtime
Reworks the Aria listbox to avoid a circular dependency between `Listbox` and `Option`.
1 parent 76f2603 commit 0cf9339

File tree

7 files changed

+107
-89
lines changed

7 files changed

+107
-89
lines changed

goldens/aria/listbox/index.api.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { ComboboxPattern } from '@angular/aria/private';
1212
import { ComboboxTreeControls } from '@angular/aria/private';
1313
import * as i1 from '@angular/aria/private';
1414
import { ListboxPattern } from '@angular/aria/private';
15-
import { OptionPattern } from '@angular/aria/private';
1615
import { WritableSignal } from '@angular/core';
1716

1817
// @public
@@ -23,7 +22,7 @@ export class Listbox<V> {
2322
focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
2423
gotoFirst(): void;
2524
readonly id: _angular_core.InputSignal<string>;
26-
protected items: _angular_core.Signal<any[]>;
25+
protected items: _angular_core.Signal<i1.OptionPattern<any>[]>;
2726
multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
2827
// (undocumented)
2928
_onFocus(): void;

goldens/aria/private/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,7 @@ export class MenuTriggerPattern<V> {
648648
// @public
649649
export interface OptionInputs<V> extends Omit<ListItem<V>, 'index' | 'selectable'> {
650650
// (undocumented)
651-
listbox: SignalLike<ListboxPattern$1<V> | undefined>;
651+
listbox: SignalLike<ListboxPattern_2<V> | undefined>;
652652
}
653653

654654
// @public
@@ -659,7 +659,7 @@ export class OptionPattern<V> {
659659
element: SignalLike<HTMLElement | undefined>;
660660
id: SignalLike<string>;
661661
index: _angular_core.Signal<number>;
662-
listbox: SignalLike<ListboxPattern$1<V> | undefined>;
662+
listbox: SignalLike<ListboxPattern_2<V> | undefined>;
663663
searchTerm: SignalLike<string>;
664664
selectable: () => boolean;
665665
selected: _angular_core.Signal<boolean | undefined>;

src/aria/listbox/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export {Listbox, Option} from './listbox';
9+
export {Listbox} from './listbox';
10+
export {Option} from './option';

src/aria/listbox/listbox.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Component, DebugElement, signal} from '@angular/core';
2-
import {Listbox, Option} from './listbox';
2+
import {Listbox} from './listbox';
3+
import {Option} from './option';
34
import {ComponentFixture, TestBed} from '@angular/core/testing';
45
import {By} from '@angular/platform-browser';
56
import {Direction} from '@angular/cdk/bidi';

src/aria/listbox/listbox.ts

Lines changed: 5 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,19 @@ import {
1313
contentChildren,
1414
Directive,
1515
ElementRef,
16-
forwardRef,
1716
inject,
1817
input,
1918
model,
2019
signal,
2120
untracked,
2221
} from '@angular/core';
23-
import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '@angular/aria/private';
22+
import {ComboboxListboxPattern, ListboxPattern} from '@angular/aria/private';
2423
import {Directionality} from '@angular/cdk/bidi';
2524
import {toSignal} from '@angular/core/rxjs-interop';
2625
import {_IdGenerator} from '@angular/cdk/a11y';
2726
import {ComboboxPopup} from '../combobox';
27+
import {Option} from './option';
28+
import {LISTBOX} from './tokens';
2829

2930
/**
3031
* Represents a container used to display a list of items for a user to select from.
@@ -62,6 +63,7 @@ import {ComboboxPopup} from '../combobox';
6263
'(focusin)': '_onFocus()',
6364
},
6465
hostDirectives: [ComboboxPopup],
66+
providers: [{provide: LISTBOX, useExisting: Listbox}],
6567
})
6668
export class Listbox<V> {
6769
/** A unique identifier for the listbox. */
@@ -82,13 +84,7 @@ export class Listbox<V> {
8284
private readonly _directionality = inject(Directionality);
8385

8486
/** The Options nested inside of the Listbox. */
85-
private readonly _options = contentChildren(
86-
// We need a `forwardRef` here, because the option class is declared further down
87-
// in the same file. When the reference is written to Angular's metadata this can
88-
// cause an attempt to access the class before it's defined.
89-
forwardRef(() => Option),
90-
{descendants: true},
91-
);
87+
private readonly _options = contentChildren(Option, {descendants: true});
9288

9389
/** A signal wrapper for directionality. */
9490
protected textDirection = toSignal(this._directionality.change, {
@@ -214,77 +210,3 @@ export class Listbox<V> {
214210
this._pattern.listBehavior.first();
215211
}
216212
}
217-
218-
/**
219-
* A selectable option in an `ngListbox`.
220-
*
221-
* This directive should be applied to an element (e.g., `<li>`, `<div>`) within an
222-
* `ngListbox`. The `value` input is used to identify the option, and the `label` input provides
223-
* the accessible name for the option.
224-
*
225-
* ```html
226-
* <li ngOption value="item-id" label="Item Name">
227-
* Item Name
228-
* </li>
229-
* ```
230-
*
231-
* @developerPreview 21.0
232-
*/
233-
@Directive({
234-
selector: '[ngOption]',
235-
exportAs: 'ngOption',
236-
host: {
237-
'role': 'option',
238-
'[attr.data-active]': 'active()',
239-
'[attr.id]': '_pattern.id()',
240-
'[attr.tabindex]': '_pattern.tabIndex()',
241-
'[attr.aria-selected]': '_pattern.selected()',
242-
'[attr.aria-disabled]': '_pattern.disabled()',
243-
},
244-
})
245-
export class Option<V> {
246-
/** A reference to the host element. */
247-
private readonly _elementRef = inject(ElementRef);
248-
249-
/** A reference to the host element. */
250-
readonly element = this._elementRef.nativeElement as HTMLElement;
251-
252-
/** Whether the option is currently active (focused). */
253-
active = computed(() => this._pattern.active());
254-
255-
/** The parent Listbox. */
256-
private readonly _listbox = inject(Listbox);
257-
258-
/** A unique identifier for the option. */
259-
readonly id = input(inject(_IdGenerator).getId('ng-option-', true));
260-
261-
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
262-
// reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.
263-
/** The text used by the typeahead search. */
264-
protected searchTerm = computed(() => this.label() ?? this.element.textContent);
265-
266-
/** The parent Listbox UIPattern. */
267-
private readonly _listboxPattern = computed(() => this._listbox._pattern);
268-
269-
/** The value of the option. */
270-
value = input.required<V>();
271-
272-
/** Whether an item is disabled. */
273-
disabled = input(false, {transform: booleanAttribute});
274-
275-
/** The text used by the typeahead search. */
276-
label = input<string>();
277-
278-
/** Whether the option is selected. */
279-
readonly selected = computed(() => this._pattern.selected());
280-
281-
/** The Option UIPattern. */
282-
readonly _pattern = new OptionPattern<V>({
283-
...this,
284-
id: this.id,
285-
value: this.value,
286-
listbox: this._listboxPattern,
287-
element: () => this.element,
288-
searchTerm: () => this.searchTerm() ?? '',
289-
});
290-
}

src/aria/listbox/option.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {booleanAttribute, computed, Directive, ElementRef, inject, input} from '@angular/core';
10+
import {_IdGenerator} from '@angular/cdk/a11y';
11+
import {OptionPattern} from '../private';
12+
import {LISTBOX} from './tokens';
13+
14+
/**
15+
* A selectable option in an `ngListbox`.
16+
*
17+
* This directive should be applied to an element (e.g., `<li>`, `<div>`) within an
18+
* `ngListbox`. The `value` input is used to identify the option, and the `label` input provides
19+
* the accessible name for the option.
20+
*
21+
* ```html
22+
* <li ngOption value="item-id" label="Item Name">
23+
* Item Name
24+
* </li>
25+
* ```
26+
*
27+
* @developerPreview 21.0
28+
*/
29+
@Directive({
30+
selector: '[ngOption]',
31+
exportAs: 'ngOption',
32+
host: {
33+
'role': 'option',
34+
'[attr.data-active]': 'active()',
35+
'[attr.id]': '_pattern.id()',
36+
'[attr.tabindex]': '_pattern.tabIndex()',
37+
'[attr.aria-selected]': '_pattern.selected()',
38+
'[attr.aria-disabled]': '_pattern.disabled()',
39+
},
40+
})
41+
export class Option<V> {
42+
/** A reference to the host element. */
43+
readonly element = inject(ElementRef).nativeElement as HTMLElement;
44+
45+
/** Whether the option is currently active (focused). */
46+
active = computed(() => this._pattern.active());
47+
48+
/** The parent Listbox. */
49+
private readonly _listbox = inject(LISTBOX);
50+
51+
/** A unique identifier for the option. */
52+
readonly id = input(inject(_IdGenerator).getId('ng-option-', true));
53+
54+
// TODO(wagnermaciel): See if we want to change how we handle this since textContent is not
55+
// reactive. See https://github.com/angular/components/pull/30495#discussion_r1961260216.
56+
/** The text used by the typeahead search. */
57+
protected searchTerm = computed(() => this.label() ?? this.element.textContent);
58+
59+
/** The parent Listbox UIPattern. */
60+
private readonly _listboxPattern = computed(() => this._listbox._pattern);
61+
62+
/** The value of the option. */
63+
value = input.required<V>();
64+
65+
/** Whether an item is disabled. */
66+
disabled = input(false, {transform: booleanAttribute});
67+
68+
/** The text used by the typeahead search. */
69+
label = input<string>();
70+
71+
/** Whether the option is selected. */
72+
readonly selected = computed(() => this._pattern.selected());
73+
74+
/** The Option UIPattern. */
75+
readonly _pattern = new OptionPattern<V>({
76+
...this,
77+
id: this.id,
78+
value: this.value,
79+
listbox: this._listboxPattern,
80+
element: () => this.element,
81+
searchTerm: () => this.searchTerm() ?? '',
82+
});
83+
}

src/aria/listbox/tokens.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import type {Listbox} from './listbox';
11+
12+
export const LISTBOX = new InjectionToken<Listbox<any>>('LISTBOX');

0 commit comments

Comments
 (0)