Skip to content

Commit fd6273b

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 fd6273b

File tree

5 files changed

+104
-84
lines changed

5 files changed

+104
-84
lines changed

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 & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import {
2020
signal,
2121
untracked,
2222
} from '@angular/core';
23-
import {ComboboxListboxPattern, ListboxPattern, OptionPattern} from '@angular/aria/private';
23+
import {ComboboxListboxPattern, ListboxPattern} from '@angular/aria/private';
2424
import {Directionality} from '@angular/cdk/bidi';
2525
import {toSignal} from '@angular/core/rxjs-interop';
2626
import {_IdGenerator} from '@angular/cdk/a11y';
2727
import {ComboboxPopup} from '../combobox';
28+
import {Option} from './option';
29+
import {LISTBOX} from './tokens';
2830

2931
/**
3032
* Represents a container used to display a list of items for a user to select from.
@@ -62,6 +64,7 @@ import {ComboboxPopup} from '../combobox';
6264
'(focusin)': '_onFocus()',
6365
},
6466
hostDirectives: [ComboboxPopup],
67+
providers: [{provide: LISTBOX, useExisting: Listbox}],
6568
})
6669
export class Listbox<V> {
6770
/** A unique identifier for the listbox. */
@@ -82,13 +85,7 @@ export class Listbox<V> {
8285
private readonly _directionality = inject(Directionality);
8386

8487
/** 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-
);
88+
private readonly _options = contentChildren(Option, {descendants: true});
9289

9390
/** A signal wrapper for directionality. */
9491
protected textDirection = toSignal(this._directionality.change, {
@@ -214,77 +211,3 @@ export class Listbox<V> {
214211
this._pattern.listBehavior.first();
215212
}
216213
}
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)