forty-cdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion/README.md +122 -0
- package/aspect-ratio/README.md +76 -0
- package/avatar/README.md +100 -0
- package/breadcrumbs/README.md +49 -0
- package/breakpoints/README.md +81 -0
- package/button/README.md +49 -0
- package/calendar/README.md +458 -0
- package/carousel/README.md +358 -0
- package/checkbox/README.md +146 -0
- package/combobox/README.md +535 -0
- package/context-menu/README.md +139 -0
- package/date-field/README.md +184 -0
- package/date-picker/README.md +338 -0
- package/dialog/README.md +388 -0
- package/disclosure/README.md +114 -0
- package/drag-drop/README.md +359 -0
- package/drawer/README.md +560 -0
- package/dropdown-menu/README.md +176 -0
- package/fesm2022/forty-cdk-accordion.mjs +348 -0
- package/fesm2022/forty-cdk-accordion.mjs.map +1 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs +74 -0
- package/fesm2022/forty-cdk-aspect-ratio.mjs.map +1 -0
- package/fesm2022/forty-cdk-avatar.mjs +308 -0
- package/fesm2022/forty-cdk-avatar.mjs.map +1 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs +125 -0
- package/fesm2022/forty-cdk-breadcrumbs.mjs.map +1 -0
- package/fesm2022/forty-cdk-breakpoints.mjs +117 -0
- package/fesm2022/forty-cdk-breakpoints.mjs.map +1 -0
- package/fesm2022/forty-cdk-button.mjs +134 -0
- package/fesm2022/forty-cdk-button.mjs.map +1 -0
- package/fesm2022/forty-cdk-calendar.mjs +2034 -0
- package/fesm2022/forty-cdk-calendar.mjs.map +1 -0
- package/fesm2022/forty-cdk-carousel.mjs +968 -0
- package/fesm2022/forty-cdk-carousel.mjs.map +1 -0
- package/fesm2022/forty-cdk-checkbox.mjs +226 -0
- package/fesm2022/forty-cdk-checkbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-combobox.mjs +2596 -0
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -0
- package/fesm2022/forty-cdk-context-menu.mjs +413 -0
- package/fesm2022/forty-cdk-context-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-core.mjs +9022 -0
- package/fesm2022/forty-cdk-core.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-field.mjs +744 -0
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-date-picker.mjs +1011 -0
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-dialog.mjs +707 -0
- package/fesm2022/forty-cdk-dialog.mjs.map +1 -0
- package/fesm2022/forty-cdk-disclosure.mjs +190 -0
- package/fesm2022/forty-cdk-disclosure.mjs.map +1 -0
- package/fesm2022/forty-cdk-drag-drop.mjs +1180 -0
- package/fesm2022/forty-cdk-drag-drop.mjs.map +1 -0
- package/fesm2022/forty-cdk-drawer.mjs +1641 -0
- package/fesm2022/forty-cdk-drawer.mjs.map +1 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs +350 -0
- package/fesm2022/forty-cdk-dropdown-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-field.mjs +425 -0
- package/fesm2022/forty-cdk-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-fieldset.mjs +164 -0
- package/fesm2022/forty-cdk-fieldset.mjs.map +1 -0
- package/fesm2022/forty-cdk-file-upload.mjs +221 -0
- package/fesm2022/forty-cdk-file-upload.mjs.map +1 -0
- package/fesm2022/forty-cdk-hover-card.mjs +496 -0
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -0
- package/fesm2022/forty-cdk-input.mjs +274 -0
- package/fesm2022/forty-cdk-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-internationalized-date.mjs +1 -1
- package/fesm2022/forty-cdk-internationalized-date.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +1279 -0
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -0
- package/fesm2022/forty-cdk-menu.mjs +1439 -0
- package/fesm2022/forty-cdk-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-menubar.mjs +787 -0
- package/fesm2022/forty-cdk-menubar.mjs.map +1 -0
- package/fesm2022/forty-cdk-meter.mjs +211 -0
- package/fesm2022/forty-cdk-meter.mjs.map +1 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs +1145 -0
- package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -0
- package/fesm2022/forty-cdk-number-input.mjs +559 -0
- package/fesm2022/forty-cdk-number-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-otp-input.mjs +527 -0
- package/fesm2022/forty-cdk-otp-input.mjs.map +1 -0
- package/fesm2022/forty-cdk-pagination.mjs +323 -0
- package/fesm2022/forty-cdk-pagination.mjs.map +1 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs +297 -0
- package/fesm2022/forty-cdk-pane-resizer.mjs.map +1 -0
- package/fesm2022/forty-cdk-popover.mjs +698 -0
- package/fesm2022/forty-cdk-popover.mjs.map +1 -0
- package/fesm2022/forty-cdk-progress.mjs +226 -0
- package/fesm2022/forty-cdk-progress.mjs.map +1 -0
- package/fesm2022/forty-cdk-radio-group.mjs +378 -0
- package/fesm2022/forty-cdk-radio-group.mjs.map +1 -0
- package/fesm2022/forty-cdk-scroll-area.mjs +640 -0
- package/fesm2022/forty-cdk-scroll-area.mjs.map +1 -0
- package/fesm2022/forty-cdk-search.mjs +205 -0
- package/fesm2022/forty-cdk-search.mjs.map +1 -0
- package/fesm2022/forty-cdk-select.mjs +1661 -0
- package/fesm2022/forty-cdk-select.mjs.map +1 -0
- package/fesm2022/forty-cdk-separator.mjs +82 -0
- package/fesm2022/forty-cdk-separator.mjs.map +1 -0
- package/fesm2022/forty-cdk-signal-forms.mjs +97 -0
- package/fesm2022/forty-cdk-signal-forms.mjs.map +1 -0
- package/fesm2022/forty-cdk-slider.mjs +803 -0
- package/fesm2022/forty-cdk-slider.mjs.map +1 -0
- package/fesm2022/forty-cdk-stepper.mjs +886 -0
- package/fesm2022/forty-cdk-stepper.mjs.map +1 -0
- package/fesm2022/forty-cdk-switch.mjs +137 -0
- package/fesm2022/forty-cdk-switch.mjs.map +1 -0
- package/fesm2022/forty-cdk-table.mjs +1518 -0
- package/fesm2022/forty-cdk-table.mjs.map +1 -0
- package/fesm2022/forty-cdk-tabs.mjs +400 -0
- package/fesm2022/forty-cdk-tabs.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-field.mjs +593 -0
- package/fesm2022/forty-cdk-time-field.mjs.map +1 -0
- package/fesm2022/forty-cdk-time-picker.mjs +1013 -0
- package/fesm2022/forty-cdk-time-picker.mjs.map +1 -0
- package/fesm2022/forty-cdk-toast.mjs +1153 -0
- package/fesm2022/forty-cdk-toast.mjs.map +1 -0
- package/fesm2022/forty-cdk-toggle.mjs +516 -0
- package/fesm2022/forty-cdk-toggle.mjs.map +1 -0
- package/fesm2022/forty-cdk-toolbar.mjs +374 -0
- package/fesm2022/forty-cdk-toolbar.mjs.map +1 -0
- package/fesm2022/forty-cdk-tooltip.mjs +672 -0
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -0
- package/fesm2022/forty-cdk-tree.mjs +2007 -0
- package/fesm2022/forty-cdk-tree.mjs.map +1 -0
- package/fesm2022/forty-cdk-virtualization.mjs +1 -1
- package/fesm2022/forty-cdk-virtualization.mjs.map +1 -1
- package/fesm2022/forty-cdk.mjs +0 -43310
- package/fesm2022/forty-cdk.mjs.map +1 -1
- package/field/README.md +97 -0
- package/fieldset/README.md +86 -0
- package/file-upload/README.md +73 -0
- package/hover-card/README.md +171 -0
- package/input/README.md +156 -0
- package/listbox/README.md +424 -0
- package/menu/README.md +181 -0
- package/menubar/README.md +140 -0
- package/meter/README.md +128 -0
- package/navigation-menu/README.md +253 -0
- package/number-input/README.md +171 -0
- package/otp-input/README.md +198 -0
- package/package.json +213 -1
- package/pagination/README.md +61 -0
- package/pane-resizer/README.md +136 -0
- package/popover/README.md +262 -0
- package/progress/README.md +115 -0
- package/radio-group/README.md +129 -0
- package/scroll-area/README.md +184 -0
- package/search/README.md +42 -0
- package/select/README.md +488 -0
- package/separator/README.md +84 -0
- package/signal-forms/README.md +72 -0
- package/slider/README.md +152 -0
- package/stepper/README.md +292 -0
- package/switch/README.md +116 -0
- package/table/README.md +769 -0
- package/tabs/README.md +130 -0
- package/time-field/README.md +157 -0
- package/time-picker/README.md +172 -0
- package/toast/README.md +398 -0
- package/toggle/README.md +224 -0
- package/toolbar/README.md +109 -0
- package/tooltip/README.md +274 -0
- package/tree/README.md +708 -0
- package/types/forty-cdk-accordion.d.ts +242 -0
- package/types/forty-cdk-aspect-ratio.d.ts +59 -0
- package/types/forty-cdk-avatar.d.ts +133 -0
- package/types/forty-cdk-breadcrumbs.d.ts +92 -0
- package/types/forty-cdk-breakpoints.d.ts +141 -0
- package/types/forty-cdk-button.d.ts +80 -0
- package/types/forty-cdk-calendar.d.ts +914 -0
- package/types/forty-cdk-carousel.d.ts +530 -0
- package/types/forty-cdk-checkbox.d.ts +141 -0
- package/types/forty-cdk-combobox.d.ts +1259 -0
- package/types/forty-cdk-context-menu.d.ts +313 -0
- package/types/forty-cdk-core.d.ts +5774 -0
- package/types/forty-cdk-date-field.d.ts +307 -0
- package/types/forty-cdk-date-picker.d.ts +622 -0
- package/types/forty-cdk-dialog.d.ts +546 -0
- package/types/forty-cdk-disclosure.d.ts +127 -0
- package/types/forty-cdk-drag-drop.d.ts +456 -0
- package/types/forty-cdk-drawer.d.ts +871 -0
- package/types/forty-cdk-dropdown-menu.d.ts +242 -0
- package/types/forty-cdk-field.d.ts +236 -0
- package/types/forty-cdk-fieldset.d.ts +119 -0
- package/types/forty-cdk-file-upload.d.ts +124 -0
- package/types/forty-cdk-hover-card.d.ts +320 -0
- package/types/forty-cdk-input.d.ts +169 -0
- package/types/forty-cdk-internationalized-date.d.ts +1 -1
- package/types/forty-cdk-listbox.d.ts +513 -0
- package/types/forty-cdk-menu.d.ts +629 -0
- package/types/forty-cdk-menubar.d.ts +451 -0
- package/types/forty-cdk-meter.d.ts +122 -0
- package/types/forty-cdk-navigation-menu.d.ts +514 -0
- package/types/forty-cdk-number-input.d.ts +319 -0
- package/types/forty-cdk-otp-input.d.ts +248 -0
- package/types/forty-cdk-pagination.d.ts +214 -0
- package/types/forty-cdk-pane-resizer.d.ts +145 -0
- package/types/forty-cdk-popover.d.ts +509 -0
- package/types/forty-cdk-progress.d.ts +143 -0
- package/types/forty-cdk-radio-group.d.ts +222 -0
- package/types/forty-cdk-scroll-area.d.ts +258 -0
- package/types/forty-cdk-search.d.ts +142 -0
- package/types/forty-cdk-select.d.ts +899 -0
- package/types/forty-cdk-separator.d.ts +59 -0
- package/types/forty-cdk-signal-forms.d.ts +58 -0
- package/types/forty-cdk-slider.d.ts +379 -0
- package/types/forty-cdk-stepper.d.ts +650 -0
- package/types/forty-cdk-switch.d.ts +87 -0
- package/types/forty-cdk-table.d.ts +723 -0
- package/types/forty-cdk-tabs.d.ts +235 -0
- package/types/forty-cdk-time-field.d.ts +307 -0
- package/types/forty-cdk-time-picker.d.ts +578 -0
- package/types/forty-cdk-toast.d.ts +598 -0
- package/types/forty-cdk-toggle.d.ts +310 -0
- package/types/forty-cdk-toolbar.d.ts +217 -0
- package/types/forty-cdk-tooltip.d.ts +436 -0
- package/types/forty-cdk-tree.d.ts +688 -0
- package/types/forty-cdk.d.ts +1 -19743
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, computed, model, input, booleanAttribute, numberAttribute, output, signal, linkedSignal, effect, Directive, ElementRef } from '@angular/core';
|
|
3
|
+
import { createDefaults, VirtualizedNavigator, readEntryGuarded, FormUiControlBase, IdGenerator, injectTypeahead, defaultItemToFormValue, singleSelected, injectTextDirection, ListboxOverlayController, isRequiredInputUnset, injectHiddenInput, isInArray, toggleInArray, findTypeaheadMatch, resolveListNavigation, registerHandle, reflectDisabled, injectModalShell, injectOverlayShell, hostId, registerA11yName } from 'forty-cdk/core';
|
|
4
|
+
|
|
5
|
+
const FOR_SELECT_CONTEXT = new InjectionToken('FOR_SELECT_CONTEXT');
|
|
6
|
+
function injectSelectContext(piece) {
|
|
7
|
+
const ctx = inject(FOR_SELECT_CONTEXT, { optional: true });
|
|
8
|
+
if (!ctx) {
|
|
9
|
+
throw new Error(`[forty-cdk/select] ${piece} must be used inside a [forSelect] element. ` +
|
|
10
|
+
"If it is declared inside an ng-template, DI resolves at the template's declaration site — " +
|
|
11
|
+
'not where it is stamped (e.g. via ngTemplateOutlet) — so declare the template inside the ' +
|
|
12
|
+
'[forSelect] root.');
|
|
13
|
+
}
|
|
14
|
+
return ctx;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the trigger's root context: the explicit reference when the
|
|
18
|
+
* `[forSelectTrigger]` input carries one, the injected `FOR_SELECT_CONTEXT`
|
|
19
|
+
* otherwise. The orphan error only fires when neither resolves, on first read
|
|
20
|
+
* of the returned signal. Must be called in an injection context.
|
|
21
|
+
*/
|
|
22
|
+
function injectSelectTriggerContext(explicitRoot) {
|
|
23
|
+
const injected = inject(FOR_SELECT_CONTEXT, { optional: true });
|
|
24
|
+
return computed(() => {
|
|
25
|
+
const explicit = explicitRoot();
|
|
26
|
+
if (explicit !== '') {
|
|
27
|
+
return explicit;
|
|
28
|
+
}
|
|
29
|
+
if (injected) {
|
|
30
|
+
return injected;
|
|
31
|
+
}
|
|
32
|
+
throw new Error('[forty-cdk/select] ForSelectTrigger could not resolve its [forSelect] root: ' +
|
|
33
|
+
'no FOR_SELECT_CONTEXT provider is visible and no explicit root reference was passed. ' +
|
|
34
|
+
"If this trigger is declared inside an ng-template, DI resolves at the template's declaration " +
|
|
35
|
+
'site — not where it is stamped — so either declare the template inside the root or pass the ' +
|
|
36
|
+
'root explicitly: [forSelectTrigger]="root" with #root="forSelect".');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Library fallback for select defaults, read at the root injector when no
|
|
42
|
+
* consumer has called `provideForSelectDefaults`. Exported for the shared defaults
|
|
43
|
+
* contract spec; not re-exported from the primitive's public entry.
|
|
44
|
+
*/
|
|
45
|
+
const FOR_SELECT_FALLBACK_DEFAULTS = {
|
|
46
|
+
sideOffset: 4,
|
|
47
|
+
collisionPadding: 8,
|
|
48
|
+
};
|
|
49
|
+
const { token, provideDefaults } = createDefaults('FOR_SELECT_DEFAULTS', FOR_SELECT_FALLBACK_DEFAULTS);
|
|
50
|
+
/** Token holding the resolved select defaults for the current scope. */
|
|
51
|
+
const FOR_SELECT_DEFAULTS = token;
|
|
52
|
+
/**
|
|
53
|
+
* Configures forty-cdk select defaults for this injector scope. Partial
|
|
54
|
+
* overrides inherit unspecified keys from the parent scope (or library
|
|
55
|
+
* defaults at the root).
|
|
56
|
+
*/
|
|
57
|
+
function provideForSelectDefaults(defaults = {}) {
|
|
58
|
+
return provideDefaults(defaults);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Virtualization navigation engine for `ForSelect`. A thin adapter over the
|
|
63
|
+
* shared `_internal/virtualized-navigator` engine: it maps the select option
|
|
64
|
+
* handle onto the engine's accessors (the option carries its raw `value` so the
|
|
65
|
+
* root can resolve the committed option's index on open) and reads the option's
|
|
66
|
+
* `value` through the single NG0950 read guard.
|
|
67
|
+
*
|
|
68
|
+
* Internal — not re-exported from `select/index.ts` or `public-api.ts`.
|
|
69
|
+
*/
|
|
70
|
+
class SelectVirtualizedNavigator {
|
|
71
|
+
#core;
|
|
72
|
+
constructor(deps) {
|
|
73
|
+
this.#core = new VirtualizedNavigator({ ...deps, loop: deps.loop }, {
|
|
74
|
+
posOf: (o) => o.posInSet(),
|
|
75
|
+
idOf: (o) => o.id(),
|
|
76
|
+
hostOf: (o) => o.host,
|
|
77
|
+
readEntry: (o) => readEntryGuarded(() => ({ id: o.id(), value: o.value(), disabled: o.disabled() })),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** @see VirtualizedNavigator.prime */
|
|
81
|
+
prime() {
|
|
82
|
+
this.#core.prime();
|
|
83
|
+
}
|
|
84
|
+
/** @see VirtualizedNavigator.tryResolvePending */
|
|
85
|
+
tryResolvePending() {
|
|
86
|
+
return this.#core.tryResolvePending();
|
|
87
|
+
}
|
|
88
|
+
/** @see VirtualizedNavigator.navigate */
|
|
89
|
+
navigate(direction) {
|
|
90
|
+
this.#core.navigate(direction);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read-only position snapshot for the root's committed-index resolution.
|
|
94
|
+
* Keyed by absolute `posInSet`; persists across close → reopen while
|
|
95
|
+
* `totalCount` is unchanged.
|
|
96
|
+
*/
|
|
97
|
+
snapshotByPos() {
|
|
98
|
+
return this.#core.snapshotByPos();
|
|
99
|
+
}
|
|
100
|
+
/** @see VirtualizedNavigator.seedActive */
|
|
101
|
+
seedActive(index) {
|
|
102
|
+
this.#core.seedActive(index);
|
|
103
|
+
}
|
|
104
|
+
/** @see VirtualizedNavigator.resetPending */
|
|
105
|
+
resetPending() {
|
|
106
|
+
this.#core.resetPending();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Sentinel for an option handle whose `input.required` `[value]` is not yet written. */
|
|
111
|
+
const NO_VALUE = Symbol('forty-cdk/select:no-value');
|
|
112
|
+
/**
|
|
113
|
+
* Headless implementation of the [WAI-ARIA select-only combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/).
|
|
114
|
+
* Implements `FormValueControl<readonly T[]>` from
|
|
115
|
+
* `@angular/forms/signals` for `[formField]` auto-wiring.
|
|
116
|
+
*
|
|
117
|
+
* Generic over the option value type `T` (default `string`). When the
|
|
118
|
+
* consumer binds object items the directive infers `T` from `[(value)]` and
|
|
119
|
+
* `[forSelectOption][value]`; object identity is resolved by the
|
|
120
|
+
* consumer-supplied `[isItemEqualToValue]` and the hidden inputs serialize
|
|
121
|
+
* via `[itemToFormValue]`. Option display text is read from the rendered
|
|
122
|
+
* `textContent`, so no separate label function is needed — supply the
|
|
123
|
+
* optional `[itemToLabel]` only when a pre-set object value must render its
|
|
124
|
+
* label before the listbox is ever opened (the documented `@if (open())`
|
|
125
|
+
* pattern).
|
|
126
|
+
*
|
|
127
|
+
* Selection is always modeled as `readonly T[]`:
|
|
128
|
+
* - In single mode (`multiple=false`, default), the array has 0 or 1 element
|
|
129
|
+
* and option activation closes the listbox.
|
|
130
|
+
* - In multi mode, option activation toggles and the listbox stays open.
|
|
131
|
+
*
|
|
132
|
+
* Typeahead has two modes mirroring native `<select>`:
|
|
133
|
+
* - **Closed trigger** (single mode only): printable keys select the matching
|
|
134
|
+
* option immediately without opening the listbox.
|
|
135
|
+
* - **Open listbox**: printable keys move focus to the first matching option
|
|
136
|
+
* (selection still requires Enter / Space / click).
|
|
137
|
+
*/
|
|
138
|
+
class ForSelect extends FormUiControlBase {
|
|
139
|
+
#idGen = inject(IdGenerator);
|
|
140
|
+
#typeahead = injectTypeahead();
|
|
141
|
+
#closedTypeahead = injectTypeahead();
|
|
142
|
+
#defaults = inject(FOR_SELECT_DEFAULTS);
|
|
143
|
+
/**
|
|
144
|
+
* Two-way bindable. Selected option values. Single-mode keeps 0 or 1
|
|
145
|
+
* element. The `model()` change emitter (`(valueChange)`) fires only on
|
|
146
|
+
* internal selection changes, never on consumer writes via `[(value)]`.
|
|
147
|
+
*/
|
|
148
|
+
value = model([], /* @ts-ignore */
|
|
149
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
150
|
+
/**
|
|
151
|
+
* Compare two items for equality. Defaults to `===`, which is the
|
|
152
|
+
* correct identity for primitive `T` (e.g. strings, numbers). Override
|
|
153
|
+
* when binding object items so the directive can locate selected entries
|
|
154
|
+
* by id (or any other stable key) instead of by reference:
|
|
155
|
+
* `[isItemEqualToValue]="(a, b) => a.id === b.id"`.
|
|
156
|
+
*/
|
|
157
|
+
isItemEqualToValue = input((a, b) => a === b, /* @ts-ignore */
|
|
158
|
+
...(ngDevMode ? [{ debugName: "isItemEqualToValue" }] : /* istanbul ignore next */ []));
|
|
159
|
+
/**
|
|
160
|
+
* Serialize an item for the hidden input that participates in native
|
|
161
|
+
* form submission. Defaults to identity for strings and to
|
|
162
|
+
* `JSON.stringify` for non-string items so the primitive works out of
|
|
163
|
+
* the box round-tripping objects. Override to emit a specific wire
|
|
164
|
+
* format — typically a per-item id — when the backend expects that:
|
|
165
|
+
* `[itemToFormValue]="(it) => it.id"`.
|
|
166
|
+
*/
|
|
167
|
+
itemToFormValue = input(defaultItemToFormValue, /* @ts-ignore */
|
|
168
|
+
...(ngDevMode ? [{ debugName: "itemToFormValue" }] : /* istanbul ignore next */ []));
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the display label for an item without the listbox mounted.
|
|
171
|
+
* When set, {@link selectedLabels} (and therefore `[forSelectValue]`)
|
|
172
|
+
* renders this for any selected value, so a pre-set object value shows
|
|
173
|
+
* its label on first paint — before the listbox has ever been opened, in
|
|
174
|
+
* the documented `@if (forSelect.open())` pattern. Without it, object-value
|
|
175
|
+
* labels resolve from the rendered option `textContent`, which is only
|
|
176
|
+
* available once the content mounts; the serialized form value is shown as
|
|
177
|
+
* a last-resort fallback in the meantime: `[itemToLabel]="(c) => c.name"`.
|
|
178
|
+
*
|
|
179
|
+
* Defaults to `undefined` (string mode renders the value verbatim, so no
|
|
180
|
+
* label function is needed).
|
|
181
|
+
*/
|
|
182
|
+
itemToLabel = input(undefined, /* @ts-ignore */
|
|
183
|
+
...(ngDevMode ? [{ debugName: "itemToLabel" }] : /* istanbul ignore next */ []));
|
|
184
|
+
/**
|
|
185
|
+
* Read-only single-select convenience view of {@link value}. Returns the
|
|
186
|
+
* sole value when exactly one is selected (regardless of `multiple`),
|
|
187
|
+
* otherwise `null` (zero, or 2+ selected). Lets single-select consumers
|
|
188
|
+
* read `selected()` instead of unwrapping `value()[0]`. The array-backed
|
|
189
|
+
* `value` model remains the source of truth and the `FormValueControl`
|
|
190
|
+
* contract; this is a derived accessor.
|
|
191
|
+
*/
|
|
192
|
+
selected = singleSelected(this.value);
|
|
193
|
+
/**
|
|
194
|
+
* Two-way bindable. Whether the listbox is currently shown. The `model()`
|
|
195
|
+
* change emitter (`(openChange)`) fires only on internal transitions
|
|
196
|
+
* (trigger toggle, Escape, outside dismissal, single-mode option select),
|
|
197
|
+
* never on consumer writes via `[(open)]`.
|
|
198
|
+
*/
|
|
199
|
+
open = model(false, /* @ts-ignore */
|
|
200
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
201
|
+
multiple = input(false, { ...(ngDevMode ? { debugName: "multiple" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
202
|
+
/**
|
|
203
|
+
* Presentation mode. When `true`, `[forSelectContent]` mounts as a trapped /
|
|
204
|
+
* inert / scroll-locked modal surface (routed through `_internal/modal-shell`)
|
|
205
|
+
* instead of the default anchored popover — the batteries-included touch
|
|
206
|
+
* presentation a consumer opts into with `[modal]="isCoarsePointer()"`. The
|
|
207
|
+
* form-value wiring (`[(value)]`, `name`) is unchanged.
|
|
208
|
+
*
|
|
209
|
+
* Read once when the content mounts (the two shells are structurally
|
|
210
|
+
* different; switching at runtime would need a remount, and the surface
|
|
211
|
+
* mounts lazily via `@if (open())` well after `modal` settles). Every
|
|
212
|
+
* anchored-positioning input — `position`, `side`, `align`, `sideOffset`,
|
|
213
|
+
* `alignOffset`, `sticky`, `hideWhenDetached`, `clipUntilPositioned`, `avoidCollisions`,
|
|
214
|
+
* `collisionPadding`, `arrowPadding` — is a no-op in this mode. Default
|
|
215
|
+
* `false` (non-breaking). The swipe / snap-point sheet is deliberately not
|
|
216
|
+
* this mode — compose a `ForListbox` inside a `ForDrawer` for that.
|
|
217
|
+
*/
|
|
218
|
+
modal = input(false, { ...(ngDevMode ? { debugName: "modal" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
219
|
+
/**
|
|
220
|
+
* Positioning algorithm.
|
|
221
|
+
*
|
|
222
|
+
* - `'popper'` (default): standard floating-ui anchored placement using
|
|
223
|
+
* `side` / `align` / `sideOffset` / `alignOffset`, with `flip` + `shift`
|
|
224
|
+
* collision handling. Same path as Popover / DropdownMenu.
|
|
225
|
+
* - `'item-aligned'`: the listbox overlays the trigger so the selected
|
|
226
|
+
* option's vertical center aligns with the trigger's vertical center
|
|
227
|
+
* — visually the menu "snaps over" the trigger when opened, mirroring
|
|
228
|
+
* macOS native `<select>`. Falls back to the first enabled option when
|
|
229
|
+
* nothing is selected. `side`, `align`, `sideOffset`, `alignOffset`,
|
|
230
|
+
* `sticky`, `hideWhenDetached`, `clipUntilPositioned`, and `avoidCollisions` are ignored in
|
|
231
|
+
* this mode; only `collisionPadding` is honored.
|
|
232
|
+
*/
|
|
233
|
+
position = input('popper', /* @ts-ignore */
|
|
234
|
+
...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
|
|
235
|
+
/**
|
|
236
|
+
* Side the listbox is anchored to. Defaults to `'bottom'`. Pair with
|
|
237
|
+
* `align` for the full positioning API. Ignored when
|
|
238
|
+
* `position="item-aligned"`.
|
|
239
|
+
*/
|
|
240
|
+
side = input('bottom', /* @ts-ignore */
|
|
241
|
+
...(ngDevMode ? [{ debugName: "side" }] : /* istanbul ignore next */ []));
|
|
242
|
+
/** Alignment along the chosen `side`. Defaults to `'start'`. */
|
|
243
|
+
align = input('start', /* @ts-ignore */
|
|
244
|
+
...(ngDevMode ? [{ debugName: "align" }] : /* istanbul ignore next */ []));
|
|
245
|
+
/**
|
|
246
|
+
* Gap (px) between trigger and listbox along the main axis. Default `4`.
|
|
247
|
+
* The default is read from
|
|
248
|
+
* `provideForSelectDefaults` for the surrounding scope.
|
|
249
|
+
*/
|
|
250
|
+
sideOffset = input(this.#defaults.sideOffset, { ...(ngDevMode ? { debugName: "sideOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
251
|
+
/** Gap (px) along the cross axis. Default `0`. */
|
|
252
|
+
alignOffset = input(0, { ...(ngDevMode ? { debugName: "alignOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
253
|
+
/** When `true` (default), `flip` and `shift` keep the listbox inside the viewport. */
|
|
254
|
+
avoidCollisions = input(true, { ...(ngDevMode ? { debugName: "avoidCollisions" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
255
|
+
/**
|
|
256
|
+
* Padding (px) applied uniformly to flip / shift / size. Default `8`.
|
|
257
|
+
* The default is read from `provideForSelectDefaults` for the surrounding
|
|
258
|
+
* scope.
|
|
259
|
+
*/
|
|
260
|
+
collisionPadding = input(this.#defaults.collisionPadding, { ...(ngDevMode ? { debugName: "collisionPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
261
|
+
/** Padding (px) for the `arrow` middleware. Default `0`. */
|
|
262
|
+
arrowPadding = input(0, { ...(ngDevMode ? { debugName: "arrowPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
263
|
+
/** Stickiness behaviour for `shift`. Default `'partial'`. */
|
|
264
|
+
sticky = input('partial', /* @ts-ignore */
|
|
265
|
+
...(ngDevMode ? [{ debugName: "sticky" }] : /* istanbul ignore next */ []));
|
|
266
|
+
/** When `true`, sets `data-detached=""` while the trigger is scrolled off-screen. */
|
|
267
|
+
hideWhenDetached = input(false, { ...(ngDevMode ? { debugName: "hideWhenDetached" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
268
|
+
/**
|
|
269
|
+
* When `true` (default), the content is clipped until floating-ui resolves
|
|
270
|
+
* its first position, preventing a flash at the viewport corner. Set to
|
|
271
|
+
* `false` so a dramatic `animate.enter` plays from its first frame (the
|
|
272
|
+
* surface may flash briefly at the unresolved position while positioning
|
|
273
|
+
* computes).
|
|
274
|
+
*/
|
|
275
|
+
clipUntilPositioned = input(true, { ...(ngDevMode ? { debugName: "clipUntilPositioned" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
276
|
+
loop = input(true, { ...(ngDevMode ? { debugName: "loop" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
277
|
+
orientation = input('vertical', /* @ts-ignore */
|
|
278
|
+
...(ngDevMode ? [{ debugName: "orientation" }] : /* istanbul ignore next */ []));
|
|
279
|
+
/**
|
|
280
|
+
* Total number of items in the source data. When set, switches the listbox
|
|
281
|
+
* to the virtualized `aria-activedescendant` focus model and populates
|
|
282
|
+
* `aria-setsize` on each rendered option. Leave unset (default) for the
|
|
283
|
+
* standard DOM-focus model.
|
|
284
|
+
*/
|
|
285
|
+
totalCount = input(undefined, { ...(ngDevMode ? { debugName: "totalCount" } : /* istanbul ignore next */ {}), transform: (v) => (v == null ? undefined : numberAttribute(v)) });
|
|
286
|
+
/**
|
|
287
|
+
* Inclusive-exclusive `[start, end)` index range of the currently rendered
|
|
288
|
+
* options, provided by `injectVirtualizer`. Used to decide whether a
|
|
289
|
+
* navigation target is in the visible window.
|
|
290
|
+
*/
|
|
291
|
+
visibleRange = input(undefined, /* @ts-ignore */
|
|
292
|
+
...(ngDevMode ? [{ debugName: "visibleRange" }] : /* istanbul ignore next */ []));
|
|
293
|
+
/**
|
|
294
|
+
* Emitted when navigation (or open-time scroll-to-selected) reaches an
|
|
295
|
+
* option outside the rendered window. Pass to `injectVirtualizer`'s
|
|
296
|
+
* `scrollToIndex` so the correct option mounts.
|
|
297
|
+
*/
|
|
298
|
+
scrollToIndex = output();
|
|
299
|
+
/**
|
|
300
|
+
* Writing direction. When unset (default `null`), the inherited ambient
|
|
301
|
+
* direction is resolved from the nearest ancestor carrying a `dir` attribute
|
|
302
|
+
* (or `<html dir>`), defaulting to `'ltr'`. An explicit `[dir]` always wins.
|
|
303
|
+
* The resolved value is reflected to the host `dir` attribute.
|
|
304
|
+
*/
|
|
305
|
+
_dirInput = input(null, { ...(ngDevMode ? { debugName: "_dirInput" } : /* istanbul ignore next */ {}), alias: 'dir' });
|
|
306
|
+
dir = injectTextDirection(this._dirInput);
|
|
307
|
+
/**
|
|
308
|
+
* Single-mode only. When true, arrow nav also selects the focused option
|
|
309
|
+
* while the listbox is open. APG calls this optional and recommends
|
|
310
|
+
* caution — leave off unless your UX truly benefits. Default `false`.
|
|
311
|
+
*/
|
|
312
|
+
selectionFollowsFocus = input(false, { ...(ngDevMode ? { debugName: "selectionFollowsFocus" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
313
|
+
/** Placeholder shown by `[forSelectValue]` when no option is selected. */
|
|
314
|
+
placeholder = input('', /* @ts-ignore */
|
|
315
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
316
|
+
/** When true (default), Escape, pointer-down outside, and focus outside close the listbox. */
|
|
317
|
+
dismissible = input(true, { ...(ngDevMode ? { debugName: "dismissible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
318
|
+
/** When true (default), focus returns to the trigger on close. */
|
|
319
|
+
returnFocus = input(true, { ...(ngDevMode ? { debugName: "returnFocus" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
320
|
+
/** Manual `aria-label` on `[forSelectContent]` when the trigger isn't a meaningful name. */
|
|
321
|
+
ariaLabel = input(null, /* @ts-ignore */
|
|
322
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
323
|
+
escapeKeyDown = output();
|
|
324
|
+
pointerDownOutside = output();
|
|
325
|
+
focusOutside = output();
|
|
326
|
+
interactOutside = output();
|
|
327
|
+
/**
|
|
328
|
+
* Fires just before the listbox sends focus to the selected option
|
|
329
|
+
* (or first / last enabled) on mount. Call `preventDefault()` on the
|
|
330
|
+
* emitted veto to skip the imperative focus move.
|
|
331
|
+
*/
|
|
332
|
+
autoFocusOnOpen = output();
|
|
333
|
+
/**
|
|
334
|
+
* Fires just before focus returns to the trigger on unmount. Call
|
|
335
|
+
* `preventDefault()` on the veto to suppress the return-focus.
|
|
336
|
+
*/
|
|
337
|
+
autoFocusOnClose = output();
|
|
338
|
+
#virtualized = computed(() => this.totalCount() !== undefined, /* @ts-ignore */
|
|
339
|
+
...(ngDevMode ? [{ debugName: "#virtualized" }] : /* istanbul ignore next */ []));
|
|
340
|
+
#activeId = signal(null, /* @ts-ignore */
|
|
341
|
+
...(ngDevMode ? [{ debugName: "#activeId" }] : /* istanbul ignore next */ []));
|
|
342
|
+
/**
|
|
343
|
+
* Shared overlay-listbox state machine: option collection, trigger / anchor /
|
|
344
|
+
* content registries + ids, DOM-focus navigation, the open / close machine,
|
|
345
|
+
* the initial-focus / close-reason state, and the dismiss / auto-focus emit
|
|
346
|
+
* forwarders. The value-specific behaviour (`isSelected`, `activate`,
|
|
347
|
+
* `focusSelectedOption`, typeahead, the virtualized activedescendant path,
|
|
348
|
+
* `commitOnTab`'s value set) stays in this root; close-time virtualized
|
|
349
|
+
* cleanup and the post-navigate scroll / `selectionFollowsFocus` move are
|
|
350
|
+
* threaded through the controller's side-effect callbacks.
|
|
351
|
+
*/
|
|
352
|
+
#controller = new ListboxOverlayController(this.#idGen, {
|
|
353
|
+
idPrefix: 'for-select',
|
|
354
|
+
multipleAnchorsError: '[forty-cdk/select] Multiple [forSelectAnchor] inside the same [forSelect]; only one is allowed.',
|
|
355
|
+
defaultInitialFocus: 'selected',
|
|
356
|
+
effectiveDisabled: this.effectiveDisabled,
|
|
357
|
+
setOpen: (open) => this.open.set(open),
|
|
358
|
+
isOpen: () => this.open(),
|
|
359
|
+
emit: {
|
|
360
|
+
escapeKeyDown: this.escapeKeyDown,
|
|
361
|
+
pointerDownOutside: this.pointerDownOutside,
|
|
362
|
+
focusOutside: this.focusOutside,
|
|
363
|
+
interactOutside: this.interactOutside,
|
|
364
|
+
autoFocusOnOpen: this.autoFocusOnOpen,
|
|
365
|
+
autoFocusOnClose: this.autoFocusOnClose,
|
|
366
|
+
},
|
|
367
|
+
markTouched: () => this.markTouched(),
|
|
368
|
+
onClose: () => {
|
|
369
|
+
if (this.#virtualized()) {
|
|
370
|
+
this.#activeId.set(null);
|
|
371
|
+
this.#navigator?.resetPending();
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
onNavigateFocus: (target) => {
|
|
375
|
+
target.host.scrollIntoView?.({ block: 'nearest' });
|
|
376
|
+
if (!this.multiple() && this.selectionFollowsFocus() && !this.readonly()) {
|
|
377
|
+
this.#setSingle(target.value());
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
onUnregisterOption: (handle) => {
|
|
381
|
+
if (this.#virtualized() && this.#activeId() === handle.id()) {
|
|
382
|
+
this.#activeId.set(null);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
triggerId = this.#controller.triggerId;
|
|
387
|
+
contentId = this.#controller.contentId;
|
|
388
|
+
initialFocus = this.#controller.initialFocus;
|
|
389
|
+
lastCloseReason = this.#controller.lastCloseReason;
|
|
390
|
+
trigger = this.#controller.trigger;
|
|
391
|
+
/**
|
|
392
|
+
* Element floating-ui anchors the listbox against. Prefers an optional
|
|
393
|
+
* `[forSelectAnchor]` when registered, otherwise falls back to the trigger
|
|
394
|
+
* so existing selects without an anchor keep their behavior. Decoupled from
|
|
395
|
+
* `trigger` so the trigger keeps driving `aria-controls`, the click toggle,
|
|
396
|
+
* focus return, and its dismissal exemption regardless of where the listbox
|
|
397
|
+
* paints.
|
|
398
|
+
*/
|
|
399
|
+
anchor = this.#controller.anchor;
|
|
400
|
+
content = this.#controller.content;
|
|
401
|
+
options = this.#controller.options;
|
|
402
|
+
/** The active option's `id` in the virtualized path, `null` in the default path. */
|
|
403
|
+
activeDescendantId = computed(() => this.#virtualized() ? this.#activeId() : null, /* @ts-ignore */
|
|
404
|
+
...(ngDevMode ? [{ debugName: "activeDescendantId" }] : /* istanbul ignore next */ []));
|
|
405
|
+
#navigator = null;
|
|
406
|
+
#requireNavigator() {
|
|
407
|
+
return (this.#navigator ??= new SelectVirtualizedNavigator({
|
|
408
|
+
items: this.options,
|
|
409
|
+
totalCount: this.totalCount,
|
|
410
|
+
visibleRange: this.visibleRange,
|
|
411
|
+
loop: this.loop,
|
|
412
|
+
getActiveId: () => this.#activeId(),
|
|
413
|
+
setActiveId: (id) => this.#activeId.set(id),
|
|
414
|
+
emitScrollToIndex: (idx) => this.scrollToIndex.emit(idx),
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Persisted snapshot of the registered options as `{ value, label }` tuples,
|
|
419
|
+
* keyed internally by serialized form value. Drives closed-state typeahead
|
|
420
|
+
* and `[forSelectValue]` label rendering — the live `options` registry is
|
|
421
|
+
* empty whenever `[forSelectContent]` is unmounted, so the snapshot carries
|
|
422
|
+
* the last non-empty option set across close → re-open cycles.
|
|
423
|
+
*
|
|
424
|
+
* A `linkedSignal` that folds the live option set on every `options()`
|
|
425
|
+
* change: when the listbox unmounts (`items().length === 0`) it returns the
|
|
426
|
+
* previous accumulator unchanged so labels stay resolvable while closed. Each
|
|
427
|
+
* option's `label` is itself a `Signal<string>`, so this never reads
|
|
428
|
+
* `textContent` from inside the fold — the canonical replacement for the
|
|
429
|
+
* previous `afterEveryRender(() => signal.set(...))` snapshot (no
|
|
430
|
+
* state-propagation inside an `effect`). A statically-rendered option that
|
|
431
|
+
* registers before its `[value]` binding is written is skipped this fold and
|
|
432
|
+
* folded in on the re-run the binding triggers (see {@link #readHandleValue}).
|
|
433
|
+
*/
|
|
434
|
+
#cachedOptions = linkedSignal({ ...(ngDevMode ? { debugName: "#cachedOptions" } : /* istanbul ignore next */ {}), source: () => this.options(),
|
|
435
|
+
computation: (items, prev) => {
|
|
436
|
+
if (items.length === 0) {
|
|
437
|
+
return prev?.value ?? [];
|
|
438
|
+
}
|
|
439
|
+
const toFormValue = this.itemToFormValue();
|
|
440
|
+
const merged = new Map();
|
|
441
|
+
for (const entry of prev?.value ?? []) {
|
|
442
|
+
merged.set(toFormValue(entry.value), entry);
|
|
443
|
+
}
|
|
444
|
+
for (const item of items) {
|
|
445
|
+
const value = this.#readHandleValue(item);
|
|
446
|
+
if (value === NO_VALUE) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
merged.set(toFormValue(value), { value, label: item.label() });
|
|
450
|
+
}
|
|
451
|
+
return [...merged.values()];
|
|
452
|
+
} });
|
|
453
|
+
/**
|
|
454
|
+
* Trimmed display labels of the selected values, in selection order. Pure
|
|
455
|
+
* derivation: when `[itemToLabel]` is supplied it is authoritative; otherwise
|
|
456
|
+
* resolve from the persisted option snapshot, then the serialized form value
|
|
457
|
+
* so non-string items still render meaningfully on a cold cache.
|
|
458
|
+
*
|
|
459
|
+
* The snapshot's labels come from each option's reactive `label` signal,
|
|
460
|
+
* which reads the rendered `textContent`. `textContent` is not a signal, so a
|
|
461
|
+
* label whose rendered text changes without a value change does not self-heal
|
|
462
|
+
* here — supply `[itemToLabel]` for a pure signal derivation that observes
|
|
463
|
+
* label changes directly.
|
|
464
|
+
*/
|
|
465
|
+
selectedLabels = computed(() => {
|
|
466
|
+
const values = this.value();
|
|
467
|
+
if (values.length === 0) {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
// When `[itemToLabel]` is supplied it is authoritative: the label resolves
|
|
471
|
+
// without the listbox mounted, so a pre-set object value renders correctly
|
|
472
|
+
// on first paint in the documented `@if (open())` pattern and never flickers
|
|
473
|
+
// from a serialized id to the real label once the listbox is first opened.
|
|
474
|
+
const itemToLabel = this.itemToLabel();
|
|
475
|
+
if (itemToLabel) {
|
|
476
|
+
return values.map(itemToLabel);
|
|
477
|
+
}
|
|
478
|
+
const cached = this.#cachedOptions();
|
|
479
|
+
const toFormValue = this.itemToFormValue();
|
|
480
|
+
const cachedByKey = new Map();
|
|
481
|
+
for (const o of cached) {
|
|
482
|
+
cachedByKey.set(toFormValue(o.value), o);
|
|
483
|
+
}
|
|
484
|
+
const labels = [];
|
|
485
|
+
for (const v of values) {
|
|
486
|
+
const key = toFormValue(v);
|
|
487
|
+
const opt = cachedByKey.get(key);
|
|
488
|
+
labels.push(opt ? opt.label : typeof v === 'string' ? v : key);
|
|
489
|
+
}
|
|
490
|
+
return labels;
|
|
491
|
+
}, /* @ts-ignore */
|
|
492
|
+
...(ngDevMode ? [{ debugName: "selectedLabels" }] : /* istanbul ignore next */ []));
|
|
493
|
+
selectedOptionEl = computed(() => {
|
|
494
|
+
const values = this.value();
|
|
495
|
+
if (values.length === 0) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const items = this.options();
|
|
499
|
+
const toFormValue = this.itemToFormValue();
|
|
500
|
+
const byKey = new Map();
|
|
501
|
+
for (const o of items) {
|
|
502
|
+
byKey.set(toFormValue(o.value()), o);
|
|
503
|
+
}
|
|
504
|
+
for (const v of values) {
|
|
505
|
+
const opt = byKey.get(toFormValue(v));
|
|
506
|
+
if (opt) {
|
|
507
|
+
return opt.host;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}, /* @ts-ignore */
|
|
512
|
+
...(ngDevMode ? [{ debugName: "selectedOptionEl" }] : /* istanbul ignore next */ []));
|
|
513
|
+
fieldLabelledElement() {
|
|
514
|
+
return this.trigger();
|
|
515
|
+
}
|
|
516
|
+
fieldLabelledElementId() {
|
|
517
|
+
return this.triggerId();
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Read an option handle's `value()` inside the snapshot fold, tolerating the
|
|
521
|
+
* NG0950 thrown while a statically-rendered option is between registering
|
|
522
|
+
* (its constructor, during the content view's creation pass) and having its
|
|
523
|
+
* `input.required` `[value]` binding written (that view's update pass). The
|
|
524
|
+
* fold's prime read can run in that gap, so a static option above a `@for`
|
|
525
|
+
* list would otherwise hard-crash on open. Returns {@link NO_VALUE} in that
|
|
526
|
+
* window; the fold skips the option and folds it in on the re-run the binding
|
|
527
|
+
* triggers (the required-input producer is accessed before the read throws,
|
|
528
|
+
* so the dependency is still tracked). Any non-NG0950 error propagates.
|
|
529
|
+
*/
|
|
530
|
+
#readHandleValue(item) {
|
|
531
|
+
try {
|
|
532
|
+
return item.value();
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (isRequiredInputUnset(error)) {
|
|
536
|
+
return NO_VALUE;
|
|
537
|
+
}
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
constructor() {
|
|
542
|
+
super();
|
|
543
|
+
injectHiddenInput({
|
|
544
|
+
name: this.name,
|
|
545
|
+
values: this.value,
|
|
546
|
+
serialize: (item) => this.itemToFormValue()(item),
|
|
547
|
+
disabled: this.effectiveDisabled,
|
|
548
|
+
});
|
|
549
|
+
// Prime the snapshot fold while the listbox is open so its `linkedSignal`
|
|
550
|
+
// `prev` slot is seeded with the live options before they unmount — closed-
|
|
551
|
+
// state typeahead and `[forSelectValue]` then resolve labels even when no
|
|
552
|
+
// `[forSelectValue]` pulls the cache during the open cycle. A read, not a
|
|
553
|
+
// write: this is a legitimate side effect (forcing the lazy fold to run),
|
|
554
|
+
// not state propagation inside an `effect`.
|
|
555
|
+
effect(() => {
|
|
556
|
+
if (this.open()) {
|
|
557
|
+
this.#cachedOptions();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
effect(() => {
|
|
561
|
+
this.options();
|
|
562
|
+
if (!this.#virtualized()) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const navigator = this.#requireNavigator();
|
|
566
|
+
navigator.prime();
|
|
567
|
+
navigator.tryResolvePending();
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
setInitialFocus(target) {
|
|
571
|
+
this.#controller.setInitialFocus(target);
|
|
572
|
+
}
|
|
573
|
+
registerTrigger(el) {
|
|
574
|
+
this.#controller.registerTrigger(el);
|
|
575
|
+
}
|
|
576
|
+
unregisterTrigger(el) {
|
|
577
|
+
this.#controller.unregisterTrigger(el);
|
|
578
|
+
}
|
|
579
|
+
registerAnchor(el) {
|
|
580
|
+
this.#controller.registerAnchor(el);
|
|
581
|
+
}
|
|
582
|
+
unregisterAnchor(el) {
|
|
583
|
+
this.#controller.unregisterAnchor(el);
|
|
584
|
+
}
|
|
585
|
+
registerContent(el) {
|
|
586
|
+
this.#controller.registerContent(el);
|
|
587
|
+
}
|
|
588
|
+
unregisterContent(el) {
|
|
589
|
+
this.#controller.unregisterContent(el);
|
|
590
|
+
}
|
|
591
|
+
registerOption(handle) {
|
|
592
|
+
this.#controller.registerOption(handle);
|
|
593
|
+
}
|
|
594
|
+
unregisterOption(handle) {
|
|
595
|
+
this.#controller.unregisterOption(handle);
|
|
596
|
+
}
|
|
597
|
+
isSelected(v) {
|
|
598
|
+
return isInArray(this.value(), v, this.isItemEqualToValue());
|
|
599
|
+
}
|
|
600
|
+
#setSingle(v) {
|
|
601
|
+
if (this.value().length === 1 && this.isSelected(v)) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.value.set([v]);
|
|
605
|
+
}
|
|
606
|
+
activate(v) {
|
|
607
|
+
if (this.effectiveDisabled() || this.readonly()) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (this.multiple()) {
|
|
611
|
+
this.value.set(toggleInArray(this.value(), v, this.isItemEqualToValue()));
|
|
612
|
+
// Multi-select stays open — consumer closes via outside pointer / Escape / Tab.
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// Single-mode: idempotent select + close. Skip the redundant set (and its
|
|
616
|
+
// `valueChange` emission) when the same sole value is already selected.
|
|
617
|
+
this.#setSingle(v);
|
|
618
|
+
this.closeMenu('select');
|
|
619
|
+
}
|
|
620
|
+
navigate(currentOption, action) {
|
|
621
|
+
this.#controller.navigate(currentOption, action, this.loop());
|
|
622
|
+
}
|
|
623
|
+
handleTypeahead(event) {
|
|
624
|
+
if (!this.#typeahead.handle(event)) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const match = findTypeaheadMatch(this.options(), { buffer: this.#typeahead.buffer(), repeated: false, anchorIndex: -1 }, (o) => o.host.textContent ?? '', (o) => o.disabled());
|
|
628
|
+
match?.host.focus();
|
|
629
|
+
}
|
|
630
|
+
handleClosedTypeahead(event) {
|
|
631
|
+
// Only single-mode replicates native <select>'s "type-to-select" behavior.
|
|
632
|
+
// Multi-select is ambiguous (which one wins?) — caller falls back to opening.
|
|
633
|
+
if (this.multiple() || this.effectiveDisabled() || this.readonly()) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
if (!this.#closedTypeahead.handle(event)) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
const buffer = this.#closedTypeahead.buffer().toLowerCase();
|
|
640
|
+
if (!buffer) {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
// Closed-state lookup goes through the cached snapshot — `[forSelectContent]`
|
|
644
|
+
// is unmounted, so the live `options` registry is empty here. The cache
|
|
645
|
+
// populates the first time the listbox opens and renders options.
|
|
646
|
+
const cached = this.#cachedOptions();
|
|
647
|
+
const match = cached.find((o) => o.label.toLowerCase().startsWith(buffer));
|
|
648
|
+
if (match) {
|
|
649
|
+
this.value.set([match.value]);
|
|
650
|
+
}
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
focusFirstEnabledOption() {
|
|
654
|
+
return this.#controller.focusFirstEnabledOption();
|
|
655
|
+
}
|
|
656
|
+
focusLastEnabledOption() {
|
|
657
|
+
return this.#controller.focusLastEnabledOption();
|
|
658
|
+
}
|
|
659
|
+
focusSelectedOption() {
|
|
660
|
+
const values = this.value();
|
|
661
|
+
if (values.length === 0) {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const equals = this.isItemEqualToValue();
|
|
665
|
+
const items = this.options();
|
|
666
|
+
for (const v of values) {
|
|
667
|
+
const opt = items.find((o) => equals(o.value(), v) && !o.disabled());
|
|
668
|
+
if (opt) {
|
|
669
|
+
opt.host.focus();
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
toggle(initialFocus = 'selected') {
|
|
676
|
+
this.#controller.toggle(initialFocus);
|
|
677
|
+
}
|
|
678
|
+
openMenu(initialFocus = 'selected') {
|
|
679
|
+
this.#controller.openMenu(initialFocus);
|
|
680
|
+
}
|
|
681
|
+
closeMenu(reason) {
|
|
682
|
+
this.#controller.closeMenu(reason);
|
|
683
|
+
}
|
|
684
|
+
commitOnTab(value) {
|
|
685
|
+
if (this.effectiveDisabled()) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (!this.multiple() && !this.readonly()) {
|
|
689
|
+
this.#setSingle(value);
|
|
690
|
+
}
|
|
691
|
+
// Move focus to the trigger BEFORE the unmount + close so the browser's
|
|
692
|
+
// Tab default action has a stable active element to advance from. The
|
|
693
|
+
// content's `DestroyRef` reads `lastCloseReason() === 'tab'` and skips
|
|
694
|
+
// its own re-focus — otherwise it would steal focus back from wherever
|
|
695
|
+
// the browser advanced it.
|
|
696
|
+
this.#controller.focusTrigger();
|
|
697
|
+
this.closeMenu('tab');
|
|
698
|
+
}
|
|
699
|
+
emitEscapeKeyDown(event) {
|
|
700
|
+
this.#controller.emitEscapeKeyDown(event, this.dismissible(), 'escape');
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Outside-interaction emit forwarders. The shared `#pendingOutsideVeto`
|
|
704
|
+
* reuse between the specific outside channels and the composite
|
|
705
|
+
* `interactOutside` lives in `injectOverlayShell`; these only fire the
|
|
706
|
+
* matching output with the veto the shell built.
|
|
707
|
+
*/
|
|
708
|
+
emitPointerDownOutside(veto) {
|
|
709
|
+
this.#controller.emitPointerDownOutside(veto);
|
|
710
|
+
}
|
|
711
|
+
emitFocusOutside(veto) {
|
|
712
|
+
this.#controller.emitFocusOutside(veto);
|
|
713
|
+
}
|
|
714
|
+
emitInteractOutside(veto) {
|
|
715
|
+
this.#controller.emitInteractOutside(veto);
|
|
716
|
+
}
|
|
717
|
+
/** Modal-path Escape forwarder: emit only; the modal shell owns the close. */
|
|
718
|
+
forwardEscapeKeyDown(veto) {
|
|
719
|
+
this.#controller.forwardEscapeKeyDown(veto);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Implicit close requested by the shell after an un-vetoed outside
|
|
723
|
+
* interaction. Marks the control touched (mirroring the trigger blur) and
|
|
724
|
+
* closes with the channel's reason.
|
|
725
|
+
*/
|
|
726
|
+
requestClose(reason) {
|
|
727
|
+
this.#controller.requestClose(reason);
|
|
728
|
+
}
|
|
729
|
+
emitAutoFocusOnOpen() {
|
|
730
|
+
return this.#controller.emitAutoFocusOnOpen();
|
|
731
|
+
}
|
|
732
|
+
emitAutoFocusOnClose() {
|
|
733
|
+
return this.#controller.emitAutoFocusOnClose();
|
|
734
|
+
}
|
|
735
|
+
markTouched() {
|
|
736
|
+
super.markTouched();
|
|
737
|
+
}
|
|
738
|
+
seedVirtualizedInitialFocus() {
|
|
739
|
+
if (!this.#virtualized()) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const navigator = this.#requireNavigator();
|
|
743
|
+
const committed = this.#committedIndex(navigator);
|
|
744
|
+
if (committed !== null) {
|
|
745
|
+
navigator.seedActive(committed);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
navigator.navigate('first');
|
|
749
|
+
}
|
|
750
|
+
handleVirtualizedKeydown(event) {
|
|
751
|
+
if (!this.#virtualized() || this.effectiveDisabled()) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (event.key === 'Tab') {
|
|
755
|
+
if (this.modal()) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
this.#commitActiveDescendantOnTab();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
762
|
+
event.preventDefault();
|
|
763
|
+
this.#activateActiveDescendant();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const action = resolveListNavigation(event, {
|
|
767
|
+
orientation: this.orientation(),
|
|
768
|
+
dir: this.dir(),
|
|
769
|
+
pageKeys: true,
|
|
770
|
+
});
|
|
771
|
+
if (action) {
|
|
772
|
+
event.preventDefault();
|
|
773
|
+
this.#requireNavigator().navigate(action);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this.#typeaheadVirtualized(event);
|
|
777
|
+
}
|
|
778
|
+
notifyOptionClick(optionId) {
|
|
779
|
+
if (!this.#virtualized()) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
this.#activeId.set(optionId);
|
|
783
|
+
this.content()?.focus();
|
|
784
|
+
}
|
|
785
|
+
#committedIndex(navigator) {
|
|
786
|
+
const values = this.value();
|
|
787
|
+
if (values.length === 0) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
const equals = this.isItemEqualToValue();
|
|
791
|
+
const snapshot = navigator.snapshotByPos();
|
|
792
|
+
for (const v of values) {
|
|
793
|
+
for (const [pos, entry] of snapshot) {
|
|
794
|
+
if (!entry.disabled && equals(entry.value, v)) {
|
|
795
|
+
return pos;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
#activateActiveDescendant() {
|
|
802
|
+
const id = this.#activeId();
|
|
803
|
+
if (id === null) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const handle = this.options().find((o) => o.id() === id);
|
|
807
|
+
if (!handle || handle.disabled()) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.activate(handle.value());
|
|
811
|
+
}
|
|
812
|
+
#commitActiveDescendantOnTab() {
|
|
813
|
+
const id = this.#activeId();
|
|
814
|
+
const handle = id === null ? undefined : this.options().find((o) => o.id() === id);
|
|
815
|
+
if (handle && !handle.disabled()) {
|
|
816
|
+
this.commitOnTab(handle.value());
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
this.closeMenu('tab');
|
|
820
|
+
}
|
|
821
|
+
#typeaheadVirtualized(event) {
|
|
822
|
+
if (!this.#typeahead.handle(event)) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const match = findTypeaheadMatch(this.options(), { buffer: this.#typeahead.buffer(), repeated: false, anchorIndex: -1 }, (o) => o.host.textContent ?? '', (o) => o.disabled());
|
|
826
|
+
if (match) {
|
|
827
|
+
this.#activeId.set(match.id());
|
|
828
|
+
match.host.scrollIntoView?.({ block: 'nearest' });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelect, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
832
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForSelect, isStandalone: true, selector: "[forSelect]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, isItemEqualToValue: { classPropertyName: "isItemEqualToValue", publicName: "isItemEqualToValue", isSignal: true, isRequired: false, transformFunction: null }, itemToFormValue: { classPropertyName: "itemToFormValue", publicName: "itemToFormValue", isSignal: true, isRequired: false, transformFunction: null }, itemToLabel: { classPropertyName: "itemToLabel", publicName: "itemToLabel", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, modal: { classPropertyName: "modal", publicName: "modal", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, sideOffset: { classPropertyName: "sideOffset", publicName: "sideOffset", isSignal: true, isRequired: false, transformFunction: null }, alignOffset: { classPropertyName: "alignOffset", publicName: "alignOffset", isSignal: true, isRequired: false, transformFunction: null }, avoidCollisions: { classPropertyName: "avoidCollisions", publicName: "avoidCollisions", isSignal: true, isRequired: false, transformFunction: null }, collisionPadding: { classPropertyName: "collisionPadding", publicName: "collisionPadding", isSignal: true, isRequired: false, transformFunction: null }, arrowPadding: { classPropertyName: "arrowPadding", publicName: "arrowPadding", isSignal: true, isRequired: false, transformFunction: null }, sticky: { classPropertyName: "sticky", publicName: "sticky", isSignal: true, isRequired: false, transformFunction: null }, hideWhenDetached: { classPropertyName: "hideWhenDetached", publicName: "hideWhenDetached", isSignal: true, isRequired: false, transformFunction: null }, clipUntilPositioned: { classPropertyName: "clipUntilPositioned", publicName: "clipUntilPositioned", isSignal: true, isRequired: false, transformFunction: null }, loop: { classPropertyName: "loop", publicName: "loop", isSignal: true, isRequired: false, transformFunction: null }, orientation: { classPropertyName: "orientation", publicName: "orientation", isSignal: true, isRequired: false, transformFunction: null }, totalCount: { classPropertyName: "totalCount", publicName: "totalCount", isSignal: true, isRequired: false, transformFunction: null }, visibleRange: { classPropertyName: "visibleRange", publicName: "visibleRange", isSignal: true, isRequired: false, transformFunction: null }, _dirInput: { classPropertyName: "_dirInput", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, selectionFollowsFocus: { classPropertyName: "selectionFollowsFocus", publicName: "selectionFollowsFocus", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, dismissible: { classPropertyName: "dismissible", publicName: "dismissible", isSignal: true, isRequired: false, transformFunction: null }, returnFocus: { classPropertyName: "returnFocus", publicName: "returnFocus", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", open: "openChange", scrollToIndex: "scrollToIndex", escapeKeyDown: "escapeKeyDown", pointerDownOutside: "pointerDownOutside", focusOutside: "focusOutside", interactOutside: "interactOutside", autoFocusOnOpen: "autoFocusOnOpen", autoFocusOnClose: "autoFocusOnClose" }, host: { properties: { "attr.data-state": "open() ? \"open\" : \"closed\"", "attr.data-disabled": "effectiveDisabled() ? \"\" : null", "attr.dir": "dir()" } }, providers: [{ provide: FOR_SELECT_CONTEXT, useExisting: ForSelect }], exportAs: ["forSelect"], usesInheritance: true, ngImport: i0 });
|
|
833
|
+
}
|
|
834
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelect, decorators: [{
|
|
835
|
+
type: Directive,
|
|
836
|
+
args: [{
|
|
837
|
+
selector: '[forSelect]',
|
|
838
|
+
exportAs: 'forSelect',
|
|
839
|
+
host: {
|
|
840
|
+
'[attr.data-state]': 'open() ? "open" : "closed"',
|
|
841
|
+
'[attr.data-disabled]': 'effectiveDisabled() ? "" : null',
|
|
842
|
+
'[attr.dir]': 'dir()',
|
|
843
|
+
},
|
|
844
|
+
providers: [{ provide: FOR_SELECT_CONTEXT, useExisting: ForSelect }],
|
|
845
|
+
}]
|
|
846
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], isItemEqualToValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "isItemEqualToValue", required: false }] }], itemToFormValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToFormValue", required: false }] }], itemToLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToLabel", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], modal: [{ type: i0.Input, args: [{ isSignal: true, alias: "modal", required: false }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }], side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], sideOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "sideOffset", required: false }] }], alignOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignOffset", required: false }] }], avoidCollisions: [{ type: i0.Input, args: [{ isSignal: true, alias: "avoidCollisions", required: false }] }], collisionPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "collisionPadding", required: false }] }], arrowPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "arrowPadding", required: false }] }], sticky: [{ type: i0.Input, args: [{ isSignal: true, alias: "sticky", required: false }] }], hideWhenDetached: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideWhenDetached", required: false }] }], clipUntilPositioned: [{ type: i0.Input, args: [{ isSignal: true, alias: "clipUntilPositioned", required: false }] }], loop: [{ type: i0.Input, args: [{ isSignal: true, alias: "loop", required: false }] }], orientation: [{ type: i0.Input, args: [{ isSignal: true, alias: "orientation", required: false }] }], totalCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalCount", required: false }] }], visibleRange: [{ type: i0.Input, args: [{ isSignal: true, alias: "visibleRange", required: false }] }], scrollToIndex: [{ type: i0.Output, args: ["scrollToIndex"] }], _dirInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], selectionFollowsFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionFollowsFocus", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], dismissible: [{ type: i0.Input, args: [{ isSignal: true, alias: "dismissible", required: false }] }], returnFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "returnFocus", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], escapeKeyDown: [{ type: i0.Output, args: ["escapeKeyDown"] }], pointerDownOutside: [{ type: i0.Output, args: ["pointerDownOutside"] }], focusOutside: [{ type: i0.Output, args: ["focusOutside"] }], interactOutside: [{ type: i0.Output, args: ["interactOutside"] }], autoFocusOnOpen: [{ type: i0.Output, args: ["autoFocusOnOpen"] }], autoFocusOnClose: [{ type: i0.Output, args: ["autoFocusOnClose"] }] } });
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Optional positioning anchor. When present, `[forSelectContent]` is
|
|
850
|
+
* positioned against this element instead of `[forSelectTrigger]` — useful
|
|
851
|
+
* when the trigger lives inside a decorated field box (padding, prefix icon,
|
|
852
|
+
* clear / chevron buttons) and the listbox should match the visible field
|
|
853
|
+
* rather than the inner button.
|
|
854
|
+
*
|
|
855
|
+
* Only positioning changes: the trigger still owns `aria-controls`,
|
|
856
|
+
* `aria-expanded`, the click toggle, focus return on close, and its exemption
|
|
857
|
+
* from outside-pointer dismissal. If no anchor is registered the listbox falls
|
|
858
|
+
* back to anchoring against the trigger, so existing usages are unaffected.
|
|
859
|
+
*
|
|
860
|
+
* At most one `[forSelectAnchor]` may be registered per `[forSelect]`; a second
|
|
861
|
+
* one throws.
|
|
862
|
+
*
|
|
863
|
+
* ```html
|
|
864
|
+
* <div forSelect [(value)]="v">
|
|
865
|
+
* <div forSelectAnchor class="field-box">
|
|
866
|
+
* <icon />
|
|
867
|
+
* <button forSelectTrigger>…</button>
|
|
868
|
+
* <button class="clear">×</button>
|
|
869
|
+
* </div>
|
|
870
|
+
* @if (open()) {
|
|
871
|
+
* <div forSelectContent>…</div>
|
|
872
|
+
* }
|
|
873
|
+
* </div>
|
|
874
|
+
* ```
|
|
875
|
+
*/
|
|
876
|
+
class ForSelectAnchor {
|
|
877
|
+
#ctx = injectSelectContext('ForSelectAnchor');
|
|
878
|
+
#host = inject(ElementRef);
|
|
879
|
+
constructor() {
|
|
880
|
+
registerHandle(this.#host.nativeElement, (el) => this.#ctx.registerAnchor(el), (el) => this.#ctx.unregisterAnchor(el));
|
|
881
|
+
}
|
|
882
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectAnchor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
883
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectAnchor, isStandalone: true, selector: "[forSelectAnchor]", exportAs: ["forSelectAnchor"], ngImport: i0 });
|
|
884
|
+
}
|
|
885
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectAnchor, decorators: [{
|
|
886
|
+
type: Directive,
|
|
887
|
+
args: [{
|
|
888
|
+
selector: '[forSelectAnchor]',
|
|
889
|
+
exportAs: 'forSelectAnchor',
|
|
890
|
+
}]
|
|
891
|
+
}], ctorParameters: () => [] });
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Combobox button that opens the listbox. Apply on a `<button>` so
|
|
895
|
+
* Space / Enter dispatch native click events that toggle via `(click)`.
|
|
896
|
+
*
|
|
897
|
+
* Wires APG select-only combobox attributes: `role="combobox"`,
|
|
898
|
+
* `aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls` pointing
|
|
899
|
+
* to the listbox. The button is exempt from the listbox's dismissable
|
|
900
|
+
* layer — clicks on it route through `(click)` instead of triggering an
|
|
901
|
+
* outside-pointer dismissal race.
|
|
902
|
+
*
|
|
903
|
+
* The root is normally resolved via DI from the enclosing `[forSelect]`.
|
|
904
|
+
* When the trigger is declared inside an `ng-template` stamped into the root
|
|
905
|
+
* (e.g. via `ngTemplateOutlet`), DI resolves at the template's declaration
|
|
906
|
+
* site and misses the root — pass it explicitly through the selector input,
|
|
907
|
+
* `routerLink`-style: `[forSelectTrigger]="root"` with `#root="forSelect"`.
|
|
908
|
+
*
|
|
909
|
+
* Keyboard:
|
|
910
|
+
* - **Click / Enter / Space** — toggle (open focuses the selected option, or first).
|
|
911
|
+
* - **ArrowDown** — open + focus selected option (or first).
|
|
912
|
+
* - **ArrowUp** — open + focus selected option (or last when none selected).
|
|
913
|
+
* - **Typeahead** (single mode only) — printable keys select the matching
|
|
914
|
+
* option immediately without opening the listbox, mirroring native
|
|
915
|
+
* `<select>`. In multi mode the buffered key is ignored at the trigger;
|
|
916
|
+
* open the listbox first to typeahead-focus.
|
|
917
|
+
*/
|
|
918
|
+
class ForSelectTrigger {
|
|
919
|
+
#host = inject(ElementRef);
|
|
920
|
+
/**
|
|
921
|
+
* Optional explicit reference to the `[forSelect]` root, named after the
|
|
922
|
+
* selector `routerLink`-style. The bare valueless attribute keeps resolving
|
|
923
|
+
* the enclosing root via DI; pass the root explicitly
|
|
924
|
+
* (`[forSelectTrigger]="root"`, with `#root="forSelect"`) when the trigger
|
|
925
|
+
* is declared in an `ng-template` stamped inside the root — DI resolves at
|
|
926
|
+
* the template's declaration site, so the enclosing root is invisible there.
|
|
927
|
+
* The empty string (what the valueless attribute yields) is treated as unset.
|
|
928
|
+
*/
|
|
929
|
+
forSelectTrigger = input('', /* @ts-ignore */
|
|
930
|
+
...(ngDevMode ? [{ debugName: "forSelectTrigger" }] : /* istanbul ignore next */ []));
|
|
931
|
+
ctx = injectSelectTriggerContext(this.forSelectTrigger);
|
|
932
|
+
constructor() {
|
|
933
|
+
const el = this.#host.nativeElement;
|
|
934
|
+
// Registration is an imperative call into the resolved root's registry,
|
|
935
|
+
// not state derivation — the effect only re-registers the element when the
|
|
936
|
+
// resolved root changes (explicit reference swapped at runtime).
|
|
937
|
+
effect((onCleanup) => {
|
|
938
|
+
const ctx = this.ctx();
|
|
939
|
+
ctx.registerTrigger(el);
|
|
940
|
+
onCleanup(() => ctx.unregisterTrigger(el));
|
|
941
|
+
});
|
|
942
|
+
reflectDisabled(computed(() => this.ctx().effectiveDisabled()));
|
|
943
|
+
}
|
|
944
|
+
onClick() {
|
|
945
|
+
this.ctx().toggle('selected');
|
|
946
|
+
}
|
|
947
|
+
onKeyDown(event) {
|
|
948
|
+
if (this.ctx().effectiveDisabled()) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (event.key === 'ArrowDown') {
|
|
952
|
+
event.preventDefault();
|
|
953
|
+
this.ctx().openMenu('selected');
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (event.key === 'ArrowUp') {
|
|
957
|
+
event.preventDefault();
|
|
958
|
+
// ArrowUp lands on the selected option if any, else the last enabled.
|
|
959
|
+
this.ctx().openMenu(this.ctx().value().length > 0 ? 'selected' : 'last');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// Closed-state typeahead — single-mode shortcut to match native <select>.
|
|
963
|
+
// No-op (returns false) in multi mode or when the key isn't a printable
|
|
964
|
+
// single char, so default browser behavior (e.g. nothing) takes over.
|
|
965
|
+
this.ctx().handleClosedTypeahead(event);
|
|
966
|
+
}
|
|
967
|
+
onFocusOut(event) {
|
|
968
|
+
const next = event.relatedTarget;
|
|
969
|
+
if (next) {
|
|
970
|
+
// Focus moving into the listbox content (we just opened it) — not a leave.
|
|
971
|
+
const content = this.ctx().content();
|
|
972
|
+
if (content && content.contains(next)) {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// Focus going to a sibling inside the [forSelect] wrapper — also not a leave.
|
|
976
|
+
if (this.#host.nativeElement.contains(next)) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
this.ctx().markTouched();
|
|
981
|
+
}
|
|
982
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
983
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForSelectTrigger, isStandalone: true, selector: "[forSelectTrigger]", inputs: { forSelectTrigger: { classPropertyName: "forSelectTrigger", publicName: "forSelectTrigger", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "type": "button", "role": "combobox" }, listeners: { "click": "onClick()", "keydown": "onKeyDown($event)", "focusout": "onFocusOut($event)" }, properties: { "id": "ctx().triggerId()", "attr.aria-haspopup": "\"listbox\"", "attr.aria-expanded": "ctx().open() ? \"true\" : \"false\"", "attr.aria-controls": "ctx().open() ? ctx().contentId() : null", "attr.aria-disabled": "ctx().effectiveDisabled() ? \"true\" : null", "attr.aria-readonly": "ctx().readonly() ? \"true\" : null", "attr.aria-required": "ctx().required() ? \"true\" : null", "attr.aria-invalid": "ctx().invalid() ? \"true\" : null", "attr.aria-busy": "ctx().pending() ? \"true\" : null", "attr.data-state": "ctx().open() ? \"open\" : \"closed\"", "attr.data-disabled": "ctx().effectiveDisabled() ? \"\" : null" } }, exportAs: ["forSelectTrigger"], ngImport: i0 });
|
|
984
|
+
}
|
|
985
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectTrigger, decorators: [{
|
|
986
|
+
type: Directive,
|
|
987
|
+
args: [{
|
|
988
|
+
selector: '[forSelectTrigger]',
|
|
989
|
+
exportAs: 'forSelectTrigger',
|
|
990
|
+
host: {
|
|
991
|
+
type: 'button',
|
|
992
|
+
role: 'combobox',
|
|
993
|
+
'[id]': 'ctx().triggerId()',
|
|
994
|
+
'[attr.aria-haspopup]': '"listbox"',
|
|
995
|
+
'[attr.aria-expanded]': 'ctx().open() ? "true" : "false"',
|
|
996
|
+
'[attr.aria-controls]': 'ctx().open() ? ctx().contentId() : null',
|
|
997
|
+
'[attr.aria-disabled]': 'ctx().effectiveDisabled() ? "true" : null',
|
|
998
|
+
'[attr.aria-readonly]': 'ctx().readonly() ? "true" : null',
|
|
999
|
+
'[attr.aria-required]': 'ctx().required() ? "true" : null',
|
|
1000
|
+
'[attr.aria-invalid]': 'ctx().invalid() ? "true" : null',
|
|
1001
|
+
'[attr.aria-busy]': 'ctx().pending() ? "true" : null',
|
|
1002
|
+
'[attr.data-state]': 'ctx().open() ? "open" : "closed"',
|
|
1003
|
+
'[attr.data-disabled]': 'ctx().effectiveDisabled() ? "" : null',
|
|
1004
|
+
'(click)': 'onClick()',
|
|
1005
|
+
'(keydown)': 'onKeyDown($event)',
|
|
1006
|
+
'(focusout)': 'onFocusOut($event)',
|
|
1007
|
+
},
|
|
1008
|
+
}]
|
|
1009
|
+
}], ctorParameters: () => [], propDecorators: { forSelectTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "forSelectTrigger", required: false }] }] } });
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Renders the currently-selected option's text — or the configured
|
|
1013
|
+
* placeholder when nothing is selected — into its host element via
|
|
1014
|
+
* `textContent`. Apply on a `<span>` (or any inline element) inside
|
|
1015
|
+
* `[forSelectTrigger]`:
|
|
1016
|
+
*
|
|
1017
|
+
* ```html
|
|
1018
|
+
* <button forSelectTrigger>
|
|
1019
|
+
* <span forSelectValue placeholder="Select fruit…"></span>
|
|
1020
|
+
* </button>
|
|
1021
|
+
* ```
|
|
1022
|
+
*
|
|
1023
|
+
* In multi mode the labels are joined by `separator` (default `', '`). For
|
|
1024
|
+
* fully custom rendering, drop this directive and read
|
|
1025
|
+
* `forSelect.selectedLabels()` / `forSelect.value()` from your template.
|
|
1026
|
+
*/
|
|
1027
|
+
class ForSelectValue {
|
|
1028
|
+
#ctx = injectSelectContext('ForSelectValue');
|
|
1029
|
+
/** Text shown when nothing is selected. Falls back to `[forSelect][placeholder]`. */
|
|
1030
|
+
placeholder = input('', /* @ts-ignore */
|
|
1031
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
1032
|
+
/** Joiner for multi-mode label rendering. Default `', '`. */
|
|
1033
|
+
separator = input(', ', /* @ts-ignore */
|
|
1034
|
+
...(ngDevMode ? [{ debugName: "separator" }] : /* istanbul ignore next */ []));
|
|
1035
|
+
isPlaceholder = computed(() => this.#ctx.value().length === 0, /* @ts-ignore */
|
|
1036
|
+
...(ngDevMode ? [{ debugName: "isPlaceholder" }] : /* istanbul ignore next */ []));
|
|
1037
|
+
displayText = computed(() => {
|
|
1038
|
+
const labels = this.#ctx.selectedLabels();
|
|
1039
|
+
if (labels.length === 0) {
|
|
1040
|
+
return this.placeholder() || this.#ctx.placeholder();
|
|
1041
|
+
}
|
|
1042
|
+
return labels.join(this.separator());
|
|
1043
|
+
}, /* @ts-ignore */
|
|
1044
|
+
...(ngDevMode ? [{ debugName: "displayText" }] : /* istanbul ignore next */ []));
|
|
1045
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectValue, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1046
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForSelectValue, isStandalone: true, selector: "[forSelectValue]", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, separator: { classPropertyName: "separator", publicName: "separator", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "textContent": "displayText()", "attr.data-placeholder": "isPlaceholder() ? \"\" : null" } }, exportAs: ["forSelectValue"], ngImport: i0 });
|
|
1047
|
+
}
|
|
1048
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectValue, decorators: [{
|
|
1049
|
+
type: Directive,
|
|
1050
|
+
args: [{
|
|
1051
|
+
selector: '[forSelectValue]',
|
|
1052
|
+
exportAs: 'forSelectValue',
|
|
1053
|
+
host: {
|
|
1054
|
+
'[textContent]': 'displayText()',
|
|
1055
|
+
'[attr.data-placeholder]': 'isPlaceholder() ? "" : null',
|
|
1056
|
+
},
|
|
1057
|
+
}]
|
|
1058
|
+
}], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], separator: [{ type: i0.Input, args: [{ isSignal: true, alias: "separator", required: false }] }] } });
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* The listbox surface. Carries `role="listbox"`, is portaled to
|
|
1062
|
+
* `document.body`, and is positioned by `@floating-ui/dom` against the
|
|
1063
|
+
* trigger.
|
|
1064
|
+
*
|
|
1065
|
+
* Mount/unmount of the visible content is the consumer's responsibility —
|
|
1066
|
+
* wrap with `@if (open())` so `animate.enter` / `animate.leave` fire on the
|
|
1067
|
+
* natural mount cycle. While mounted, a `DismissableLayer` activates
|
|
1068
|
+
* (Escape, pointer-down outside, focus outside); the trigger element is
|
|
1069
|
+
* exempt from outside-pointer checks so trigger clicks toggle without
|
|
1070
|
+
* dismissal racing.
|
|
1071
|
+
*
|
|
1072
|
+
* Initial focus is sent to the selected option (`'selected'`), the first
|
|
1073
|
+
* enabled option (`'first'`), or the last enabled option (`'last'`)
|
|
1074
|
+
* according to the trigger's hint. On destroy, focus returns to the
|
|
1075
|
+
* trigger when `returnFocus` is true.
|
|
1076
|
+
*
|
|
1077
|
+
* The lifecycle is owned by one of the two shared shells, picked once on
|
|
1078
|
+
* construction from `[forSelect].modal`:
|
|
1079
|
+
*
|
|
1080
|
+
* - **non-modal (default)** — `injectOverlayShell` anchors the listbox to the
|
|
1081
|
+
* trigger and branches on `[forSelect].position`:
|
|
1082
|
+
* - `'popper'` (default) — standard `injectFloating` path with full
|
|
1083
|
+
* anchored placement (`side`, `align`, `sideOffset`,
|
|
1084
|
+
* `alignOffset`, `flip`, `shift`, `arrow`, `hideWhenDetached`).
|
|
1085
|
+
* - `'item-aligned'` — `injectItemAlignedPositioner` overlays the listbox so
|
|
1086
|
+
* the selected option's center aligns with the trigger's center. The
|
|
1087
|
+
* anchored-placement inputs are documented as no-ops in this mode.
|
|
1088
|
+
* - **modal** — `injectModalShell` traps focus, inerts the background, and
|
|
1089
|
+
* locks body scroll (a centered surface the consumer positions with CSS, not
|
|
1090
|
+
* trigger-anchored). The batteries-included touch presentation. Same Escape /
|
|
1091
|
+
* outside-pointer dismiss, `dismissible` / `returnFocus` / `ariaLabel`, and
|
|
1092
|
+
* `autoFocusOnOpen` / `autoFocusOnClose` veto hooks; every anchored-
|
|
1093
|
+
* positioning input is a no-op. `aria-modal="true"` is reflected as a hint —
|
|
1094
|
+
* the real modality comes from the `inert` siblings the shell applies (the
|
|
1095
|
+
* surface keeps `role="listbox"`, for which several screen readers ignore
|
|
1096
|
+
* `aria-modal`).
|
|
1097
|
+
*
|
|
1098
|
+
* Mount/unmount of the visible content is the consumer's responsibility —
|
|
1099
|
+
* wrap with `@if (open())` so `animate.enter` / `animate.leave` fire on the
|
|
1100
|
+
* natural mount cycle.
|
|
1101
|
+
*/
|
|
1102
|
+
class ForSelectContent {
|
|
1103
|
+
ctx = injectSelectContext('ForSelectContent');
|
|
1104
|
+
#host = inject(ElementRef);
|
|
1105
|
+
constructor() {
|
|
1106
|
+
const ctx = this.ctx;
|
|
1107
|
+
registerHandle(this.#host.nativeElement, (el) => ctx.registerContent(el), (el) => ctx.unregisterContent(el));
|
|
1108
|
+
// Primitive-owned initial-focus algorithm shared by both shells.
|
|
1109
|
+
// `'selected'` falls back to the first enabled option when nothing is
|
|
1110
|
+
// selected; returns `false` so the shell focuses the container on a miss.
|
|
1111
|
+
const focusInitial = () => {
|
|
1112
|
+
if (ctx.totalCount() !== undefined) {
|
|
1113
|
+
this.#host.nativeElement.focus();
|
|
1114
|
+
ctx.seedVirtualizedInitialFocus();
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
const target = ctx.initialFocus();
|
|
1118
|
+
if (target === 'selected') {
|
|
1119
|
+
return ctx.focusSelectedOption() || ctx.focusFirstEnabledOption();
|
|
1120
|
+
}
|
|
1121
|
+
if (target === 'last') {
|
|
1122
|
+
return ctx.focusLastEnabledOption();
|
|
1123
|
+
}
|
|
1124
|
+
return ctx.focusFirstEnabledOption();
|
|
1125
|
+
};
|
|
1126
|
+
// Static branch — `modal` (and `position`) is read once on construction.
|
|
1127
|
+
// Switching modes at runtime would require re-creating the directive
|
|
1128
|
+
// (mount / unmount cycle), which is the expected pattern for primitives
|
|
1129
|
+
// whose surface is structurally different. The surface mounts lazily via
|
|
1130
|
+
// `@if (open())`, well after `modal` settles.
|
|
1131
|
+
if (ctx.modal()) {
|
|
1132
|
+
injectModalShell({
|
|
1133
|
+
modal: ctx.modal,
|
|
1134
|
+
returnFocus: ctx.returnFocus,
|
|
1135
|
+
// The trap owns Tab; the shell runs `focusInitial()` (selected →
|
|
1136
|
+
// first → last) instead of its own first-focusable move.
|
|
1137
|
+
initialFocus: {
|
|
1138
|
+
move: focusInitial,
|
|
1139
|
+
veto: () => ctx.emitAutoFocusOnOpen(),
|
|
1140
|
+
},
|
|
1141
|
+
autoFocusOnClose: () => (event) => {
|
|
1142
|
+
if (ctx.emitAutoFocusOnClose()) {
|
|
1143
|
+
event.preventDefault();
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
dismiss: {
|
|
1147
|
+
dismissible: ctx.dismissible,
|
|
1148
|
+
// The shell builds the veto and calls this when not vetoed; mirror
|
|
1149
|
+
// the anchored path's touched-on-dismiss behaviour.
|
|
1150
|
+
requestClose: (reason) => {
|
|
1151
|
+
ctx.markTouched();
|
|
1152
|
+
ctx.closeMenu(reason);
|
|
1153
|
+
},
|
|
1154
|
+
emitEscapeKeyDown: (veto) => ctx.forwardEscapeKeyDown(veto),
|
|
1155
|
+
emitPointerDownOutside: (veto) => ctx.emitPointerDownOutside(veto),
|
|
1156
|
+
emitFocusOutside: (veto) => ctx.emitFocusOutside(veto),
|
|
1157
|
+
emitInteractOutside: (veto) => ctx.emitInteractOutside(veto),
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const positioner = ctx.position() === 'item-aligned'
|
|
1163
|
+
? {
|
|
1164
|
+
kind: 'item-aligned',
|
|
1165
|
+
reference: ctx.anchor,
|
|
1166
|
+
open: ctx.open,
|
|
1167
|
+
selectedOption: ctx.selectedOptionEl,
|
|
1168
|
+
collisionPadding: ctx.collisionPadding,
|
|
1169
|
+
}
|
|
1170
|
+
: {
|
|
1171
|
+
kind: 'floating',
|
|
1172
|
+
reference: ctx.anchor,
|
|
1173
|
+
open: ctx.open,
|
|
1174
|
+
side: ctx.side,
|
|
1175
|
+
align: ctx.align,
|
|
1176
|
+
sideOffset: ctx.sideOffset,
|
|
1177
|
+
alignOffset: ctx.alignOffset,
|
|
1178
|
+
avoidCollisions: ctx.avoidCollisions,
|
|
1179
|
+
collisionPadding: ctx.collisionPadding,
|
|
1180
|
+
arrowPadding: ctx.arrowPadding,
|
|
1181
|
+
sticky: ctx.sticky,
|
|
1182
|
+
hideWhenDetached: ctx.hideWhenDetached,
|
|
1183
|
+
clipUntilPositioned: ctx.clipUntilPositioned,
|
|
1184
|
+
};
|
|
1185
|
+
injectOverlayShell({
|
|
1186
|
+
positioner,
|
|
1187
|
+
dismiss: {
|
|
1188
|
+
dismissible: ctx.dismissible,
|
|
1189
|
+
// Mirror the modal path's touched-on-dismiss behaviour.
|
|
1190
|
+
requestClose: (reason) => {
|
|
1191
|
+
ctx.markTouched();
|
|
1192
|
+
ctx.closeMenu(reason);
|
|
1193
|
+
},
|
|
1194
|
+
emitEscapeKeyDown: (event) => ctx.emitEscapeKeyDown(event),
|
|
1195
|
+
emitPointerDownOutside: (veto) => ctx.emitPointerDownOutside(veto),
|
|
1196
|
+
emitFocusOutside: (veto) => ctx.emitFocusOutside(veto),
|
|
1197
|
+
emitInteractOutside: (veto) => ctx.emitInteractOutside(veto),
|
|
1198
|
+
// Trigger button is exempt — its own click handler toggles open/close;
|
|
1199
|
+
// without exemption pointer-down-outside would race and double-close.
|
|
1200
|
+
exemptElements: () => {
|
|
1201
|
+
const t = ctx.trigger();
|
|
1202
|
+
return t ? [t] : [];
|
|
1203
|
+
},
|
|
1204
|
+
},
|
|
1205
|
+
initialFocus: {
|
|
1206
|
+
move: focusInitial,
|
|
1207
|
+
veto: () => ctx.emitAutoFocusOnOpen(),
|
|
1208
|
+
},
|
|
1209
|
+
returnFocus: {
|
|
1210
|
+
enabled: ctx.returnFocus,
|
|
1211
|
+
target: () => ctx.trigger(),
|
|
1212
|
+
// `(autoFocusOnClose)` lets the consumer veto the return-focus.
|
|
1213
|
+
veto: () => ctx.emitAutoFocusOnClose(),
|
|
1214
|
+
// Skip on `'tab'` closes — Tab already moved focus to the trigger
|
|
1215
|
+
// and let the browser advance from there; re-focusing would steal
|
|
1216
|
+
// it back.
|
|
1217
|
+
skip: () => ctx.lastCloseReason() === 'tab',
|
|
1218
|
+
},
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
onKeyDown(event) {
|
|
1222
|
+
if (this.ctx.totalCount() !== undefined) {
|
|
1223
|
+
this.ctx.handleVirtualizedKeydown(event);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectContent, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1227
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectContent, isStandalone: true, selector: "[forSelectContent]", host: { attributes: { "role": "listbox" }, listeners: { "keydown": "onKeyDown($event)" }, properties: { "attr.tabindex": "ctx.totalCount() !== undefined ? \"0\" : \"-1\"", "attr.aria-activedescendant": "ctx.activeDescendantId()", "id": "ctx.contentId()", "attr.aria-labelledby": "ctx.ariaLabel() ? null : ctx.triggerId()", "attr.aria-label": "ctx.ariaLabel()", "attr.aria-modal": "ctx.modal() ? \"true\" : null", "attr.aria-multiselectable": "ctx.multiple() ? \"true\" : null", "attr.aria-orientation": "ctx.orientation()", "attr.data-state": "ctx.open() ? \"open\" : \"closed\"", "attr.data-orientation": "ctx.orientation()" } }, exportAs: ["forSelectContent"], ngImport: i0 });
|
|
1228
|
+
}
|
|
1229
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectContent, decorators: [{
|
|
1230
|
+
type: Directive,
|
|
1231
|
+
args: [{
|
|
1232
|
+
selector: '[forSelectContent]',
|
|
1233
|
+
exportAs: 'forSelectContent',
|
|
1234
|
+
host: {
|
|
1235
|
+
role: 'listbox',
|
|
1236
|
+
'[attr.tabindex]': 'ctx.totalCount() !== undefined ? "0" : "-1"',
|
|
1237
|
+
'[attr.aria-activedescendant]': 'ctx.activeDescendantId()',
|
|
1238
|
+
'[id]': 'ctx.contentId()',
|
|
1239
|
+
'[attr.aria-labelledby]': 'ctx.ariaLabel() ? null : ctx.triggerId()',
|
|
1240
|
+
'[attr.aria-label]': 'ctx.ariaLabel()',
|
|
1241
|
+
'[attr.aria-modal]': 'ctx.modal() ? "true" : null',
|
|
1242
|
+
'[attr.aria-multiselectable]': 'ctx.multiple() ? "true" : null',
|
|
1243
|
+
'[attr.aria-orientation]': 'ctx.orientation()',
|
|
1244
|
+
'[attr.data-state]': 'ctx.open() ? "open" : "closed"',
|
|
1245
|
+
'[attr.data-orientation]': 'ctx.orientation()',
|
|
1246
|
+
'(keydown)': 'onKeyDown($event)',
|
|
1247
|
+
},
|
|
1248
|
+
}]
|
|
1249
|
+
}], ctorParameters: () => [] });
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Injection key the `[forSelectIndicator]` uses to resolve its parent option,
|
|
1253
|
+
* decoupled from the concrete `ForSelectOption` class. `ForSelectOption`
|
|
1254
|
+
* provides itself under this token, so a design system wrapping the option by
|
|
1255
|
+
* subclassing re-points it at the subclass with a single provider
|
|
1256
|
+
* (`{ provide: FOR_SELECT_OPTION, useExisting: MtxSelectOption }`) and the
|
|
1257
|
+
* indicator keeps resolving — see `docs/wrapping-form-primitives.md`.
|
|
1258
|
+
*/
|
|
1259
|
+
const FOR_SELECT_OPTION = new InjectionToken('FOR_SELECT_OPTION');
|
|
1260
|
+
/**
|
|
1261
|
+
* One option inside a `[forSelectContent]`. Apply on a `<button type="button">`
|
|
1262
|
+
* so Space / Enter activation come from native button behavior — printable
|
|
1263
|
+
* keys fall through to the listbox for typeahead matching.
|
|
1264
|
+
*
|
|
1265
|
+
* Generic over the option value type `T` (default `string`). Inferred from
|
|
1266
|
+
* the `[value]` binding so consumers can pass either primitive ids or full
|
|
1267
|
+
* objects (`[value]="city"` infers `T = City`); the parent `[forSelect]`
|
|
1268
|
+
* must be parameterized over the same `T`. The parent's
|
|
1269
|
+
* `[isItemEqualToValue]` decides how options are matched against the
|
|
1270
|
+
* committed selection.
|
|
1271
|
+
*
|
|
1272
|
+
* Click activates: in single mode the value replaces `[(value)]` and the
|
|
1273
|
+
* listbox closes; in multi mode the value toggles in/out and the listbox
|
|
1274
|
+
* stays open.
|
|
1275
|
+
*
|
|
1276
|
+
* Keyboard while focused:
|
|
1277
|
+
* - **Enter / Space** — activate (via native button click).
|
|
1278
|
+
* - **ArrowDown / ArrowUp / Home / End** — move focus inside the listbox.
|
|
1279
|
+
* - **Tab / Shift+Tab** — commit the focused option (single mode) and let
|
|
1280
|
+
* the browser advance focus to the next / previous focusable, mirroring
|
|
1281
|
+
* the WAI-ARIA select-only combobox pattern and native `<select>`.
|
|
1282
|
+
* - **Escape** — close the listbox.
|
|
1283
|
+
* - **Typeahead** — printable keys match by text content.
|
|
1284
|
+
*/
|
|
1285
|
+
class ForSelectOption {
|
|
1286
|
+
#ctx = injectSelectContext('ForSelectOption');
|
|
1287
|
+
#host = inject(ElementRef);
|
|
1288
|
+
/**
|
|
1289
|
+
* Stable identifier serialized into `[(value)]` and the hidden input.
|
|
1290
|
+
* Defaults to `string` for back-compat; bind an object to specialize the
|
|
1291
|
+
* parent `[forSelect]` over a richer `T`. The parent's
|
|
1292
|
+
* `[isItemEqualToValue]` decides how options are matched against the
|
|
1293
|
+
* committed selection.
|
|
1294
|
+
*/
|
|
1295
|
+
value = input.required(/* @ts-ignore */
|
|
1296
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1297
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1298
|
+
/**
|
|
1299
|
+
* Zero-based absolute position of this option in the full source data.
|
|
1300
|
+
* Required in the virtualized path — drives `aria-posinset` and the parent's
|
|
1301
|
+
* position snapshot. Leave unset (default `null`) outside it.
|
|
1302
|
+
*/
|
|
1303
|
+
posInSet = input(null, /* @ts-ignore */
|
|
1304
|
+
...(ngDevMode ? [{ debugName: "posInSet" }] : /* istanbul ignore next */ []));
|
|
1305
|
+
id = hostId('for-select-option');
|
|
1306
|
+
selected = computed(() => this.#ctx.isSelected(this.value()), /* @ts-ignore */
|
|
1307
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
1308
|
+
effectiveDisabled = computed(() => this.disabled() || this.#ctx.effectiveDisabled(), /* @ts-ignore */
|
|
1309
|
+
...(ngDevMode ? [{ debugName: "effectiveDisabled" }] : /* istanbul ignore next */ []));
|
|
1310
|
+
ariaSetSize = computed(() => {
|
|
1311
|
+
const total = this.#ctx.totalCount();
|
|
1312
|
+
return total === undefined ? null : String(total);
|
|
1313
|
+
}, /* @ts-ignore */
|
|
1314
|
+
...(ngDevMode ? [{ debugName: "ariaSetSize" }] : /* istanbul ignore next */ []));
|
|
1315
|
+
ariaPosInSet = computed(() => {
|
|
1316
|
+
if (this.#ctx.totalCount() === undefined) {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
const pos = this.posInSet();
|
|
1320
|
+
return pos === null ? null : String(pos + 1);
|
|
1321
|
+
}, /* @ts-ignore */
|
|
1322
|
+
...(ngDevMode ? [{ debugName: "ariaPosInSet" }] : /* istanbul ignore next */ []));
|
|
1323
|
+
#focused = signal(false, /* @ts-ignore */
|
|
1324
|
+
...(ngDevMode ? [{ debugName: "#focused" }] : /* istanbul ignore next */ []));
|
|
1325
|
+
/**
|
|
1326
|
+
* True when this option is the active candidate — DOM-focused in the
|
|
1327
|
+
* default path, or `aria-activedescendant` in the virtualized path.
|
|
1328
|
+
* Reflected as `data-highlighted`.
|
|
1329
|
+
*/
|
|
1330
|
+
highlighted = computed(() => {
|
|
1331
|
+
const activeId = this.#ctx.activeDescendantId();
|
|
1332
|
+
if (activeId !== null) {
|
|
1333
|
+
return activeId === this.id();
|
|
1334
|
+
}
|
|
1335
|
+
return this.#focused();
|
|
1336
|
+
}, /* @ts-ignore */
|
|
1337
|
+
...(ngDevMode ? [{ debugName: "highlighted" }] : /* istanbul ignore next */ []));
|
|
1338
|
+
/**
|
|
1339
|
+
* Reactive effective label exposed on the handle — the trimmed `textContent`
|
|
1340
|
+
* of the host. Mirrors `ForComboboxOption`'s `#effectiveLabel` so the root
|
|
1341
|
+
* can fold a per-option `Signal<string>` into its persisted snapshot instead
|
|
1342
|
+
* of reading `textContent` from inside a `computed`. `textContent` is not a
|
|
1343
|
+
* signal, so this still does not self-heal on a text-only change with no
|
|
1344
|
+
* value change — supply `[forSelect][itemToLabel]` for a pure signal
|
|
1345
|
+
* derivation when the label can change without the value.
|
|
1346
|
+
*/
|
|
1347
|
+
#effectiveLabel = computed(() => (this.#host.nativeElement.textContent ?? '').trim(), /* @ts-ignore */
|
|
1348
|
+
...(ngDevMode ? [{ debugName: "#effectiveLabel" }] : /* istanbul ignore next */ []));
|
|
1349
|
+
constructor() {
|
|
1350
|
+
const handle = {
|
|
1351
|
+
host: this.#host.nativeElement,
|
|
1352
|
+
value: this.value,
|
|
1353
|
+
label: this.#effectiveLabel,
|
|
1354
|
+
disabled: this.effectiveDisabled,
|
|
1355
|
+
id: this.id,
|
|
1356
|
+
posInSet: this.posInSet,
|
|
1357
|
+
};
|
|
1358
|
+
registerHandle(handle, (h) => this.#ctx.registerOption(h), (h) => this.#ctx.unregisterOption(h));
|
|
1359
|
+
}
|
|
1360
|
+
onClick() {
|
|
1361
|
+
if (this.effectiveDisabled() || this.#ctx.readonly()) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
this.#ctx.activate(this.value());
|
|
1365
|
+
this.#ctx.notifyOptionClick(this.id());
|
|
1366
|
+
}
|
|
1367
|
+
onFocus() {
|
|
1368
|
+
this.#focused.set(true);
|
|
1369
|
+
}
|
|
1370
|
+
onBlur() {
|
|
1371
|
+
this.#focused.set(false);
|
|
1372
|
+
}
|
|
1373
|
+
onKeyDown(event) {
|
|
1374
|
+
if (this.effectiveDisabled()) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
if (event.key === 'Tab') {
|
|
1378
|
+
// In modal mode the focus trap owns Tab (it cycles focus inside the
|
|
1379
|
+
// surface in the capture phase); committing + closing here would defeat
|
|
1380
|
+
// the trap. Bail and let the trap's `preventDefault` keep focus inside.
|
|
1381
|
+
if (this.#ctx.modal()) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
// APG (combobox-select-only): Tab commits the focused option and lets
|
|
1385
|
+
// the browser advance focus to the next / previous focusable. Do NOT
|
|
1386
|
+
// preventDefault — the browser's Tab default uses the focus we just
|
|
1387
|
+
// moved to the trigger as the starting point.
|
|
1388
|
+
this.#ctx.commitOnTab(this.value());
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const action = resolveListNavigation(event, {
|
|
1392
|
+
orientation: this.#ctx.orientation(),
|
|
1393
|
+
dir: this.#ctx.dir(),
|
|
1394
|
+
pageKeys: true,
|
|
1395
|
+
});
|
|
1396
|
+
if (action) {
|
|
1397
|
+
event.preventDefault();
|
|
1398
|
+
this.#ctx.navigate(this.#host.nativeElement, action);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
this.#ctx.handleTypeahead(event);
|
|
1402
|
+
}
|
|
1403
|
+
onPointerDown(event) {
|
|
1404
|
+
if (this.#ctx.totalCount() === undefined) {
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
event.preventDefault();
|
|
1408
|
+
}
|
|
1409
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectOption, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1410
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForSelectOption, isStandalone: true, selector: "[forSelectOption]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, posInSet: { classPropertyName: "posInSet", publicName: "posInSet", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "option", "type": "button", "tabindex": "-1" }, listeners: { "click": "onClick()", "keydown": "onKeyDown($event)", "focus": "onFocus()", "blur": "onBlur()", "pointerdown": "onPointerDown($event)" }, properties: { "id": "id()", "attr.aria-selected": "selected() ? \"true\" : \"false\"", "attr.aria-disabled": "effectiveDisabled() ? \"true\" : null", "attr.data-state": "selected() ? \"checked\" : \"unchecked\"", "attr.data-disabled": "effectiveDisabled() ? \"\" : null", "attr.aria-setsize": "ariaSetSize()", "attr.aria-posinset": "ariaPosInSet()", "attr.data-highlighted": "highlighted() ? \"\" : null" } }, providers: [{ provide: FOR_SELECT_OPTION, useExisting: ForSelectOption }], exportAs: ["forSelectOption"], ngImport: i0 });
|
|
1411
|
+
}
|
|
1412
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectOption, decorators: [{
|
|
1413
|
+
type: Directive,
|
|
1414
|
+
args: [{
|
|
1415
|
+
selector: '[forSelectOption]',
|
|
1416
|
+
exportAs: 'forSelectOption',
|
|
1417
|
+
providers: [{ provide: FOR_SELECT_OPTION, useExisting: ForSelectOption }],
|
|
1418
|
+
host: {
|
|
1419
|
+
role: 'option',
|
|
1420
|
+
type: 'button',
|
|
1421
|
+
tabindex: '-1',
|
|
1422
|
+
'[id]': 'id()',
|
|
1423
|
+
'[attr.aria-selected]': 'selected() ? "true" : "false"',
|
|
1424
|
+
'[attr.aria-disabled]': 'effectiveDisabled() ? "true" : null',
|
|
1425
|
+
'[attr.data-state]': 'selected() ? "checked" : "unchecked"',
|
|
1426
|
+
'[attr.data-disabled]': 'effectiveDisabled() ? "" : null',
|
|
1427
|
+
'[attr.aria-setsize]': 'ariaSetSize()',
|
|
1428
|
+
'[attr.aria-posinset]': 'ariaPosInSet()',
|
|
1429
|
+
'[attr.data-highlighted]': 'highlighted() ? "" : null',
|
|
1430
|
+
'(click)': 'onClick()',
|
|
1431
|
+
'(keydown)': 'onKeyDown($event)',
|
|
1432
|
+
'(focus)': 'onFocus()',
|
|
1433
|
+
'(blur)': 'onBlur()',
|
|
1434
|
+
'(pointerdown)': 'onPointerDown($event)',
|
|
1435
|
+
},
|
|
1436
|
+
}]
|
|
1437
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], posInSet: [{ type: i0.Input, args: [{ isSignal: true, alias: "posInSet", required: false }] }] } });
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Visibility helper inside a `[forSelectOption]`. The directive flips a
|
|
1441
|
+
* `[hidden]` host binding so the consumer can keep the checkmark / icon
|
|
1442
|
+
* inline without extra `@if` glue:
|
|
1443
|
+
*
|
|
1444
|
+
* ```html
|
|
1445
|
+
* <button forSelectOption value="apple">
|
|
1446
|
+
* <span forSelectIndicator>✓</span>
|
|
1447
|
+
* Apple
|
|
1448
|
+
* </button>
|
|
1449
|
+
* ```
|
|
1450
|
+
*
|
|
1451
|
+
* Reflects the parent option's `data-state` (`"checked" | "unchecked"`) so
|
|
1452
|
+
* the consumer can also style it from CSS. Visibility while unselected is
|
|
1453
|
+
* enforced with an inline `display: none` (which beats any author `display`
|
|
1454
|
+
* rule a consumer applies via a class) in addition to the `hidden` attribute
|
|
1455
|
+
* that removes it from the a11y tree.
|
|
1456
|
+
*/
|
|
1457
|
+
class ForSelectIndicator {
|
|
1458
|
+
option = injectParentOption();
|
|
1459
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectIndicator, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1460
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectIndicator, isStandalone: true, selector: "[forSelectIndicator]", host: { attributes: { "aria-hidden": "true" }, properties: { "attr.data-state": "option.selected() ? \"checked\" : \"unchecked\"", "hidden": "!option.selected()", "style.display": "option.selected() ? null : \"none\"" } }, exportAs: ["forSelectIndicator"], ngImport: i0 });
|
|
1461
|
+
}
|
|
1462
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectIndicator, decorators: [{
|
|
1463
|
+
type: Directive,
|
|
1464
|
+
args: [{
|
|
1465
|
+
selector: '[forSelectIndicator]',
|
|
1466
|
+
exportAs: 'forSelectIndicator',
|
|
1467
|
+
host: {
|
|
1468
|
+
'aria-hidden': 'true',
|
|
1469
|
+
'[attr.data-state]': 'option.selected() ? "checked" : "unchecked"',
|
|
1470
|
+
'[hidden]': '!option.selected()',
|
|
1471
|
+
'[style.display]': 'option.selected() ? null : "none"',
|
|
1472
|
+
},
|
|
1473
|
+
}]
|
|
1474
|
+
}] });
|
|
1475
|
+
function injectParentOption() {
|
|
1476
|
+
const option = inject(FOR_SELECT_OPTION, { optional: true });
|
|
1477
|
+
if (!option) {
|
|
1478
|
+
throw new Error('[forty-cdk/select] ForSelectIndicator must be used inside a [forSelectOption] element.');
|
|
1479
|
+
}
|
|
1480
|
+
return option;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Optional grouping wrapper inside a `[forSelectContent]`. Renders
|
|
1485
|
+
* `role="group"` and references the descendant `[forSelectGroupLabel]`
|
|
1486
|
+
* (if any) via `aria-labelledby`. Options inside a group still register
|
|
1487
|
+
* with the root listbox normally, so keyboard navigation traverses across
|
|
1488
|
+
* groups in DOM order without special handling.
|
|
1489
|
+
*/
|
|
1490
|
+
class ForSelectGroup {
|
|
1491
|
+
#labelId = signal(null, /* @ts-ignore */
|
|
1492
|
+
...(ngDevMode ? [{ debugName: "#labelId" }] : /* istanbul ignore next */ []));
|
|
1493
|
+
/** The id of the registered group label (or `null` if none). */
|
|
1494
|
+
labelId = this.#labelId.asReadonly();
|
|
1495
|
+
/** Called by `ForSelectGroupLabel` on mount. */
|
|
1496
|
+
registerLabel(id) {
|
|
1497
|
+
this.#labelId.set(id);
|
|
1498
|
+
}
|
|
1499
|
+
/** Called by `ForSelectGroupLabel` on destroy. Idempotent. */
|
|
1500
|
+
unregisterLabel(id) {
|
|
1501
|
+
if (this.#labelId() === id) {
|
|
1502
|
+
this.#labelId.set(null);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1506
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectGroup, isStandalone: true, selector: "[forSelectGroup]", host: { attributes: { "role": "group" }, properties: { "attr.aria-labelledby": "labelId() || null" } }, exportAs: ["forSelectGroup"], ngImport: i0 });
|
|
1507
|
+
}
|
|
1508
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectGroup, decorators: [{
|
|
1509
|
+
type: Directive,
|
|
1510
|
+
args: [{
|
|
1511
|
+
selector: '[forSelectGroup]',
|
|
1512
|
+
exportAs: 'forSelectGroup',
|
|
1513
|
+
host: {
|
|
1514
|
+
role: 'group',
|
|
1515
|
+
'[attr.aria-labelledby]': 'labelId() || null',
|
|
1516
|
+
},
|
|
1517
|
+
}]
|
|
1518
|
+
}] });
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Visible label for a `[forSelectGroup]`. Generates a stable id and
|
|
1522
|
+
* registers with its parent group so the group's `aria-labelledby` points
|
|
1523
|
+
* at this element. Apply on whatever heading element fits the design
|
|
1524
|
+
* (often `<div>` styled as a small caption).
|
|
1525
|
+
*/
|
|
1526
|
+
class ForSelectGroupLabel {
|
|
1527
|
+
/** Stable host id used by the parent group's `aria-labelledby`. */
|
|
1528
|
+
id;
|
|
1529
|
+
constructor() {
|
|
1530
|
+
const group = inject(ForSelectGroup, { optional: true });
|
|
1531
|
+
if (!group) {
|
|
1532
|
+
throw new Error('[forty-cdk/select] ForSelectGroupLabel must be used inside a [forSelectGroup] element.');
|
|
1533
|
+
}
|
|
1534
|
+
this.id = registerA11yName(group, 'for-select-group-label');
|
|
1535
|
+
}
|
|
1536
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectGroupLabel, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1537
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectGroupLabel, isStandalone: true, selector: "[forSelectGroupLabel]", host: { properties: { "id": "id()" } }, exportAs: ["forSelectGroupLabel"], ngImport: i0 });
|
|
1538
|
+
}
|
|
1539
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectGroupLabel, decorators: [{
|
|
1540
|
+
type: Directive,
|
|
1541
|
+
args: [{
|
|
1542
|
+
selector: '[forSelectGroupLabel]',
|
|
1543
|
+
exportAs: 'forSelectGroupLabel',
|
|
1544
|
+
host: {
|
|
1545
|
+
'[id]': 'id()',
|
|
1546
|
+
},
|
|
1547
|
+
}]
|
|
1548
|
+
}], ctorParameters: () => [] });
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Decorative separator between options or groups inside a
|
|
1552
|
+
* `[forSelectContent]`. Carries `role="separator"` and is intentionally not
|
|
1553
|
+
* registered with the listbox's option collection, so keyboard navigation
|
|
1554
|
+
* and typeahead skip it automatically.
|
|
1555
|
+
*/
|
|
1556
|
+
class ForSelectSeparator {
|
|
1557
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectSeparator, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1558
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForSelectSeparator, isStandalone: true, selector: "[forSelectSeparator]", host: { attributes: { "role": "separator", "aria-orientation": "horizontal" } }, exportAs: ["forSelectSeparator"], ngImport: i0 });
|
|
1559
|
+
}
|
|
1560
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForSelectSeparator, decorators: [{
|
|
1561
|
+
type: Directive,
|
|
1562
|
+
args: [{
|
|
1563
|
+
selector: '[forSelectSeparator]',
|
|
1564
|
+
exportAs: 'forSelectSeparator',
|
|
1565
|
+
host: {
|
|
1566
|
+
role: 'separator',
|
|
1567
|
+
'aria-orientation': 'horizontal',
|
|
1568
|
+
},
|
|
1569
|
+
}]
|
|
1570
|
+
}] });
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Exact public names of every `ForSelect` input, its models included. Spread it into the
|
|
1574
|
+
* `inputs` array of a `hostDirectives` entry so a wrapper component re-exposes the
|
|
1575
|
+
* primitive's full surface — the Signal Forms members `[formField]` binds among them —
|
|
1576
|
+
* without hand-maintaining the list. Always spread into an inline object literal as shown
|
|
1577
|
+
* below: the literal is what keeps the entry statically analyzable for consumers compiling
|
|
1578
|
+
* against the published package. An anti-drift spec fails when this list no longer matches
|
|
1579
|
+
* the directive's actual API. See `docs/wrapping-form-primitives.md` for both supported
|
|
1580
|
+
* wrapping patterns.
|
|
1581
|
+
*
|
|
1582
|
+
* @example
|
|
1583
|
+
* ```ts
|
|
1584
|
+
* @Component({
|
|
1585
|
+
* selector: 'div[mySelect]',
|
|
1586
|
+
* template: '',
|
|
1587
|
+
* hostDirectives: [
|
|
1588
|
+
* {
|
|
1589
|
+
* directive: ForSelect,
|
|
1590
|
+
* inputs: [...FOR_SELECT_HOST_DIRECTIVE_INPUTS],
|
|
1591
|
+
* outputs: [...FOR_SELECT_HOST_DIRECTIVE_OUTPUTS],
|
|
1592
|
+
* },
|
|
1593
|
+
* ],
|
|
1594
|
+
* })
|
|
1595
|
+
* export class MySelect {}
|
|
1596
|
+
* ```
|
|
1597
|
+
*/
|
|
1598
|
+
const FOR_SELECT_HOST_DIRECTIVE_INPUTS = [
|
|
1599
|
+
'value',
|
|
1600
|
+
'open',
|
|
1601
|
+
'align',
|
|
1602
|
+
'alignOffset',
|
|
1603
|
+
'ariaLabel',
|
|
1604
|
+
'arrowPadding',
|
|
1605
|
+
'avoidCollisions',
|
|
1606
|
+
'clipUntilPositioned',
|
|
1607
|
+
'collisionPadding',
|
|
1608
|
+
'dir',
|
|
1609
|
+
'dirty',
|
|
1610
|
+
'disabled',
|
|
1611
|
+
'dismissible',
|
|
1612
|
+
'errors',
|
|
1613
|
+
'hideWhenDetached',
|
|
1614
|
+
'invalid',
|
|
1615
|
+
'isItemEqualToValue',
|
|
1616
|
+
'itemToFormValue',
|
|
1617
|
+
'itemToLabel',
|
|
1618
|
+
'loop',
|
|
1619
|
+
'modal',
|
|
1620
|
+
'multiple',
|
|
1621
|
+
'name',
|
|
1622
|
+
'orientation',
|
|
1623
|
+
'pending',
|
|
1624
|
+
'placeholder',
|
|
1625
|
+
'position',
|
|
1626
|
+
'readonly',
|
|
1627
|
+
'required',
|
|
1628
|
+
'returnFocus',
|
|
1629
|
+
'selectionFollowsFocus',
|
|
1630
|
+
'side',
|
|
1631
|
+
'sideOffset',
|
|
1632
|
+
'sticky',
|
|
1633
|
+
'totalCount',
|
|
1634
|
+
'touched',
|
|
1635
|
+
'visibleRange',
|
|
1636
|
+
];
|
|
1637
|
+
/**
|
|
1638
|
+
* Exact public names of every `ForSelect` output, the Signal Forms `touch` output
|
|
1639
|
+
* included. Spread it into the `outputs` array of the same `hostDirectives` entry as
|
|
1640
|
+
* {@link FOR_SELECT_HOST_DIRECTIVE_INPUTS}.
|
|
1641
|
+
*/
|
|
1642
|
+
const FOR_SELECT_HOST_DIRECTIVE_OUTPUTS = [
|
|
1643
|
+
'valueChange',
|
|
1644
|
+
'openChange',
|
|
1645
|
+
'escapeKeyDown',
|
|
1646
|
+
'pointerDownOutside',
|
|
1647
|
+
'focusOutside',
|
|
1648
|
+
'interactOutside',
|
|
1649
|
+
'autoFocusOnOpen',
|
|
1650
|
+
'autoFocusOnClose',
|
|
1651
|
+
'scrollToIndex',
|
|
1652
|
+
'touchedChange',
|
|
1653
|
+
'touch',
|
|
1654
|
+
];
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Generated bundle index. Do not edit.
|
|
1658
|
+
*/
|
|
1659
|
+
|
|
1660
|
+
export { FOR_SELECT_CONTEXT, FOR_SELECT_DEFAULTS, FOR_SELECT_HOST_DIRECTIVE_INPUTS, FOR_SELECT_HOST_DIRECTIVE_OUTPUTS, FOR_SELECT_OPTION, ForSelect, ForSelectAnchor, ForSelectContent, ForSelectGroup, ForSelectGroupLabel, ForSelectIndicator, ForSelectOption, ForSelectSeparator, ForSelectTrigger, ForSelectValue, provideForSelectDefaults };
|
|
1661
|
+
//# sourceMappingURL=forty-cdk-select.mjs.map
|