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,2596 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { linkedSignal, untracked, InjectionToken, inject, computed, model, input, booleanAttribute, numberAttribute, output, signal, effect, Directive, ElementRef, DOCUMENT } from '@angular/core';
|
|
3
|
+
import { tryReadHandle, createDefaults, foldSnapshotOnTotalCountTransition, VirtualizedNavigator as VirtualizedNavigator$1, FormUiControlBase, IdGenerator, Collection, defaultItemToFormValue, injectTextDirection, createPointerSuppression, singleSelected, injectHiddenInput, adoptHostId, isInArray, toggleInArray, nextEnabledHandle, emitVetoableEvent, emitVetoableNativeEvent, registerHandle, reflectDisabled, injectOverlayShell, hostId, registerA11yName } from 'forty-cdk/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Combobox-local re-exports of the shared snapshot-fold helpers. The single
|
|
7
|
+
* sources live in `_internal/`: `tryReadHandle` (the NG0950 read guard) in
|
|
8
|
+
* `_internal/signal-graph/read-handle.ts` and `foldSnapshotOnTotalCountTransition`
|
|
9
|
+
* (the `totalCount`-reset `linkedSignal` fold) in
|
|
10
|
+
* `_internal/collection/fold-snapshot.ts`. Kept as a barrel so `OptionLabelCache`
|
|
11
|
+
* and the combobox virtualized navigator import from one combobox-local path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the host's activedescendant pointer as a `linkedSignal` so the
|
|
16
|
+
* "what should be highlighted given open / items / autoHighlight" decision is a
|
|
17
|
+
* **pure derivation**, never a write from an `effect` (the banned
|
|
18
|
+
* state-propagation pattern). The returned signal is still writable for the
|
|
19
|
+
* genuinely imperative moves that own their own scroll — arrow / Home / End
|
|
20
|
+
* navigation, pointer-move hover, multi-mode activation, and the virtualized
|
|
21
|
+
* pending-nav resolution (`navigate` / `seedFromIndexedSnapshot` /
|
|
22
|
+
* `tryResolvePending` in `combobox-virtualized-navigator.ts`).
|
|
23
|
+
*
|
|
24
|
+
* The reset/seed rule, in order:
|
|
25
|
+
* - On a `query` change the previously-active option may have been filtered
|
|
26
|
+
* out, so the prior pointer is dropped.
|
|
27
|
+
* - A pointer that no longer matches a registered option is dropped (covers the
|
|
28
|
+
* consumer mutating the list without touching `query`).
|
|
29
|
+
* - When a valid pointer survives, it is preserved (arrow nav, hover).
|
|
30
|
+
* - Otherwise, in the **non-virtualized** case, auto-highlight seeds the
|
|
31
|
+
* first / last enabled option (per `initialFocus`) while the listbox is open
|
|
32
|
+
* via {@link resolveAutoHighlightSeed}. The virtualized case returns `null`
|
|
33
|
+
* here and the host's effect seeds it imperatively, because that seed must
|
|
34
|
+
* order options by absolute `posInSet` and must lose to a pending
|
|
35
|
+
* `(scrollToIndex)` resolution.
|
|
36
|
+
*
|
|
37
|
+
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
38
|
+
*/
|
|
39
|
+
function createActiveIdSignal(source) {
|
|
40
|
+
return linkedSignal({
|
|
41
|
+
source: () => ({
|
|
42
|
+
query: source.query(),
|
|
43
|
+
open: source.open(),
|
|
44
|
+
autoHighlight: source.autoHighlight(),
|
|
45
|
+
virtualized: source.virtualized(),
|
|
46
|
+
initialFocus: source.initialFocus(),
|
|
47
|
+
items: source.items(),
|
|
48
|
+
value: source.value(),
|
|
49
|
+
equals: source.equals(),
|
|
50
|
+
}),
|
|
51
|
+
computation: ({ query, open, autoHighlight, virtualized, initialFocus, items, value, equals }, prev) => {
|
|
52
|
+
const queryChanged = prev !== undefined && prev.source.query !== query;
|
|
53
|
+
let current = queryChanged ? null : (prev?.value ?? null);
|
|
54
|
+
if (current !== null && !items.some((o) => o.id() === current)) {
|
|
55
|
+
current = null;
|
|
56
|
+
}
|
|
57
|
+
if (current !== null) {
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
if (virtualized || !autoHighlight || !open || items.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return resolveAutoHighlightSeed({ items, initialFocus, value, equals });
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the auto-highlight seed for the **non-virtualized** combobox: the id
|
|
69
|
+
* of the option `aria-activedescendant` should fall on when the listbox is open
|
|
70
|
+
* and no pointer survives. Returns `null` when no enabled option qualifies (or
|
|
71
|
+
* when a `'selected'` seed can't yet read its options — see `tryReadHandle`).
|
|
72
|
+
*
|
|
73
|
+
* Pure over its inputs — no signal reads, no DOM access — so the host can call
|
|
74
|
+
* it from inside its `#activeId` linkedSignal computation without leaking the
|
|
75
|
+
* banned write-from-effect pattern. The virtualized branch seeds imperatively
|
|
76
|
+
* via the navigator instead (it orders by absolute `posInSet` and must lose to
|
|
77
|
+
* a pending `(scrollToIndex)` resolution).
|
|
78
|
+
*
|
|
79
|
+
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
80
|
+
*/
|
|
81
|
+
function resolveAutoHighlightSeed(input) {
|
|
82
|
+
const { items, initialFocus, value, equals } = input;
|
|
83
|
+
if (initialFocus === 'selected') {
|
|
84
|
+
const selected = findSelectedEnabled(items, value, equals);
|
|
85
|
+
if (selected === NOT_READY) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (selected) {
|
|
89
|
+
return selected.id();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const target = initialFocus === 'last' ? findLastEnabled(items) : findFirstEnabled(items);
|
|
93
|
+
return target?.id() ?? null;
|
|
94
|
+
}
|
|
95
|
+
function findFirstEnabled(items) {
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
if (!item.disabled()) {
|
|
98
|
+
return item;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function findLastEnabled(items) {
|
|
104
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
105
|
+
const item = items[i];
|
|
106
|
+
if (item && !item.disabled()) {
|
|
107
|
+
return item;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const NOT_READY = Symbol('forty-cdk/combobox:not-ready');
|
|
113
|
+
function findSelectedEnabled(items, values, equals) {
|
|
114
|
+
if (values.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
if (item.disabled()) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const read = tryReadHandle(() => ({ value: item.value() }));
|
|
122
|
+
if (read === null) {
|
|
123
|
+
return NOT_READY;
|
|
124
|
+
}
|
|
125
|
+
if (values.some((sel) => equals(read.value, sel))) {
|
|
126
|
+
return item;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The **imperative tail** of the auto-highlight flow, run from the host's
|
|
133
|
+
* constructor `effect`. The activedescendant *decision* is a pure derivation in
|
|
134
|
+
* {@link createActiveIdSignal}; this only performs the side effects that escape
|
|
135
|
+
* the reactive graph. It reacts to `items()`, `autoHighlight()` and `open()`:
|
|
136
|
+
*
|
|
137
|
+
* 1. Primes the label cache so its `linkedSignal` `prev` slot gets seeded while
|
|
138
|
+
* the listbox is open — without an eager pull the lazy cache never runs
|
|
139
|
+
* during the open cycle in non-virtualized usage and persistence across
|
|
140
|
+
* close → re-open would start from an empty `prev`. The virtualization
|
|
141
|
+
* navigator (and its position-map) is primed only when the consumer set
|
|
142
|
+
* `totalCount()`, so a plain combobox never builds it.
|
|
143
|
+
* 2. Virtualized only: resolves a pending `(scrollToIndex)` navigation — once
|
|
144
|
+
* the option for the requested posInSet mounts, `tryResolvePending` seeds
|
|
145
|
+
* activedescendant to its id and scrolls it into view. This is the single
|
|
146
|
+
* sanctioned activedescendant write from an effect, and it is a legitimate
|
|
147
|
+
* side effect (not state propagation): it integrates the consumer's
|
|
148
|
+
* virtualizer mounting a row asynchronously, and it must win over the
|
|
149
|
+
* auto-highlight seed — which is why the virtualized seed can't live in the
|
|
150
|
+
* linkedSignal. `seedFromIndexedSnapshot` then seeds the topmost / bottommost
|
|
151
|
+
* *rendered* enabled option (ordered by absolute `posInSet`) deliberately
|
|
152
|
+
* passively — it only moves the pointer, never the consumer's scroll position.
|
|
153
|
+
* 3. Non-virtualized: scrolls the auto-highlight-seeded option into view so a
|
|
154
|
+
* seed that lands below the fold is visible, for parity with `navigate()`.
|
|
155
|
+
* The seed itself comes from the linkedSignal; this is its imperative tail.
|
|
156
|
+
* The activedescendant is read `untracked` so the scroll never re-triggers
|
|
157
|
+
* the effect, and pointer-move hover doesn't reach here (it changes none of
|
|
158
|
+
* the tracked reads), so hovering never scrolls.
|
|
159
|
+
*
|
|
160
|
+
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
161
|
+
*/
|
|
162
|
+
function runAutoHighlightBridge(deps) {
|
|
163
|
+
deps.labelCache.prime();
|
|
164
|
+
const items = deps.items();
|
|
165
|
+
const open = deps.open();
|
|
166
|
+
const autoHighlight = deps.autoHighlight();
|
|
167
|
+
const virtualized = deps.virtualized();
|
|
168
|
+
if (virtualized) {
|
|
169
|
+
const navigator = deps.requireNavigator();
|
|
170
|
+
navigator.prime();
|
|
171
|
+
if (navigator.tryResolvePending()) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (autoHighlight && open && untracked(() => deps.getActiveId()) === null && items.length > 0) {
|
|
175
|
+
navigator.seedFromIndexedSnapshot(deps.initialFocus() === 'last' ? 'last' : 'first');
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!open) {
|
|
180
|
+
deps.setLastPositionedId(null);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const activeId = untracked(() => deps.getActiveId());
|
|
184
|
+
if (activeId === null || activeId === deps.getLastPositionedId()) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const active = items.find((o) => o.id() === activeId);
|
|
188
|
+
active?.host.scrollIntoView?.({ block: 'nearest' });
|
|
189
|
+
deps.setLastPositionedId(activeId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `ForCombobox<T>`'s generic does NOT flow to this token: an `InjectionToken`
|
|
194
|
+
* is a single runtime instance, so it is published at `ForComboboxContext<unknown>`.
|
|
195
|
+
* `injectComboboxContext<T>()` re-applies `T` with an `as unknown as` cast, and
|
|
196
|
+
* each piece (input, option, chip) relies on consumer discipline — the
|
|
197
|
+
* `[forComboboxOption][value]` and the root `[(value)]` must be the same `T`.
|
|
198
|
+
* There is no clean fix without abandoning the token pattern; the contract is
|
|
199
|
+
* the consumer's to honor. Object identity is reconciled at runtime via
|
|
200
|
+
* `isItemEqualToValue`, which bounds the practical blast radius of a mismatch.
|
|
201
|
+
*/
|
|
202
|
+
const FOR_COMBOBOX_CONTEXT = new InjectionToken('FOR_COMBOBOX_CONTEXT');
|
|
203
|
+
/**
|
|
204
|
+
* Resolve the surrounding combobox context, re-applying the caller's `T`. The
|
|
205
|
+
* cast through `unknown` is unavoidable (see {@link FOR_COMBOBOX_CONTEXT}): the
|
|
206
|
+
* token can't carry the per-instance generic, so `T` correctness is a
|
|
207
|
+
* consumer-honored contract, not a compiler-enforced one.
|
|
208
|
+
*/
|
|
209
|
+
function injectComboboxContext(piece) {
|
|
210
|
+
const ctx = inject(FOR_COMBOBOX_CONTEXT, { optional: true });
|
|
211
|
+
if (!ctx) {
|
|
212
|
+
throw new Error(`[forty-cdk/combobox] ${piece} must be used inside a [forCombobox] element. ` +
|
|
213
|
+
"If it is declared inside an ng-template, DI resolves at the template's declaration site — " +
|
|
214
|
+
'not where it is stamped (e.g. via ngTemplateOutlet) — so declare the template inside the ' +
|
|
215
|
+
'[forCombobox] root.');
|
|
216
|
+
}
|
|
217
|
+
return ctx;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Resolves the trigger's root context: the explicit reference when the
|
|
221
|
+
* `[forComboboxTrigger]` input carries one, the injected `FOR_COMBOBOX_CONTEXT`
|
|
222
|
+
* otherwise. The orphan error only fires when neither resolves, on first read
|
|
223
|
+
* of the returned signal. Must be called in an injection context.
|
|
224
|
+
*/
|
|
225
|
+
function injectComboboxTriggerContext(explicitRoot) {
|
|
226
|
+
const injected = inject(FOR_COMBOBOX_CONTEXT, { optional: true });
|
|
227
|
+
return computed(() => {
|
|
228
|
+
const explicit = explicitRoot();
|
|
229
|
+
if (explicit !== '') {
|
|
230
|
+
return explicit;
|
|
231
|
+
}
|
|
232
|
+
if (injected) {
|
|
233
|
+
return injected;
|
|
234
|
+
}
|
|
235
|
+
throw new Error('[forty-cdk/combobox] ForComboboxTrigger could not resolve its [forCombobox] root: ' +
|
|
236
|
+
'no FOR_COMBOBOX_CONTEXT provider is visible and no explicit root reference was passed. ' +
|
|
237
|
+
"If this trigger is declared inside an ng-template, DI resolves at the template's declaration " +
|
|
238
|
+
'site — not where it is stamped — so either declare the template inside the root or pass the ' +
|
|
239
|
+
'root explicitly: [forComboboxTrigger]="root" with #root="forCombobox".');
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Library fallback for combobox defaults, read at the root injector when no
|
|
245
|
+
* consumer has called `provideForComboboxDefaults`. Exported for the shared
|
|
246
|
+
* defaults contract spec; not re-exported from the primitive's public entry.
|
|
247
|
+
*/
|
|
248
|
+
const FOR_COMBOBOX_FALLBACK_DEFAULTS = {
|
|
249
|
+
sideOffset: 4,
|
|
250
|
+
collisionPadding: 8,
|
|
251
|
+
};
|
|
252
|
+
const { token, provideDefaults } = createDefaults('FOR_COMBOBOX_DEFAULTS', FOR_COMBOBOX_FALLBACK_DEFAULTS);
|
|
253
|
+
/** Token holding the resolved combobox defaults for the current scope. */
|
|
254
|
+
const FOR_COMBOBOX_DEFAULTS = token;
|
|
255
|
+
/**
|
|
256
|
+
* Configures forty-cdk combobox defaults for this injector scope. Partial
|
|
257
|
+
* overrides inherit unspecified keys from the parent scope (or library
|
|
258
|
+
* defaults at the root).
|
|
259
|
+
*/
|
|
260
|
+
function provideForComboboxDefaults(defaults = {}) {
|
|
261
|
+
return provideDefaults(defaults);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Always-on label cache for `ForCombobox`. Keeps a flat list of
|
|
266
|
+
* `{ id, value, label }` tuples that persists across listbox close / open
|
|
267
|
+
* cycles, driving inline autocomplete and the `selected` label fallback.
|
|
268
|
+
*
|
|
269
|
+
* Persistence: when the consumer's `@if` unmounts the content
|
|
270
|
+
* (`items().length === 0`) the prior cache is carried over so chips outside
|
|
271
|
+
* the listbox area and inline completion after close keep resolving labels.
|
|
272
|
+
* Resets only when the consumer's `totalCount` transitions — a query / source
|
|
273
|
+
* rebuild signal in the virtualized case (the shared stale-window invariant
|
|
274
|
+
* lives in `combobox-snapshot-fold.ts`).
|
|
275
|
+
*
|
|
276
|
+
* The option's `label` is itself a `Signal<string>` so we never peek at
|
|
277
|
+
* `textContent`. Internal helper — not re-exported from `combobox/index.ts`
|
|
278
|
+
* or `public-api.ts`. Constructed once per host directive, on every combobox
|
|
279
|
+
* (virtualized or not).
|
|
280
|
+
*/
|
|
281
|
+
class OptionLabelCache {
|
|
282
|
+
#cachedOptions;
|
|
283
|
+
constructor(deps) {
|
|
284
|
+
this.#cachedOptions = foldSnapshotOnTotalCountTransition(deps.items, deps.totalCount, () => [], (prev, items) => {
|
|
285
|
+
if (items.length === 0) {
|
|
286
|
+
return prev;
|
|
287
|
+
}
|
|
288
|
+
const merged = new Map();
|
|
289
|
+
for (const entry of prev) {
|
|
290
|
+
merged.set(entry.id, entry);
|
|
291
|
+
}
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
// A static option (rendered outside `@for`) registers before its
|
|
294
|
+
// `[value]` binding is written; skip it this fold and pick it up on
|
|
295
|
+
// the re-run the binding triggers. See `tryReadHandle`.
|
|
296
|
+
const entry = tryReadHandle(() => ({
|
|
297
|
+
id: item.id(),
|
|
298
|
+
value: item.value(),
|
|
299
|
+
label: item.label(),
|
|
300
|
+
}));
|
|
301
|
+
if (entry === null) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
merged.set(entry.id, entry);
|
|
305
|
+
}
|
|
306
|
+
return [...merged.values()];
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Pull the cache so its `linkedSignal` `prev` slot gets seeded while the
|
|
311
|
+
* listbox is open. Called from the host's bridge effect — without an eager
|
|
312
|
+
* pull the lazy cache never runs during the open cycle (no other consumer
|
|
313
|
+
* pulls it in non-virtualized usage), and persistence across close → re-open
|
|
314
|
+
* would start from an empty `prev`.
|
|
315
|
+
*/
|
|
316
|
+
prime() {
|
|
317
|
+
this.#cachedOptions();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Snapshot of the registered options as plain `{ id, value, label }` tuples.
|
|
321
|
+
* Drives inline-autocomplete matching and the label-resolution fallback in
|
|
322
|
+
* `ForCombobox.selected`.
|
|
323
|
+
*/
|
|
324
|
+
entries() {
|
|
325
|
+
return this.#cachedOptions();
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Live entries merged with any off-window entries from the virtualized
|
|
329
|
+
* position-map, so typeahead and inline autocomplete can match against
|
|
330
|
+
* options scrolled out of view. Live entries take precedence (freshest data)
|
|
331
|
+
* and appear first, followed by off-window indexed entries sorted by absolute
|
|
332
|
+
* position. Pass an empty / zero-size map (the non-virtualized case) to get
|
|
333
|
+
* the live entries unchanged.
|
|
334
|
+
*/
|
|
335
|
+
mergedEntries(indexed) {
|
|
336
|
+
const live = this.#cachedOptions();
|
|
337
|
+
if (indexed.size === 0) {
|
|
338
|
+
return live;
|
|
339
|
+
}
|
|
340
|
+
const seen = new Set(live.map((o) => o.id));
|
|
341
|
+
const merged = [...live];
|
|
342
|
+
const positions = [...indexed.keys()].sort((a, b) => a - b);
|
|
343
|
+
for (const pos of positions) {
|
|
344
|
+
const entry = indexed.get(pos);
|
|
345
|
+
if (seen.has(entry.id)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
merged.push({ id: entry.id, value: entry.value, label: entry.label });
|
|
349
|
+
}
|
|
350
|
+
return merged;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Virtualization navigation engine for `ForCombobox`. A thin adapter over the
|
|
356
|
+
* shared `_internal/virtualized-navigator` engine. Maps the combobox option
|
|
357
|
+
* handle (whose `posInSet` is optional) onto the engine's accessors, reads the
|
|
358
|
+
* option through the single NG0950 read guard, and overrides scroll-into-view
|
|
359
|
+
* with the host's pointer-suppression wrapper. Adds the combobox-only
|
|
360
|
+
* auto-highlight seed that stays passive (never scrolls the window).
|
|
361
|
+
*
|
|
362
|
+
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
363
|
+
*/
|
|
364
|
+
class VirtualizedNavigator {
|
|
365
|
+
#deps;
|
|
366
|
+
#core;
|
|
367
|
+
constructor(deps) {
|
|
368
|
+
this.#deps = deps;
|
|
369
|
+
this.#core = new VirtualizedNavigator$1({ ...deps, loop: deps.loop }, {
|
|
370
|
+
posOf: (o) => o.posInSet?.() ?? null,
|
|
371
|
+
idOf: (o) => o.id(),
|
|
372
|
+
hostOf: (o) => o.host,
|
|
373
|
+
readEntry: (o) => tryReadHandle(() => ({
|
|
374
|
+
id: o.id(),
|
|
375
|
+
value: o.value(),
|
|
376
|
+
label: o.label(),
|
|
377
|
+
disabled: o.disabled(),
|
|
378
|
+
})),
|
|
379
|
+
scrollIntoView: (host) => deps.scrollActiveIntoView(host),
|
|
380
|
+
}, { deferFoldOnTotalTransition: true });
|
|
381
|
+
}
|
|
382
|
+
/** @see VirtualizedNavigator.prime */
|
|
383
|
+
prime() {
|
|
384
|
+
this.#core.prime();
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Position-keyed snapshot for `ForCombobox.selected`'s scrolled-out-of-view
|
|
388
|
+
* fallback and for the merged-label lookup. Read-only for callers.
|
|
389
|
+
*/
|
|
390
|
+
snapshotByPos() {
|
|
391
|
+
return this.#core.snapshotByPos();
|
|
392
|
+
}
|
|
393
|
+
/** @see VirtualizedNavigator.tryResolvePending */
|
|
394
|
+
tryResolvePending() {
|
|
395
|
+
return this.#core.tryResolvePending();
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Auto-highlight the first or last enabled option that is **currently
|
|
399
|
+
* rendered**, ordered by absolute `posInSet`. Used in the virtualized branch
|
|
400
|
+
* of the host's auto-highlight effect — for non-virtualized lists the host
|
|
401
|
+
* walks the live `items()` array directly. No-op if `totalCount` is unset /
|
|
402
|
+
* zero or nothing is rendered.
|
|
403
|
+
*
|
|
404
|
+
* Deliberately *passive*: it only ever moves `aria-activedescendant`, never
|
|
405
|
+
* the consumer's scroll position. Auto-highlight re-runs every time the
|
|
406
|
+
* activedescendant is cleared, and scrolling the active option out of the
|
|
407
|
+
* rendered window clears it (see `ForCombobox.unregisterOption`). If this
|
|
408
|
+
* seed emitted `(scrollToIndex)` toward the absolute first option, every
|
|
409
|
+
* wheel tick that unmounted the active row would snap the listbox straight
|
|
410
|
+
* back to the top. Off-window targets are reached only through explicit
|
|
411
|
+
* keyboard navigation (`navigate`), which owns scroll-into-view.
|
|
412
|
+
*/
|
|
413
|
+
seedFromIndexedSnapshot(direction) {
|
|
414
|
+
const total = this.#deps.totalCount();
|
|
415
|
+
if (total === undefined || total <= 0) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const items = this.#deps.items();
|
|
419
|
+
if (items.length === 0) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const ordered = [...items].sort((a, b) => {
|
|
423
|
+
const pa = a.posInSet?.() ?? 0;
|
|
424
|
+
const pb = b.posInSet?.() ?? 0;
|
|
425
|
+
return direction === 'last' ? pb - pa : pa - pb;
|
|
426
|
+
});
|
|
427
|
+
for (const item of ordered) {
|
|
428
|
+
if (!item.disabled()) {
|
|
429
|
+
this.#deps.setActiveId(item.id());
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** @see VirtualizedNavigator.navigate */
|
|
435
|
+
navigate(direction) {
|
|
436
|
+
this.#core.navigate(direction);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Drop any pending virtualized navigation. Called by `closeMenu` so a
|
|
440
|
+
* pending request from the previous open cycle doesn't seed activedescendant
|
|
441
|
+
* after the listbox re-opens.
|
|
442
|
+
*/
|
|
443
|
+
resetPending() {
|
|
444
|
+
this.#core.resetPending();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Headless implementation of the [WAI-ARIA combobox with listbox popup pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
|
|
450
|
+
* Implements `FormValueControl<readonly T[]>` from `@angular/forms/signals`
|
|
451
|
+
* for `[formField]` auto-wiring.
|
|
452
|
+
*
|
|
453
|
+
* Generic over the option value type `T` (default `string`). When the
|
|
454
|
+
* consumer binds object items the directive infers `T` from `[(value)]`
|
|
455
|
+
* and per-piece signatures (`[forComboboxOption][value]`,
|
|
456
|
+
* `[forComboboxChip][value]`) specialize accordingly. Object identity is
|
|
457
|
+
* resolved by the consumer-supplied `[isItemEqualToValue]` and labels by
|
|
458
|
+
* `[itemToStringLabel]`; the hidden inputs serialize via
|
|
459
|
+
* `[itemToFormValue]` (defaults to `JSON.stringify` for non-strings).
|
|
460
|
+
*
|
|
461
|
+
* Selection is always modeled as `readonly T[]`:
|
|
462
|
+
* - In single mode (`multiple=false`, default), the array has 0 or 1
|
|
463
|
+
* element and option activation closes the listbox.
|
|
464
|
+
* - In multi mode, option activation toggles in/out and the listbox stays
|
|
465
|
+
* open. Selected entries are typically rendered as chips inside
|
|
466
|
+
* `[forComboboxChips]` next to the input.
|
|
467
|
+
*
|
|
468
|
+
* The visible input ("query") and the form value are separate two-way
|
|
469
|
+
* bindable models — the consumer keeps them in sync via filtering /
|
|
470
|
+
* display logic, and the primitive only commits to `value` when an option
|
|
471
|
+
* is explicitly activated.
|
|
472
|
+
*
|
|
473
|
+
* Filtering is **always** the consumer's responsibility — the primitive is
|
|
474
|
+
* headless and doesn't filter the registered options. Render the filtered
|
|
475
|
+
* subset with `@for` and the registry tracks them automatically.
|
|
476
|
+
*/
|
|
477
|
+
class ForCombobox extends FormUiControlBase {
|
|
478
|
+
#idGen = inject(IdGenerator);
|
|
479
|
+
#defaults = inject(FOR_COMBOBOX_DEFAULTS);
|
|
480
|
+
#items = new Collection();
|
|
481
|
+
#chips = new Collection();
|
|
482
|
+
/**
|
|
483
|
+
* Two-way bindable. Visible input text. The `model()` change emitter
|
|
484
|
+
* (`(queryChange)`) fires only on internal mutations (option activation
|
|
485
|
+
* commit, `clear()`, multi-mode select reset, picker-anatomy reset on
|
|
486
|
+
* close), never on consumer writes via `[(query)]`.
|
|
487
|
+
*/
|
|
488
|
+
query = model('', /* @ts-ignore */
|
|
489
|
+
...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
|
|
490
|
+
/**
|
|
491
|
+
* Two-way bindable. Selected option values. Single mode (`multiple=false`)
|
|
492
|
+
* keeps 0 or 1 element; multi mode keeps any number. The `model()` change
|
|
493
|
+
* emitter (`(valueChange)`) fires only on internal selection changes,
|
|
494
|
+
* never on consumer writes via `[(value)]`.
|
|
495
|
+
*/
|
|
496
|
+
value = model([], /* @ts-ignore */
|
|
497
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
498
|
+
/**
|
|
499
|
+
* Compare two items for equality. Defaults to `===`, which is the
|
|
500
|
+
* correct identity for primitive `T` (e.g. strings, numbers). Override
|
|
501
|
+
* when binding object items so the directive can locate selected /
|
|
502
|
+
* removed entries by id (or any other stable key) instead of by
|
|
503
|
+
* reference: `[isItemEqualToValue]="(a, b) => a.id === b.id"`.
|
|
504
|
+
*/
|
|
505
|
+
isItemEqualToValue = input((a, b) => a === b, /* @ts-ignore */
|
|
506
|
+
...(ngDevMode ? [{ debugName: "isItemEqualToValue" }] : /* istanbul ignore next */ []));
|
|
507
|
+
/**
|
|
508
|
+
* Render an item as a string label. Defaults to `String(item)`, which is
|
|
509
|
+
* identity for strings. Drives the visible input text after activation
|
|
510
|
+
* (when `commitOnSelect`) and the chip label fallback in multi mode.
|
|
511
|
+
* Override when binding object items so the directive can fall back to
|
|
512
|
+
* a meaningful label without relying on the option cache being warm:
|
|
513
|
+
* `[itemToStringLabel]="(it) => it.name"`.
|
|
514
|
+
*/
|
|
515
|
+
itemToStringLabel = input((item) => String(item), /* @ts-ignore */
|
|
516
|
+
...(ngDevMode ? [{ debugName: "itemToStringLabel" }] : /* istanbul ignore next */ []));
|
|
517
|
+
/**
|
|
518
|
+
* Serialize an item for the hidden input that participates in native
|
|
519
|
+
* form submission. Defaults to identity for strings and to
|
|
520
|
+
* `JSON.stringify` for non-string items so the primitive works out of
|
|
521
|
+
* the box round-tripping objects. Override to emit a specific wire
|
|
522
|
+
* format — typically a per-item id — when the backend expects that:
|
|
523
|
+
* `[itemToFormValue]="(it) => it.id"`.
|
|
524
|
+
*/
|
|
525
|
+
itemToFormValue = input(defaultItemToFormValue, /* @ts-ignore */
|
|
526
|
+
...(ngDevMode ? [{ debugName: "itemToFormValue" }] : /* istanbul ignore next */ []));
|
|
527
|
+
/**
|
|
528
|
+
* Two-way bindable. Whether the listbox is currently shown. Internal
|
|
529
|
+
* transitions: input typing (when `openOnQuery`), focus (when
|
|
530
|
+
* `openOnFocus`), ArrowDown / ArrowUp, Escape, outside dismissal,
|
|
531
|
+
* single-mode option activation.
|
|
532
|
+
*/
|
|
533
|
+
open = model(false, /* @ts-ignore */
|
|
534
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
535
|
+
multiple = input(false, { ...(ngDevMode ? { debugName: "multiple" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
536
|
+
/**
|
|
537
|
+
* Autocomplete mode applied to the input. Mirrors the
|
|
538
|
+
* [WAI-ARIA `aria-autocomplete` property](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#wai-ariaroles,states,andproperties)
|
|
539
|
+
* and drives whether the listbox auto-opens on query and whether the
|
|
540
|
+
* input gets inline-completed with the first match. Renamed from
|
|
541
|
+
* `autocomplete` so consumers don't conflate it with the native HTML
|
|
542
|
+
* `autocomplete` attribute (which the directive forces to `"off"`).
|
|
543
|
+
*/
|
|
544
|
+
autocompleteMode = input('list', /* @ts-ignore */
|
|
545
|
+
...(ngDevMode ? [{ debugName: "autocompleteMode" }] : /* istanbul ignore next */ []));
|
|
546
|
+
/** Open the listbox when the input gains focus. Off by default — opening on query / arrow keys is the standard ecosystem behavior. */
|
|
547
|
+
openOnFocus = input(false, { ...(ngDevMode ? { debugName: "openOnFocus" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
548
|
+
/** Open the listbox when the user starts typing. On by default. Only honored when `autocompleteMode` includes a listbox (`'list'` or `'both'`). */
|
|
549
|
+
openOnQuery = input(true, { ...(ngDevMode ? { debugName: "openOnQuery" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
550
|
+
/**
|
|
551
|
+
* In single mode, copy the activated option's label into `query`. In
|
|
552
|
+
* multi mode, instead **clear** the query so the user can search the
|
|
553
|
+
* next item. On by default in both. Set `false` to leave `query`
|
|
554
|
+
* untouched on activation in either mode.
|
|
555
|
+
*
|
|
556
|
+
* Governs the **editable anatomy** only. In the picker anatomy (a
|
|
557
|
+
* `[forComboboxTrigger]` is registered) the in-panel input is a transient
|
|
558
|
+
* filter, not the value display: the single-mode label copy is always
|
|
559
|
+
* skipped and `query` resets to `''` on close regardless of this flag.
|
|
560
|
+
*/
|
|
561
|
+
commitOnSelect = input(true, { ...(ngDevMode ? { debugName: "commitOnSelect" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
562
|
+
/** When the user edits the query, automatically clear the committed `value`. Off by default — most apps want the value preserved across query edits. Single-mode only. */
|
|
563
|
+
clearOnQueryChange = input(false, { ...(ngDevMode ? { debugName: "clearOnQueryChange" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
564
|
+
/**
|
|
565
|
+
* Auto-highlight the first enabled option whenever the listbox is open
|
|
566
|
+
* and no activedescendant is set (e.g. after the consumer's filter
|
|
567
|
+
* removed the previously-active option). On by default. Set `false` for
|
|
568
|
+
* "user must arrow before anything is highlighted" behavior.
|
|
569
|
+
*/
|
|
570
|
+
autoHighlight = input(true, { ...(ngDevMode ? { debugName: "autoHighlight" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
571
|
+
/**
|
|
572
|
+
* Writing direction. Drives chip-cluster keyboard navigation (ArrowLeft /
|
|
573
|
+
* ArrowRight semantics swap in RTL so they follow the visual order, not DOM
|
|
574
|
+
* order) and the default `align` of the listbox (anchors to the right edge
|
|
575
|
+
* of the input in RTL). When unset (default `null`), the inherited ambient
|
|
576
|
+
* direction is resolved from the nearest ancestor carrying a `dir` attribute
|
|
577
|
+
* (or `<html dir>`), defaulting to `'ltr'`. An explicit `[dir]` always wins
|
|
578
|
+
* and the resolved value is reflected to the host `dir` attribute.
|
|
579
|
+
*/
|
|
580
|
+
_dirInput = input(null, { ...(ngDevMode ? { debugName: "_dirInput" } : /* istanbul ignore next */ {}), alias: 'dir' });
|
|
581
|
+
dir = injectTextDirection(this._dirInput);
|
|
582
|
+
/**
|
|
583
|
+
* Side the listbox is anchored to. Defaults to `'bottom'`. Pair with
|
|
584
|
+
* `align` for the full positioning API.
|
|
585
|
+
*/
|
|
586
|
+
side = input('bottom', /* @ts-ignore */
|
|
587
|
+
...(ngDevMode ? [{ debugName: "side" }] : /* istanbul ignore next */ []));
|
|
588
|
+
/**
|
|
589
|
+
* Alignment along the chosen `side`. When unset, defaults to `'start'`
|
|
590
|
+
* in LTR and `'end'` in RTL (per `dir`). Set explicitly to pin an
|
|
591
|
+
* alignment regardless of writing direction.
|
|
592
|
+
*
|
|
593
|
+
* The input is aliased to `align`; consumers bind `[align]="..."` and
|
|
594
|
+
* read the effective value via the public `align` computed below.
|
|
595
|
+
*/
|
|
596
|
+
_alignInput = input(undefined, { ...(ngDevMode ? { debugName: "_alignInput" } : /* istanbul ignore next */ {}), alias: 'align' });
|
|
597
|
+
align = computed(() => this._alignInput() ?? (this.dir() === 'rtl' ? 'end' : 'start'), /* @ts-ignore */
|
|
598
|
+
...(ngDevMode ? [{ debugName: "align" }] : /* istanbul ignore next */ []));
|
|
599
|
+
/** Gap (px) between input and listbox along the main axis. Default `4`. */
|
|
600
|
+
sideOffset = input(this.#defaults.sideOffset, { ...(ngDevMode ? { debugName: "sideOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
601
|
+
/** Gap (px) along the cross axis. Default `0`. */
|
|
602
|
+
alignOffset = input(0, { ...(ngDevMode ? { debugName: "alignOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
603
|
+
/** When `true` (default), `flip` and `shift` keep the listbox inside the viewport. */
|
|
604
|
+
avoidCollisions = input(true, { ...(ngDevMode ? { debugName: "avoidCollisions" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
605
|
+
/** Padding (px) applied uniformly to flip / shift / size. Default `8`. */
|
|
606
|
+
collisionPadding = input(this.#defaults.collisionPadding, { ...(ngDevMode ? { debugName: "collisionPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
607
|
+
/** Padding (px) for the `arrow` middleware. Default `0`. */
|
|
608
|
+
arrowPadding = input(0, { ...(ngDevMode ? { debugName: "arrowPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
|
|
609
|
+
/** Stickiness behaviour for `shift`. Default `'partial'`. */
|
|
610
|
+
sticky = input('partial', /* @ts-ignore */
|
|
611
|
+
...(ngDevMode ? [{ debugName: "sticky" }] : /* istanbul ignore next */ []));
|
|
612
|
+
/** When `true`, sets `data-detached=""` while the input is scrolled off-screen. */
|
|
613
|
+
hideWhenDetached = input(false, { ...(ngDevMode ? { debugName: "hideWhenDetached" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
614
|
+
/**
|
|
615
|
+
* When `true` (default), the content is clipped until floating-ui resolves
|
|
616
|
+
* its first position, preventing a flash at the viewport corner. Set to
|
|
617
|
+
* `false` so a dramatic `animate.enter` plays from its first frame (the
|
|
618
|
+
* surface may flash briefly at the unresolved position while positioning
|
|
619
|
+
* computes).
|
|
620
|
+
*/
|
|
621
|
+
clipUntilPositioned = input(true, { ...(ngDevMode ? { debugName: "clipUntilPositioned" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
622
|
+
loop = input(true, { ...(ngDevMode ? { debugName: "loop" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
623
|
+
/** When true (default), Escape, pointer-down outside, and focus outside close the listbox. */
|
|
624
|
+
dismissible = input(true, { ...(ngDevMode ? { debugName: "dismissible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
625
|
+
/**
|
|
626
|
+
* When true (default), focus returns to the `[forComboboxTrigger]` on close.
|
|
627
|
+
* Only relevant in the picker anatomy (a trigger is registered) — in the
|
|
628
|
+
* editable anatomy focus never leaves the input, so there is nothing to
|
|
629
|
+
* return.
|
|
630
|
+
*/
|
|
631
|
+
returnFocus = input(true, { ...(ngDevMode ? { debugName: "returnFocus" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
632
|
+
/** Manual `aria-label` on the listbox (`[forComboboxList]`, or `[forComboboxContent]` in the editable anatomy) when the input isn't a meaningful name. */
|
|
633
|
+
ariaLabel = input(null, /* @ts-ignore */
|
|
634
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
635
|
+
/**
|
|
636
|
+
* Total number of options in the consumer's source array. Set when wiring
|
|
637
|
+
* up a virtualized listbox (only the visible window is rendered) so the
|
|
638
|
+
* directive can reflect `aria-setsize` and walk the snapshot for
|
|
639
|
+
* navigation past the rendered range. Defaults to `undefined`, in which
|
|
640
|
+
* case the directive falls back to `options().length` (the live registry).
|
|
641
|
+
*/
|
|
642
|
+
totalCount = input(undefined, { ...(ngDevMode ? { debugName: "totalCount" } : /* istanbul ignore next */ {}), transform: (v) => (v == null ? undefined : numberAttribute(v)) });
|
|
643
|
+
/**
|
|
644
|
+
* Inclusive-exclusive `[start, end)` range of options currently rendered
|
|
645
|
+
* in the DOM. Used by `navigate()` to translate "move to absolute
|
|
646
|
+
* position N" into either an in-window highlight update or a request to
|
|
647
|
+
* the consumer to scroll N into view (`(scrollToIndex)`). When
|
|
648
|
+
* `undefined` (default), navigation assumes every option in the snapshot
|
|
649
|
+
* is rendered — appropriate for non-virtualized lists.
|
|
650
|
+
*/
|
|
651
|
+
visibleRange = input(undefined, /* @ts-ignore */
|
|
652
|
+
...(ngDevMode ? [{ debugName: "visibleRange" }] : /* istanbul ignore next */ []));
|
|
653
|
+
/**
|
|
654
|
+
* Emitted when keyboard navigation needs to land on an option whose
|
|
655
|
+
* absolute index falls outside `visibleRange()`. Wire this to the
|
|
656
|
+
* consumer's virtualizer (`scrollToIndex(idx)` on `@tanstack/virtual`,
|
|
657
|
+
* `virtua`, etc.); once the option mounts, the directive seeds
|
|
658
|
+
* `aria-activedescendant` automatically.
|
|
659
|
+
*/
|
|
660
|
+
scrollToIndex = output();
|
|
661
|
+
escapeKeyDown = output();
|
|
662
|
+
pointerDownOutside = output();
|
|
663
|
+
focusOutside = output();
|
|
664
|
+
interactOutside = output();
|
|
665
|
+
/**
|
|
666
|
+
* _(picker anatomy only)_ Fires just before focus moves into the input on
|
|
667
|
+
* open. Call `preventDefault()` on the emitted veto to skip the imperative
|
|
668
|
+
* focus move. Only emitted when a `[forComboboxTrigger]` is registered — the
|
|
669
|
+
* editable anatomy keeps focus in the input the whole time and has no move to
|
|
670
|
+
* veto.
|
|
671
|
+
*/
|
|
672
|
+
autoFocusOnOpen = output();
|
|
673
|
+
/**
|
|
674
|
+
* _(picker anatomy only)_ Fires just before focus returns to the trigger on
|
|
675
|
+
* close. Call `preventDefault()` on the veto to suppress the return-focus.
|
|
676
|
+
*/
|
|
677
|
+
autoFocusOnClose = output();
|
|
678
|
+
inputId = signal(this.#idGen.next('for-combobox-input'), /* @ts-ignore */
|
|
679
|
+
...(ngDevMode ? [{ debugName: "inputId" }] : /* istanbul ignore next */ []));
|
|
680
|
+
contentId = signal(this.#idGen.next('for-combobox-content'), /* @ts-ignore */
|
|
681
|
+
...(ngDevMode ? [{ debugName: "contentId" }] : /* istanbul ignore next */ []));
|
|
682
|
+
listId = signal(this.#idGen.next('for-combobox-list'), /* @ts-ignore */
|
|
683
|
+
...(ngDevMode ? [{ debugName: "listId" }] : /* istanbul ignore next */ []));
|
|
684
|
+
#inputEl = signal(null, /* @ts-ignore */
|
|
685
|
+
...(ngDevMode ? [{ debugName: "#inputEl" }] : /* istanbul ignore next */ []));
|
|
686
|
+
input = this.#inputEl.asReadonly();
|
|
687
|
+
#anchorEl = signal(null, /* @ts-ignore */
|
|
688
|
+
...(ngDevMode ? [{ debugName: "#anchorEl" }] : /* istanbul ignore next */ []));
|
|
689
|
+
#triggerEl = signal(null, /* @ts-ignore */
|
|
690
|
+
...(ngDevMode ? [{ debugName: "#triggerEl" }] : /* istanbul ignore next */ []));
|
|
691
|
+
trigger = this.#triggerEl.asReadonly();
|
|
692
|
+
/**
|
|
693
|
+
* Element floating-ui anchors the listbox against. Resolution order:
|
|
694
|
+
* explicit `[forComboboxAnchor]` → `[forComboboxTrigger]` (picker anatomy) →
|
|
695
|
+
* the input (editable anatomy fallback, so existing comboboxes keep their
|
|
696
|
+
* behavior). Decoupled from `input` so the input keeps driving
|
|
697
|
+
* `aria-controls`, `aria-activedescendant`, keyboard interaction, and its
|
|
698
|
+
* dismissal exemption regardless of where the listbox paints.
|
|
699
|
+
*/
|
|
700
|
+
anchor = computed(() => this.#anchorEl() ?? this.#triggerEl() ?? this.#inputEl(), /* @ts-ignore */
|
|
701
|
+
...(ngDevMode ? [{ debugName: "anchor" }] : /* istanbul ignore next */ []));
|
|
702
|
+
#contentEl = signal(null, /* @ts-ignore */
|
|
703
|
+
...(ngDevMode ? [{ debugName: "#contentEl" }] : /* istanbul ignore next */ []));
|
|
704
|
+
content = this.#contentEl.asReadonly();
|
|
705
|
+
#listEl = signal(null, /* @ts-ignore */
|
|
706
|
+
...(ngDevMode ? [{ debugName: "#listEl" }] : /* istanbul ignore next */ []));
|
|
707
|
+
list = this.#listEl.asReadonly();
|
|
708
|
+
/** True once a `[forComboboxList]` has registered (picker anatomy). */
|
|
709
|
+
hasList = computed(() => this.#listEl() !== null, /* @ts-ignore */
|
|
710
|
+
...(ngDevMode ? [{ debugName: "hasList" }] : /* istanbul ignore next */ []));
|
|
711
|
+
/**
|
|
712
|
+
* Id of the element carrying `role="listbox"`: the list when one is
|
|
713
|
+
* registered (picker anatomy), otherwise the content surface (editable
|
|
714
|
+
* anatomy). The input targets this with `aria-controls`.
|
|
715
|
+
*/
|
|
716
|
+
listboxId = computed(() => (this.hasList() ? this.listId() : this.contentId()), /* @ts-ignore */
|
|
717
|
+
...(ngDevMode ? [{ debugName: "listboxId" }] : /* istanbul ignore next */ []));
|
|
718
|
+
options = this.#items.items;
|
|
719
|
+
chips = this.#chips.items;
|
|
720
|
+
#initialFocus = signal('first', /* @ts-ignore */
|
|
721
|
+
...(ngDevMode ? [{ debugName: "#initialFocus" }] : /* istanbul ignore next */ []));
|
|
722
|
+
initialFocus = this.#initialFocus.asReadonly();
|
|
723
|
+
#lastCloseReason = signal(null, /* @ts-ignore */
|
|
724
|
+
...(ngDevMode ? [{ debugName: "#lastCloseReason" }] : /* istanbul ignore next */ []));
|
|
725
|
+
lastCloseReason = this.#lastCloseReason.asReadonly();
|
|
726
|
+
/**
|
|
727
|
+
* The activedescendant pointer. Built by {@link createActiveIdSignal} as a
|
|
728
|
+
* `linkedSignal` so the highlight decision is a pure derivation, never a write
|
|
729
|
+
* from an `effect`; it stays writable for the imperative moves that own their
|
|
730
|
+
* own scroll (arrow / Home / End navigation, hover, multi-mode activation, and
|
|
731
|
+
* the virtualized pending-nav resolution). The reset/seed rule lives with the
|
|
732
|
+
* factory in `combobox-auto-highlight.ts`.
|
|
733
|
+
*/
|
|
734
|
+
#activeId = createActiveIdSignal({
|
|
735
|
+
query: this.query,
|
|
736
|
+
open: this.open,
|
|
737
|
+
autoHighlight: this.autoHighlight,
|
|
738
|
+
virtualized: () => this.totalCount() !== undefined,
|
|
739
|
+
initialFocus: this.#initialFocus,
|
|
740
|
+
items: this.#items.items,
|
|
741
|
+
value: this.value,
|
|
742
|
+
equals: this.isItemEqualToValue,
|
|
743
|
+
});
|
|
744
|
+
activeId = this.#activeId.asReadonly();
|
|
745
|
+
#lastPositionedId = null;
|
|
746
|
+
#pointerSuppression = createPointerSuppression();
|
|
747
|
+
/**
|
|
748
|
+
* Always-on label cache — keeps `{ id, value, label }` tuples across
|
|
749
|
+
* close/open and scroll-out-of-view to drive inline autocomplete matching
|
|
750
|
+
* and the `selected` label fallback. See `combobox-label-cache.ts`.
|
|
751
|
+
*/
|
|
752
|
+
#labelCache = new OptionLabelCache({
|
|
753
|
+
items: this.#items.items,
|
|
754
|
+
totalCount: this.totalCount,
|
|
755
|
+
});
|
|
756
|
+
/**
|
|
757
|
+
* Virtualization navigation engine — constructed lazily the first time the
|
|
758
|
+
* consumer sets `totalCount()`, so a non-virtualized combobox never pulls
|
|
759
|
+
* the position-map machinery. Powers keyboard navigation past the rendered
|
|
760
|
+
* window and the scrolled-out-of-view label fallback.
|
|
761
|
+
*/
|
|
762
|
+
#navigator = null;
|
|
763
|
+
#requireNavigator() {
|
|
764
|
+
return (this.#navigator ??= new VirtualizedNavigator({
|
|
765
|
+
items: this.#items.items,
|
|
766
|
+
totalCount: this.totalCount,
|
|
767
|
+
visibleRange: this.visibleRange,
|
|
768
|
+
loop: this.loop,
|
|
769
|
+
getActiveId: () => this.#activeId(),
|
|
770
|
+
setActiveId: (id) => this.#activeId.set(id),
|
|
771
|
+
emitScrollToIndex: (idx) => this.scrollToIndex.emit(idx),
|
|
772
|
+
scrollActiveIntoView: (host) => this.#scrollActiveIntoView(host),
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
775
|
+
selected = computed(() => {
|
|
776
|
+
const values = this.value();
|
|
777
|
+
if (values.length === 0) {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
// `cachedOptions()` already merges off-window entries from the indexed
|
|
781
|
+
// snapshot when virtualizing, so a selected option scrolled out of view
|
|
782
|
+
// still resolves here without a separate position-map lookup.
|
|
783
|
+
const cached = this.cachedOptions();
|
|
784
|
+
const equals = this.isItemEqualToValue();
|
|
785
|
+
const toLabel = this.itemToStringLabel();
|
|
786
|
+
return values.map((v) => {
|
|
787
|
+
const opt = cached.find((o) => equals(o.value, v));
|
|
788
|
+
if (opt) {
|
|
789
|
+
return { value: v, label: opt.label };
|
|
790
|
+
}
|
|
791
|
+
// Fall back to the consumer-provided label fn so non-string items
|
|
792
|
+
// still render a meaningful string before the option cache warms up.
|
|
793
|
+
return { value: v, label: typeof v === 'string' ? v : toLabel(v) };
|
|
794
|
+
});
|
|
795
|
+
}, /* @ts-ignore */
|
|
796
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
797
|
+
/**
|
|
798
|
+
* Read-only single-select convenience view of {@link value}. Returns the
|
|
799
|
+
* sole selected item when exactly one is selected, otherwise `null` (empty
|
|
800
|
+
* selection, or multiple selections in `multiple` mode). Lets single-select
|
|
801
|
+
* consumers read `selectedItem()` instead of unwrapping `value()[0]`. The
|
|
802
|
+
* array-backed `value` model remains the source of truth and the
|
|
803
|
+
* `FormValueControl` contract; this is a derived accessor. Distinct from
|
|
804
|
+
* {@link selected}, which pairs every selected value with its resolved
|
|
805
|
+
* label for chip rendering.
|
|
806
|
+
*/
|
|
807
|
+
selectedItem = singleSelected(this.value);
|
|
808
|
+
fieldLabelledElement() {
|
|
809
|
+
return this.input();
|
|
810
|
+
}
|
|
811
|
+
fieldLabelledElementId() {
|
|
812
|
+
return this.inputId();
|
|
813
|
+
}
|
|
814
|
+
constructor() {
|
|
815
|
+
super();
|
|
816
|
+
injectHiddenInput({
|
|
817
|
+
name: this.name,
|
|
818
|
+
values: this.value,
|
|
819
|
+
serialize: (item) => this.itemToFormValue()(item),
|
|
820
|
+
disabled: this.effectiveDisabled,
|
|
821
|
+
});
|
|
822
|
+
// The activedescendant *decision* is a pure derivation in `#activeId`; this
|
|
823
|
+
// effect runs only its imperative tail (label-cache priming, virtualized
|
|
824
|
+
// pending-nav resolution + passive seed, non-virtualized scroll-into-view).
|
|
825
|
+
// Full rationale lives with `runAutoHighlightBridge` in
|
|
826
|
+
// `combobox-auto-highlight.ts`.
|
|
827
|
+
effect(() => {
|
|
828
|
+
runAutoHighlightBridge({
|
|
829
|
+
labelCache: this.#labelCache,
|
|
830
|
+
requireNavigator: () => this.#requireNavigator(),
|
|
831
|
+
items: this.#items.items,
|
|
832
|
+
open: this.open,
|
|
833
|
+
autoHighlight: this.autoHighlight,
|
|
834
|
+
virtualized: () => this.totalCount() !== undefined,
|
|
835
|
+
initialFocus: this.#initialFocus,
|
|
836
|
+
getActiveId: () => this.#activeId(),
|
|
837
|
+
getLastPositionedId: () => this.#lastPositionedId,
|
|
838
|
+
setLastPositionedId: (id) => (this.#lastPositionedId = id),
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
registerInput(el) {
|
|
843
|
+
adoptHostId(el, this.inputId);
|
|
844
|
+
this.#inputEl.set(el);
|
|
845
|
+
}
|
|
846
|
+
unregisterInput(el) {
|
|
847
|
+
if (this.#inputEl() === el) {
|
|
848
|
+
this.#inputEl.set(null);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
registerAnchor(el) {
|
|
852
|
+
const current = this.#anchorEl();
|
|
853
|
+
if (current !== null && current !== el) {
|
|
854
|
+
throw new Error('[forty-cdk/combobox] Multiple [forComboboxAnchor] inside the same [forCombobox]; only one is allowed.');
|
|
855
|
+
}
|
|
856
|
+
this.#anchorEl.set(el);
|
|
857
|
+
}
|
|
858
|
+
unregisterAnchor(el) {
|
|
859
|
+
if (this.#anchorEl() === el) {
|
|
860
|
+
this.#anchorEl.set(null);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
registerTrigger(el) {
|
|
864
|
+
this.#triggerEl.set(el);
|
|
865
|
+
}
|
|
866
|
+
unregisterTrigger(el) {
|
|
867
|
+
if (this.#triggerEl() === el) {
|
|
868
|
+
this.#triggerEl.set(null);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
registerContent(el) {
|
|
872
|
+
adoptHostId(el, this.contentId);
|
|
873
|
+
this.#contentEl.set(el);
|
|
874
|
+
}
|
|
875
|
+
unregisterContent(el) {
|
|
876
|
+
if (this.#contentEl() === el) {
|
|
877
|
+
this.#contentEl.set(null);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
registerList(el) {
|
|
881
|
+
adoptHostId(el, this.listId);
|
|
882
|
+
this.#listEl.set(el);
|
|
883
|
+
}
|
|
884
|
+
unregisterList(el) {
|
|
885
|
+
if (this.#listEl() === el) {
|
|
886
|
+
this.#listEl.set(null);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
registerOption(handle) {
|
|
890
|
+
this.#items.register(handle);
|
|
891
|
+
}
|
|
892
|
+
unregisterOption(handle) {
|
|
893
|
+
this.#items.unregister(handle);
|
|
894
|
+
if (this.#activeId() === handle.id()) {
|
|
895
|
+
this.#activeId.set(null);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
registerChip(handle) {
|
|
899
|
+
this.#chips.register(handle);
|
|
900
|
+
}
|
|
901
|
+
unregisterChip(handle) {
|
|
902
|
+
this.#chips.unregister(handle);
|
|
903
|
+
}
|
|
904
|
+
isSelected(v) {
|
|
905
|
+
return isInArray(this.value(), v, this.isItemEqualToValue());
|
|
906
|
+
}
|
|
907
|
+
isActive(id) {
|
|
908
|
+
return this.#activeId() === id;
|
|
909
|
+
}
|
|
910
|
+
isPointerSuppressed() {
|
|
911
|
+
return this.#pointerSuppression.isSuppressed();
|
|
912
|
+
}
|
|
913
|
+
activate(handle) {
|
|
914
|
+
if (this.effectiveDisabled() || this.readonly() || handle.disabled()) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const v = handle.value();
|
|
918
|
+
if (this.multiple()) {
|
|
919
|
+
// Toggle in/out of the array. Stay open so the user can keep picking.
|
|
920
|
+
this.value.set(toggleInArray(this.value(), v, this.isItemEqualToValue()));
|
|
921
|
+
if (this.commitOnSelect()) {
|
|
922
|
+
// Reset query so the next typed prefix searches afresh.
|
|
923
|
+
this.query.set('');
|
|
924
|
+
this.#syncInputValue('');
|
|
925
|
+
}
|
|
926
|
+
this.#activeId.set(handle.id());
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
// Single mode: replace + close + commit label.
|
|
930
|
+
this.value.set([v]);
|
|
931
|
+
if (this.commitOnSelect() && this.trigger() === null) {
|
|
932
|
+
this.query.set(handle.label());
|
|
933
|
+
this.#syncInputValue(handle.label());
|
|
934
|
+
}
|
|
935
|
+
this.closeMenu('select');
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Imperatively align the visible `<input>` text with `query()` even
|
|
939
|
+
* while the input has focus. The directive's reactive sync skips
|
|
940
|
+
* focused syncs to avoid clobbering inline-autocomplete selection
|
|
941
|
+
* during typing — but activation / clear writes happen *outside* the
|
|
942
|
+
* typing flow and should be visible immediately.
|
|
943
|
+
*/
|
|
944
|
+
#syncInputValue(value) {
|
|
945
|
+
const el = this.#inputEl();
|
|
946
|
+
if (el && el.value !== value) {
|
|
947
|
+
el.value = value;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
removeValue(v) {
|
|
951
|
+
if (this.effectiveDisabled() || this.readonly()) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const equals = this.isItemEqualToValue();
|
|
955
|
+
const current = this.value();
|
|
956
|
+
if (!current.some((x) => equals(x, v))) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
this.value.set(current.filter((x) => !equals(x, v)));
|
|
960
|
+
}
|
|
961
|
+
activateActive() {
|
|
962
|
+
const id = this.#activeId();
|
|
963
|
+
if (!id) {
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
const handle = this.#items.items().find((o) => o.id() === id);
|
|
967
|
+
if (!handle || handle.disabled()) {
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
this.activate(handle);
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
navigate(direction) {
|
|
974
|
+
if (this.effectiveDisabled()) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (this.totalCount() !== undefined) {
|
|
978
|
+
this.#requireNavigator().navigate(direction);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const items = this.#items.items();
|
|
982
|
+
if (items.length === 0) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const currentId = this.#activeId();
|
|
986
|
+
const currentIndex = currentId === null ? -1 : items.findIndex((o) => o.id() === currentId);
|
|
987
|
+
// No activedescendant yet + arrow nav: jump to the natural extreme
|
|
988
|
+
// (ArrowDown → first enabled, ArrowUp → last enabled).
|
|
989
|
+
let action = direction;
|
|
990
|
+
if (currentIndex < 0 && direction === 'next') {
|
|
991
|
+
action = 'first';
|
|
992
|
+
}
|
|
993
|
+
else if (currentIndex < 0 && direction === 'prev') {
|
|
994
|
+
action = 'last';
|
|
995
|
+
}
|
|
996
|
+
const target = nextEnabledHandle(items, currentIndex, action, { loop: this.loop() });
|
|
997
|
+
if (target === null) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
this.#activeId.set(target.id());
|
|
1001
|
+
this.#scrollActiveIntoView(target.host);
|
|
1002
|
+
this.#lastPositionedId = target.id();
|
|
1003
|
+
}
|
|
1004
|
+
setQueryFromInput(query) {
|
|
1005
|
+
if (this.effectiveDisabled() || this.readonly()) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
this.query.set(query);
|
|
1009
|
+
if (this.clearOnQueryChange() && !this.multiple() && this.value().length > 0) {
|
|
1010
|
+
this.value.set([]);
|
|
1011
|
+
}
|
|
1012
|
+
const mode = this.autocompleteMode();
|
|
1013
|
+
const hasListbox = mode === 'list' || mode === 'both';
|
|
1014
|
+
if (this.openOnQuery() && hasListbox && query.length > 0 && !this.open()) {
|
|
1015
|
+
this.openMenu();
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
setActiveId(id) {
|
|
1019
|
+
this.#activeId.set(id);
|
|
1020
|
+
this.#lastPositionedId = id;
|
|
1021
|
+
}
|
|
1022
|
+
#scrollActiveIntoView(host) {
|
|
1023
|
+
this.#pointerSuppression.suppress();
|
|
1024
|
+
host.scrollIntoView?.({ block: 'nearest' });
|
|
1025
|
+
}
|
|
1026
|
+
#cachedOptionsMemo = computed(() => {
|
|
1027
|
+
if (this.totalCount() === undefined) {
|
|
1028
|
+
return this.#labelCache.entries();
|
|
1029
|
+
}
|
|
1030
|
+
return this.#labelCache.mergedEntries(this.#requireNavigator().snapshotByPos());
|
|
1031
|
+
}, /* @ts-ignore */
|
|
1032
|
+
...(ngDevMode ? [{ debugName: "#cachedOptionsMemo" }] : /* istanbul ignore next */ []));
|
|
1033
|
+
cachedOptions() {
|
|
1034
|
+
return this.#cachedOptionsMemo();
|
|
1035
|
+
}
|
|
1036
|
+
clear(clearQuery = true) {
|
|
1037
|
+
if (this.effectiveDisabled() || this.readonly()) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
this.value.set([]);
|
|
1041
|
+
if (clearQuery) {
|
|
1042
|
+
this.query.set('');
|
|
1043
|
+
this.#syncInputValue('');
|
|
1044
|
+
}
|
|
1045
|
+
this.#activeId.set(null);
|
|
1046
|
+
}
|
|
1047
|
+
setInitialFocus(target) {
|
|
1048
|
+
this.#initialFocus.set(target);
|
|
1049
|
+
}
|
|
1050
|
+
toggle() {
|
|
1051
|
+
if (this.effectiveDisabled()) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (this.open()) {
|
|
1055
|
+
this.closeMenu('programmatic');
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
this.openMenu(this.trigger() !== null ? 'selected' : 'first');
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
openMenu(initialFocus = 'first') {
|
|
1062
|
+
if (this.effectiveDisabled() || this.open()) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
this.#initialFocus.set(initialFocus);
|
|
1066
|
+
this.#lastCloseReason.set(null);
|
|
1067
|
+
this.open.set(true);
|
|
1068
|
+
}
|
|
1069
|
+
closeMenu(reason) {
|
|
1070
|
+
if (!this.open()) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
this.#lastCloseReason.set(reason);
|
|
1074
|
+
this.open.set(false);
|
|
1075
|
+
this.#activeId.set(null);
|
|
1076
|
+
this.#navigator?.resetPending();
|
|
1077
|
+
if (this.trigger() !== null) {
|
|
1078
|
+
this.query.set('');
|
|
1079
|
+
this.#syncInputValue('');
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/** Fire `(autoFocusOnOpen)` and report whether the consumer vetoed the focus move. */
|
|
1083
|
+
emitAutoFocusOnOpen() {
|
|
1084
|
+
return emitVetoableEvent(this.autoFocusOnOpen);
|
|
1085
|
+
}
|
|
1086
|
+
/** Fire `(autoFocusOnClose)` and report whether the consumer vetoed the return-focus. */
|
|
1087
|
+
emitAutoFocusOnClose() {
|
|
1088
|
+
return emitVetoableEvent(this.autoFocusOnClose);
|
|
1089
|
+
}
|
|
1090
|
+
emitEscapeKeyDown(event) {
|
|
1091
|
+
const vetoed = emitVetoableNativeEvent(this.escapeKeyDown, event);
|
|
1092
|
+
if (!vetoed && this.dismissible()) {
|
|
1093
|
+
event.stopPropagation();
|
|
1094
|
+
this.closeMenu('escape');
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Outside-interaction emit forwarders. The shared `#pendingOutsideVeto`
|
|
1099
|
+
* reuse between the specific outside channels and the composite
|
|
1100
|
+
* `interactOutside` lives in `injectOverlayShell`; these only fire the
|
|
1101
|
+
* matching output with the veto the shell built.
|
|
1102
|
+
*/
|
|
1103
|
+
emitPointerDownOutside(veto) {
|
|
1104
|
+
this.pointerDownOutside.emit(veto);
|
|
1105
|
+
}
|
|
1106
|
+
emitFocusOutside(veto) {
|
|
1107
|
+
this.focusOutside.emit(veto);
|
|
1108
|
+
}
|
|
1109
|
+
emitInteractOutside(veto) {
|
|
1110
|
+
this.interactOutside.emit(veto);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Implicit close requested by the shell after an un-vetoed outside
|
|
1114
|
+
* interaction. Marks the control touched and closes with the channel's
|
|
1115
|
+
* reason.
|
|
1116
|
+
*/
|
|
1117
|
+
requestClose(reason) {
|
|
1118
|
+
this.markTouched();
|
|
1119
|
+
this.closeMenu(reason);
|
|
1120
|
+
}
|
|
1121
|
+
markTouched() {
|
|
1122
|
+
super.markTouched();
|
|
1123
|
+
}
|
|
1124
|
+
onFocusOut(event) {
|
|
1125
|
+
const next = event.relatedTarget;
|
|
1126
|
+
const inputEl = this.#inputEl();
|
|
1127
|
+
const content = this.#contentEl();
|
|
1128
|
+
if (next) {
|
|
1129
|
+
if (inputEl && inputEl.contains(next)) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (content && content.contains(next)) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// Focus moving to a chip (multi mode) is "inside" — chips are an
|
|
1136
|
+
// extension of the input area, not an outside boundary.
|
|
1137
|
+
const chips = this.#chips.items();
|
|
1138
|
+
for (const chip of chips) {
|
|
1139
|
+
if (chip.host.contains(next)) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
this.markTouched();
|
|
1145
|
+
}
|
|
1146
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForCombobox, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1147
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForCombobox, isStandalone: true, selector: "[forCombobox]", inputs: { query: { classPropertyName: "query", publicName: "query", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, isItemEqualToValue: { classPropertyName: "isItemEqualToValue", publicName: "isItemEqualToValue", isSignal: true, isRequired: false, transformFunction: null }, itemToStringLabel: { classPropertyName: "itemToStringLabel", publicName: "itemToStringLabel", isSignal: true, isRequired: false, transformFunction: null }, itemToFormValue: { classPropertyName: "itemToFormValue", publicName: "itemToFormValue", 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 }, autocompleteMode: { classPropertyName: "autocompleteMode", publicName: "autocompleteMode", isSignal: true, isRequired: false, transformFunction: null }, openOnFocus: { classPropertyName: "openOnFocus", publicName: "openOnFocus", isSignal: true, isRequired: false, transformFunction: null }, openOnQuery: { classPropertyName: "openOnQuery", publicName: "openOnQuery", isSignal: true, isRequired: false, transformFunction: null }, commitOnSelect: { classPropertyName: "commitOnSelect", publicName: "commitOnSelect", isSignal: true, isRequired: false, transformFunction: null }, clearOnQueryChange: { classPropertyName: "clearOnQueryChange", publicName: "clearOnQueryChange", isSignal: true, isRequired: false, transformFunction: null }, autoHighlight: { classPropertyName: "autoHighlight", publicName: "autoHighlight", isSignal: true, isRequired: false, transformFunction: null }, _dirInput: { classPropertyName: "_dirInput", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, _alignInput: { classPropertyName: "_alignInput", 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 }, 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 }, totalCount: { classPropertyName: "totalCount", publicName: "totalCount", isSignal: true, isRequired: false, transformFunction: null }, visibleRange: { classPropertyName: "visibleRange", publicName: "visibleRange", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { query: "queryChange", value: "valueChange", open: "openChange", scrollToIndex: "scrollToIndex", escapeKeyDown: "escapeKeyDown", pointerDownOutside: "pointerDownOutside", focusOutside: "focusOutside", interactOutside: "interactOutside", autoFocusOnOpen: "autoFocusOnOpen", autoFocusOnClose: "autoFocusOnClose" }, host: { listeners: { "focusout": "onFocusOut($event)" }, properties: { "attr.data-state": "open() ? \"open\" : \"closed\"", "attr.data-disabled": "effectiveDisabled() ? \"\" : null", "attr.dir": "dir()" } }, providers: [{ provide: FOR_COMBOBOX_CONTEXT, useExisting: ForCombobox }], exportAs: ["forCombobox"], usesInheritance: true, ngImport: i0 });
|
|
1148
|
+
}
|
|
1149
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForCombobox, decorators: [{
|
|
1150
|
+
type: Directive,
|
|
1151
|
+
args: [{
|
|
1152
|
+
selector: '[forCombobox]',
|
|
1153
|
+
exportAs: 'forCombobox',
|
|
1154
|
+
host: {
|
|
1155
|
+
'[attr.data-state]': 'open() ? "open" : "closed"',
|
|
1156
|
+
'[attr.data-disabled]': 'effectiveDisabled() ? "" : null',
|
|
1157
|
+
'[attr.dir]': 'dir()',
|
|
1158
|
+
'(focusout)': 'onFocusOut($event)',
|
|
1159
|
+
},
|
|
1160
|
+
providers: [{ provide: FOR_COMBOBOX_CONTEXT, useExisting: ForCombobox }],
|
|
1161
|
+
}]
|
|
1162
|
+
}], ctorParameters: () => [], propDecorators: { query: [{ type: i0.Input, args: [{ isSignal: true, alias: "query", required: false }] }, { type: i0.Output, args: ["queryChange"] }], 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 }] }], itemToStringLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToStringLabel", required: false }] }], itemToFormValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToFormValue", 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 }] }], autocompleteMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "autocompleteMode", required: false }] }], openOnFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "openOnFocus", required: false }] }], openOnQuery: [{ type: i0.Input, args: [{ isSignal: true, alias: "openOnQuery", required: false }] }], commitOnSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "commitOnSelect", required: false }] }], clearOnQueryChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearOnQueryChange", required: false }] }], autoHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoHighlight", required: false }] }], _dirInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], _alignInput: [{ 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 }] }], 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 }] }], 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"] }], 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"] }] } });
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Optional positioning anchor. When present, `[forComboboxContent]` is
|
|
1166
|
+
* positioned against this element instead of `[forComboboxInput]` — useful
|
|
1167
|
+
* when the input lives inside a decorated field box (padding, prefix icon,
|
|
1168
|
+
* clear button, chip cluster) and the listbox should match the visible field
|
|
1169
|
+
* rather than the inner `<input>`.
|
|
1170
|
+
*
|
|
1171
|
+
* Only positioning changes: the input still owns `aria-controls`,
|
|
1172
|
+
* `aria-expanded`, `aria-activedescendant`, keyboard interaction, and its
|
|
1173
|
+
* exemption from outside-pointer dismissal. If no anchor is registered the
|
|
1174
|
+
* listbox falls back to anchoring against the input, so existing usages are
|
|
1175
|
+
* unaffected.
|
|
1176
|
+
*
|
|
1177
|
+
* At most one `[forComboboxAnchor]` may be registered per `[forCombobox]`; a
|
|
1178
|
+
* second one throws.
|
|
1179
|
+
*
|
|
1180
|
+
* ```html
|
|
1181
|
+
* <div forCombobox [(value)]="v" [(query)]="q">
|
|
1182
|
+
* <div forComboboxAnchor class="field-box">
|
|
1183
|
+
* <icon />
|
|
1184
|
+
* <input forComboboxInput />
|
|
1185
|
+
* <button class="clear">×</button>
|
|
1186
|
+
* </div>
|
|
1187
|
+
* @if (open()) {
|
|
1188
|
+
* <div forComboboxContent>…</div>
|
|
1189
|
+
* }
|
|
1190
|
+
* </div>
|
|
1191
|
+
* ```
|
|
1192
|
+
*/
|
|
1193
|
+
class ForComboboxAnchor {
|
|
1194
|
+
#ctx = injectComboboxContext('ForComboboxAnchor');
|
|
1195
|
+
#host = inject(ElementRef);
|
|
1196
|
+
constructor() {
|
|
1197
|
+
registerHandle(this.#host.nativeElement, (el) => this.#ctx.registerAnchor(el), (el) => this.#ctx.unregisterAnchor(el));
|
|
1198
|
+
}
|
|
1199
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxAnchor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1200
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxAnchor, isStandalone: true, selector: "[forComboboxAnchor]", exportAs: ["forComboboxAnchor"], ngImport: i0 });
|
|
1201
|
+
}
|
|
1202
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxAnchor, decorators: [{
|
|
1203
|
+
type: Directive,
|
|
1204
|
+
args: [{
|
|
1205
|
+
selector: '[forComboboxAnchor]',
|
|
1206
|
+
exportAs: 'forComboboxAnchor',
|
|
1207
|
+
}]
|
|
1208
|
+
}], ctorParameters: () => [] });
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Button that opens the listbox and keeps showing the committed selection
|
|
1212
|
+
* (label + icon) while the search input lives **inside** the panel — the
|
|
1213
|
+
* "combobox with trigger" / picker anatomy. Apply on
|
|
1214
|
+
* a real `<button>` so Space / Enter dispatch native click events that toggle
|
|
1215
|
+
* via `(click)`.
|
|
1216
|
+
*
|
|
1217
|
+
* Registering a trigger switches the combobox into the picker anatomy: it
|
|
1218
|
+
* becomes the default positioning anchor (after an explicit
|
|
1219
|
+
* `[forComboboxAnchor]`), focus moves into `[forComboboxInput]` on open and
|
|
1220
|
+
* returns to the trigger on close (the standard `(autoFocusOnOpen)` /
|
|
1221
|
+
* `(autoFocusOnClose)` vetoable hooks now fire), and `[forComboboxContent]`
|
|
1222
|
+
* expects an inner `[forComboboxList]` to carry the listbox role.
|
|
1223
|
+
*
|
|
1224
|
+
* Wires `aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls`
|
|
1225
|
+
* (pointing to the popup surface while open). Native `disabled` reflects
|
|
1226
|
+
* `effectiveDisabled` (single-purpose trigger). The trigger is exempt from the
|
|
1227
|
+
* popup's dismissable layer — clicks on it route through `(click)` instead of
|
|
1228
|
+
* racing an outside-pointer dismissal.
|
|
1229
|
+
*
|
|
1230
|
+
* The root is normally resolved via DI from the enclosing `[forCombobox]`.
|
|
1231
|
+
* When the trigger is declared inside an `ng-template` stamped into the root
|
|
1232
|
+
* (e.g. via `ngTemplateOutlet`), DI resolves at the template's declaration
|
|
1233
|
+
* site and misses the root — pass it explicitly through the selector input,
|
|
1234
|
+
* `routerLink`-style: `[forComboboxTrigger]="root"` with `#root="forCombobox"`.
|
|
1235
|
+
*
|
|
1236
|
+
* Keyboard:
|
|
1237
|
+
* - **Click / Enter / Space** — toggle (open moves focus into the input).
|
|
1238
|
+
* - **ArrowDown** — open with the first enabled option highlighted.
|
|
1239
|
+
* - **ArrowUp** — open with the last enabled option highlighted.
|
|
1240
|
+
*/
|
|
1241
|
+
class ForComboboxTrigger {
|
|
1242
|
+
#host = inject(ElementRef);
|
|
1243
|
+
/**
|
|
1244
|
+
* Optional explicit reference to the `[forCombobox]` root, named after the
|
|
1245
|
+
* selector `routerLink`-style. The bare valueless attribute keeps resolving
|
|
1246
|
+
* the enclosing root via DI; pass the root explicitly
|
|
1247
|
+
* (`[forComboboxTrigger]="root"`, with `#root="forCombobox"`) when the
|
|
1248
|
+
* trigger is declared in an `ng-template` stamped inside the root — DI
|
|
1249
|
+
* resolves at the template's declaration site, so the enclosing root is
|
|
1250
|
+
* invisible there. The empty string (what the valueless attribute yields) is
|
|
1251
|
+
* treated as unset.
|
|
1252
|
+
*/
|
|
1253
|
+
forComboboxTrigger = input('', /* @ts-ignore */
|
|
1254
|
+
...(ngDevMode ? [{ debugName: "forComboboxTrigger" }] : /* istanbul ignore next */ []));
|
|
1255
|
+
ctx = injectComboboxTriggerContext(this.forComboboxTrigger);
|
|
1256
|
+
constructor() {
|
|
1257
|
+
const el = this.#host.nativeElement;
|
|
1258
|
+
// Registration is an imperative call into the resolved root's registry,
|
|
1259
|
+
// not state derivation — the effect only re-registers the element when the
|
|
1260
|
+
// resolved root changes (explicit reference swapped at runtime).
|
|
1261
|
+
effect((onCleanup) => {
|
|
1262
|
+
const ctx = this.ctx();
|
|
1263
|
+
ctx.registerTrigger(el);
|
|
1264
|
+
onCleanup(() => ctx.unregisterTrigger(el));
|
|
1265
|
+
});
|
|
1266
|
+
reflectDisabled(computed(() => this.ctx().effectiveDisabled()));
|
|
1267
|
+
}
|
|
1268
|
+
onClick() {
|
|
1269
|
+
this.ctx().toggle();
|
|
1270
|
+
}
|
|
1271
|
+
onKeyDown(event) {
|
|
1272
|
+
if (this.ctx().effectiveDisabled()) {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (event.key === 'ArrowDown') {
|
|
1276
|
+
event.preventDefault();
|
|
1277
|
+
this.ctx().openMenu('first');
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (event.key === 'ArrowUp') {
|
|
1281
|
+
event.preventDefault();
|
|
1282
|
+
this.ctx().openMenu('last');
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1286
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForComboboxTrigger, isStandalone: true, selector: "[forComboboxTrigger]", inputs: { forComboboxTrigger: { classPropertyName: "forComboboxTrigger", publicName: "forComboboxTrigger", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "type": "button" }, listeners: { "click": "onClick()", "keydown": "onKeyDown($event)" }, properties: { "attr.aria-haspopup": "\"listbox\"", "attr.aria-expanded": "ctx().open() ? \"true\" : \"false\"", "attr.aria-controls": "ctx().open() ? ctx().contentId() : null", "attr.data-state": "ctx().open() ? \"open\" : \"closed\"", "attr.data-disabled": "ctx().effectiveDisabled() ? \"\" : null" } }, exportAs: ["forComboboxTrigger"], ngImport: i0 });
|
|
1287
|
+
}
|
|
1288
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxTrigger, decorators: [{
|
|
1289
|
+
type: Directive,
|
|
1290
|
+
args: [{
|
|
1291
|
+
selector: '[forComboboxTrigger]',
|
|
1292
|
+
exportAs: 'forComboboxTrigger',
|
|
1293
|
+
host: {
|
|
1294
|
+
type: 'button',
|
|
1295
|
+
'[attr.aria-haspopup]': '"listbox"',
|
|
1296
|
+
'[attr.aria-expanded]': 'ctx().open() ? "true" : "false"',
|
|
1297
|
+
'[attr.aria-controls]': 'ctx().open() ? ctx().contentId() : null',
|
|
1298
|
+
'[attr.data-state]': 'ctx().open() ? "open" : "closed"',
|
|
1299
|
+
'[attr.data-disabled]': 'ctx().effectiveDisabled() ? "" : null',
|
|
1300
|
+
'(click)': 'onClick()',
|
|
1301
|
+
'(keydown)': 'onKeyDown($event)',
|
|
1302
|
+
},
|
|
1303
|
+
}]
|
|
1304
|
+
}], ctorParameters: () => [], propDecorators: { forComboboxTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "forComboboxTrigger", required: false }] }] } });
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* The combobox `<input role="combobox">`. Owns the visible text and all
|
|
1308
|
+
* keyboard interaction. Apply on a real `<input>` so the browser's native
|
|
1309
|
+
* caret / selection semantics drive inline-autocomplete and so the form
|
|
1310
|
+
* still behaves like a text field for screen readers.
|
|
1311
|
+
*
|
|
1312
|
+
* Wires `aria-haspopup="listbox"`, `aria-expanded`, `aria-controls`,
|
|
1313
|
+
* `aria-autocomplete`, and `aria-activedescendant` (per APG combobox).
|
|
1314
|
+
* Focus stays on the input — arrow keys move the listbox's *active
|
|
1315
|
+
* descendant* via id reference, never moving DOM focus into the listbox.
|
|
1316
|
+
*
|
|
1317
|
+
* Keyboard:
|
|
1318
|
+
* - **ArrowDown / ArrowUp** — open + move activedescendant.
|
|
1319
|
+
* - **Home / End** (when open) — first / last enabled option.
|
|
1320
|
+
* - **PageUp / PageDown** (when open) — first / last enabled option.
|
|
1321
|
+
* - **Enter** (when open) — activate the activedescendant; no-op otherwise.
|
|
1322
|
+
* - **Escape** (when open) — close (focus stays in input). On the
|
|
1323
|
+
* open→closed transition the input value is re-synced to `query()` even
|
|
1324
|
+
* while focused, so a consumer restoring the committed label from an
|
|
1325
|
+
* `(openChange)` handler renders without reaching for the DOM.
|
|
1326
|
+
* - **Tab** (when open) — close and let Tab flow to the next focusable.
|
|
1327
|
+
* - Printable keys: update `query` and (if `autocompleteMode` includes `'inline'`)
|
|
1328
|
+
* complete the rest of the first match in the input as selected text.
|
|
1329
|
+
*/
|
|
1330
|
+
class ForComboboxInput {
|
|
1331
|
+
// The input doesn't introspect the value type — it reads/writes the
|
|
1332
|
+
// query string and reads cached labels for inline autocomplete.
|
|
1333
|
+
ctx = injectComboboxContext('ForComboboxInput');
|
|
1334
|
+
#host = inject(ElementRef);
|
|
1335
|
+
#composing = false;
|
|
1336
|
+
ariaAutocomplete = computed(() => this.ctx.autocompleteMode(), /* @ts-ignore */
|
|
1337
|
+
...(ngDevMode ? [{ debugName: "ariaAutocomplete" }] : /* istanbul ignore next */ []));
|
|
1338
|
+
constructor() {
|
|
1339
|
+
registerHandle(this.#host.nativeElement, (el) => this.ctx.registerInput(el), (el) => this.ctx.unregisterInput(el));
|
|
1340
|
+
reflectDisabled(this.ctx.effectiveDisabled);
|
|
1341
|
+
const doc = inject(DOCUMENT);
|
|
1342
|
+
// Sync DOM input.value to query() when the input doesn't currently have
|
|
1343
|
+
// focus (e.g. consumer set [(query)] programmatically, or option
|
|
1344
|
+
// activation copied the label into query via commitOnSelect). When the
|
|
1345
|
+
// input is focused the user is mid-edit; clobbering input.value would
|
|
1346
|
+
// jump the caret and break inline autocomplete state.
|
|
1347
|
+
//
|
|
1348
|
+
// The open→closed transition is the documented exception: the edit
|
|
1349
|
+
// session is over (Escape closes but keeps focus in the input, per APG),
|
|
1350
|
+
// so a consumer restoring the committed label — `query.set(label)` from an
|
|
1351
|
+
// `(openChange)` handler — must reach the DOM even while focused. The
|
|
1352
|
+
// caret-protection rationale no longer applies and a caret jump to the end
|
|
1353
|
+
// of the restored text is the expected outcome. `wasOpen` tracks the prior
|
|
1354
|
+
// open state to detect the edge; it's a plain transition latch, not derived
|
|
1355
|
+
// signal state.
|
|
1356
|
+
let wasOpen = this.ctx.open();
|
|
1357
|
+
effect(() => {
|
|
1358
|
+
const q = this.ctx.query();
|
|
1359
|
+
const closing = wasOpen && !this.ctx.open();
|
|
1360
|
+
wasOpen = this.ctx.open();
|
|
1361
|
+
const el = this.#host.nativeElement;
|
|
1362
|
+
if (el.value === q) {
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (doc.activeElement !== el || closing) {
|
|
1366
|
+
el.value = q;
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
onCompositionStart() {
|
|
1371
|
+
this.#composing = true;
|
|
1372
|
+
}
|
|
1373
|
+
onCompositionEnd() {
|
|
1374
|
+
this.#composing = false;
|
|
1375
|
+
if (this.ctx.effectiveDisabled() || this.ctx.readonly()) {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
this.#syncQuery(true, false);
|
|
1379
|
+
}
|
|
1380
|
+
onInput(event) {
|
|
1381
|
+
if (this.ctx.effectiveDisabled() || this.ctx.readonly()) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (this.#composing) {
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const inputEvent = event;
|
|
1388
|
+
const allowInline = !inputEvent.isComposing;
|
|
1389
|
+
const isDelete = (inputEvent.inputType ?? '').startsWith('delete');
|
|
1390
|
+
this.#syncQuery(allowInline, isDelete);
|
|
1391
|
+
}
|
|
1392
|
+
#syncQuery(allowInline, isDelete) {
|
|
1393
|
+
const el = this.#host.nativeElement;
|
|
1394
|
+
// The "user prefix" is everything before the caret. After inline
|
|
1395
|
+
// autocomplete sets a selection on chars [prefixLen..end], typing replaces
|
|
1396
|
+
// the selection (caret lands right after the new char) so this still
|
|
1397
|
+
// captures the user's intent. On Backspace, the browser deletes the
|
|
1398
|
+
// selection without touching the prefix — re-completing here would mean
|
|
1399
|
+
// the user can never shorten the query, so we skip completion on delete.
|
|
1400
|
+
const caret = el.selectionStart ?? el.value.length;
|
|
1401
|
+
const prefix = el.value.slice(0, caret);
|
|
1402
|
+
const atEnd = caret === el.value.length;
|
|
1403
|
+
const mode = this.ctx.autocompleteMode();
|
|
1404
|
+
const inlineActive = allowInline &&
|
|
1405
|
+
!isDelete &&
|
|
1406
|
+
atEnd &&
|
|
1407
|
+
(mode === 'inline' || mode === 'both') &&
|
|
1408
|
+
prefix.length > 0;
|
|
1409
|
+
if (inlineActive) {
|
|
1410
|
+
this.ctx.setQueryFromInput(prefix);
|
|
1411
|
+
this.#applyInlineCompletion(prefix);
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
this.ctx.setQueryFromInput(el.value);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
#applyInlineCompletion(prefix) {
|
|
1418
|
+
const cached = this.ctx.cachedOptions();
|
|
1419
|
+
const lower = prefix.toLowerCase();
|
|
1420
|
+
const match = cached.find((o) => o.label.toLowerCase().startsWith(lower));
|
|
1421
|
+
const el = this.#host.nativeElement;
|
|
1422
|
+
if (!match || match.label.toLowerCase() === lower) {
|
|
1423
|
+
// No completion to apply — strip any prior selection so the user's
|
|
1424
|
+
// prefix is plainly visible.
|
|
1425
|
+
if (el.value !== prefix) {
|
|
1426
|
+
el.value = prefix;
|
|
1427
|
+
}
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Use the option's full label as the visible text (preserving its case),
|
|
1431
|
+
// with chars [prefixLen..end] selected so the next keystroke replaces
|
|
1432
|
+
// the appended suggestion. Matches native browser autocomplete behavior.
|
|
1433
|
+
const composed = match.label;
|
|
1434
|
+
el.value = composed;
|
|
1435
|
+
el.setSelectionRange(prefix.length, composed.length);
|
|
1436
|
+
}
|
|
1437
|
+
onKeyDown(event) {
|
|
1438
|
+
if (this.ctx.effectiveDisabled()) {
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
// Multi-mode Backspace heuristic: an empty input sends focus to the
|
|
1442
|
+
// last chip so a second Backspace there removes it. Skip when there's a
|
|
1443
|
+
// non-empty selection in the input (the user's mid-edit) — Backspace falls
|
|
1444
|
+
// through to the native delete-char handler.
|
|
1445
|
+
if (event.key === 'Backspace' &&
|
|
1446
|
+
this.ctx.multiple() &&
|
|
1447
|
+
this.#host.nativeElement.value === '' &&
|
|
1448
|
+
!this.ctx.readonly() &&
|
|
1449
|
+
!this.ctx.effectiveDisabled()) {
|
|
1450
|
+
const chips = this.ctx.chips();
|
|
1451
|
+
const last = chips[chips.length - 1];
|
|
1452
|
+
if (last) {
|
|
1453
|
+
event.preventDefault();
|
|
1454
|
+
last.host.focus();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
switch (event.key) {
|
|
1459
|
+
case 'ArrowDown':
|
|
1460
|
+
event.preventDefault();
|
|
1461
|
+
if (!this.ctx.open()) {
|
|
1462
|
+
// Open with the natural extreme as the auto-highlight bias —
|
|
1463
|
+
// navigate('next') won't fire here because items aren't registered
|
|
1464
|
+
// yet; the root's `activeId` linkedSignal seeds the activedescendant
|
|
1465
|
+
// once the options register.
|
|
1466
|
+
this.ctx.openMenu('first');
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
this.ctx.navigate('next');
|
|
1470
|
+
}
|
|
1471
|
+
break;
|
|
1472
|
+
case 'ArrowUp':
|
|
1473
|
+
event.preventDefault();
|
|
1474
|
+
if (!this.ctx.open()) {
|
|
1475
|
+
this.ctx.openMenu('last');
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
this.ctx.navigate('prev');
|
|
1479
|
+
}
|
|
1480
|
+
break;
|
|
1481
|
+
case 'Home':
|
|
1482
|
+
if (this.ctx.open()) {
|
|
1483
|
+
event.preventDefault();
|
|
1484
|
+
this.ctx.navigate('first');
|
|
1485
|
+
}
|
|
1486
|
+
break;
|
|
1487
|
+
case 'End':
|
|
1488
|
+
if (this.ctx.open()) {
|
|
1489
|
+
event.preventDefault();
|
|
1490
|
+
this.ctx.navigate('last');
|
|
1491
|
+
}
|
|
1492
|
+
break;
|
|
1493
|
+
case 'PageUp':
|
|
1494
|
+
if (this.ctx.open()) {
|
|
1495
|
+
event.preventDefault();
|
|
1496
|
+
this.ctx.navigate('first');
|
|
1497
|
+
}
|
|
1498
|
+
break;
|
|
1499
|
+
case 'PageDown':
|
|
1500
|
+
if (this.ctx.open()) {
|
|
1501
|
+
event.preventDefault();
|
|
1502
|
+
this.ctx.navigate('last');
|
|
1503
|
+
}
|
|
1504
|
+
break;
|
|
1505
|
+
case 'Enter':
|
|
1506
|
+
if (this.ctx.open() && this.ctx.activateActive()) {
|
|
1507
|
+
event.preventDefault();
|
|
1508
|
+
}
|
|
1509
|
+
break;
|
|
1510
|
+
case 'Escape':
|
|
1511
|
+
// Routed through emitEscapeKeyDown so the consumer's
|
|
1512
|
+
// (escapeKeyDown) output fires and can veto the close. We still
|
|
1513
|
+
// handle Escape inline (not via the dismissable layer) because
|
|
1514
|
+
// focus stays in the input — the Escape belongs to this input
|
|
1515
|
+
// and shouldn't bubble through nested layers before it sees it.
|
|
1516
|
+
if (this.ctx.open()) {
|
|
1517
|
+
event.preventDefault();
|
|
1518
|
+
this.ctx.emitEscapeKeyDown(event);
|
|
1519
|
+
}
|
|
1520
|
+
break;
|
|
1521
|
+
case 'Tab':
|
|
1522
|
+
if (this.ctx.open()) {
|
|
1523
|
+
this.ctx.closeMenu('tab');
|
|
1524
|
+
}
|
|
1525
|
+
// Don't preventDefault — Tab still flows to the next focusable.
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
onFocus() {
|
|
1530
|
+
if (this.ctx.openOnFocus() && !this.ctx.open()) {
|
|
1531
|
+
this.ctx.openMenu();
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
onClick() {
|
|
1535
|
+
// A click on the input while focused mirrors the focus-open intent —
|
|
1536
|
+
// useful when the user closed via Escape and wants to re-open without
|
|
1537
|
+
// typing or reaching for the keyboard.
|
|
1538
|
+
if (this.ctx.openOnFocus() && !this.ctx.open()) {
|
|
1539
|
+
this.ctx.openMenu();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxInput, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1543
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxInput, isStandalone: true, selector: "[forComboboxInput]", host: { attributes: { "type": "text", "role": "combobox", "autocomplete": "off", "autocorrect": "off", "spellcheck": "false" }, listeners: { "input": "onInput($event)", "compositionstart": "onCompositionStart()", "compositionend": "onCompositionEnd()", "keydown": "onKeyDown($event)", "focus": "onFocus()", "click": "onClick()" }, properties: { "id": "ctx.inputId()", "attr.aria-haspopup": "\"listbox\"", "attr.aria-expanded": "ctx.open() ? \"true\" : \"false\"", "attr.aria-controls": "ctx.open() ? ctx.listboxId() : null", "attr.aria-autocomplete": "ariaAutocomplete()", "attr.aria-activedescendant": "ctx.open() ? (ctx.activeId() ?? null) : 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.readonly": "ctx.readonly() ? \"\" : null", "attr.data-state": "ctx.open() ? \"open\" : \"closed\"", "attr.data-disabled": "ctx.effectiveDisabled() ? \"\" : null" } }, exportAs: ["forComboboxInput"], ngImport: i0 });
|
|
1544
|
+
}
|
|
1545
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxInput, decorators: [{
|
|
1546
|
+
type: Directive,
|
|
1547
|
+
args: [{
|
|
1548
|
+
selector: '[forComboboxInput]',
|
|
1549
|
+
exportAs: 'forComboboxInput',
|
|
1550
|
+
host: {
|
|
1551
|
+
type: 'text',
|
|
1552
|
+
role: 'combobox',
|
|
1553
|
+
autocomplete: 'off',
|
|
1554
|
+
autocorrect: 'off',
|
|
1555
|
+
spellcheck: 'false',
|
|
1556
|
+
'[id]': 'ctx.inputId()',
|
|
1557
|
+
'[attr.aria-haspopup]': '"listbox"',
|
|
1558
|
+
'[attr.aria-expanded]': 'ctx.open() ? "true" : "false"',
|
|
1559
|
+
'[attr.aria-controls]': 'ctx.open() ? ctx.listboxId() : null',
|
|
1560
|
+
'[attr.aria-autocomplete]': 'ariaAutocomplete()',
|
|
1561
|
+
'[attr.aria-activedescendant]': 'ctx.open() ? (ctx.activeId() ?? null) : null',
|
|
1562
|
+
'[attr.aria-disabled]': 'ctx.effectiveDisabled() ? "true" : null',
|
|
1563
|
+
'[attr.aria-readonly]': 'ctx.readonly() ? "true" : null',
|
|
1564
|
+
'[attr.aria-required]': 'ctx.required() ? "true" : null',
|
|
1565
|
+
'[attr.aria-invalid]': 'ctx.invalid() ? "true" : null',
|
|
1566
|
+
'[attr.aria-busy]': 'ctx.pending() ? "true" : null',
|
|
1567
|
+
'[attr.readonly]': 'ctx.readonly() ? "" : null',
|
|
1568
|
+
'[attr.data-state]': 'ctx.open() ? "open" : "closed"',
|
|
1569
|
+
'[attr.data-disabled]': 'ctx.effectiveDisabled() ? "" : null',
|
|
1570
|
+
'(input)': 'onInput($event)',
|
|
1571
|
+
'(compositionstart)': 'onCompositionStart()',
|
|
1572
|
+
'(compositionend)': 'onCompositionEnd()',
|
|
1573
|
+
'(keydown)': 'onKeyDown($event)',
|
|
1574
|
+
'(focus)': 'onFocus()',
|
|
1575
|
+
'(click)': 'onClick()',
|
|
1576
|
+
},
|
|
1577
|
+
}]
|
|
1578
|
+
}], ctorParameters: () => [] });
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* The floating surface, portaled to `document.body` and positioned by
|
|
1582
|
+
* `@floating-ui/dom` against the anchor (explicit `[forComboboxAnchor]` →
|
|
1583
|
+
* `[forComboboxTrigger]` → input).
|
|
1584
|
+
*
|
|
1585
|
+
* Two anatomies, picked by whether an inner `[forComboboxList]` is present:
|
|
1586
|
+
*
|
|
1587
|
+
* - **Editable (no list)** — content itself carries `role="listbox"`,
|
|
1588
|
+
* `tabindex="-1"`, `aria-multiselectable`, `aria-setsize`, and the labelled
|
|
1589
|
+
* role (`aria-label` / `aria-labelledby`). The input's `aria-controls` points
|
|
1590
|
+
* here. This is the original combobox; nothing about it changes.
|
|
1591
|
+
* - **Picker (list present)** — content drops the listbox semantics and becomes
|
|
1592
|
+
* a neutral popup surface; `[forComboboxList]` takes over the listbox role and
|
|
1593
|
+
* owns the options, the input lives inside the panel, and the input's
|
|
1594
|
+
* `aria-controls` points to the list. Keeps `data-state` and the positioner +
|
|
1595
|
+
* dismissable layer unchanged.
|
|
1596
|
+
*
|
|
1597
|
+
* Mount/unmount of the visible content is the consumer's responsibility —
|
|
1598
|
+
* wrap with `@if (open())` so `animate.enter` / `animate.leave` fire on the
|
|
1599
|
+
* natural mount cycle. While mounted, a `DismissableLayer` activates for
|
|
1600
|
+
* pointer-down outside / focus outside; the input and (picker anatomy) the
|
|
1601
|
+
* trigger are exempt from outside checks.
|
|
1602
|
+
*
|
|
1603
|
+
* Focus:
|
|
1604
|
+
* - **Editable anatomy** — focus stays in the input across the whole open
|
|
1605
|
+
* lifecycle; the directive never moves DOM focus and exposes no focus hooks.
|
|
1606
|
+
* Active-option highlighting is `aria-activedescendant`-driven.
|
|
1607
|
+
* - **Picker anatomy** — on open, focus moves into the input (the search field
|
|
1608
|
+
* inside the panel); on close it returns to the trigger. Both moves are
|
|
1609
|
+
* vetoable via `(autoFocusOnOpen)` / `(autoFocusOnClose)` on `[forCombobox]`,
|
|
1610
|
+
* and the return is gated by `[returnFocus]`. Escape stays owned by the input.
|
|
1611
|
+
*
|
|
1612
|
+
* The lifecycle (positioner + dismissable layer, plus the picker anatomy's
|
|
1613
|
+
* focus bundles) is owned by the shared `injectOverlayShell` helper.
|
|
1614
|
+
*/
|
|
1615
|
+
class ForComboboxContent {
|
|
1616
|
+
ctx = injectComboboxContext('ForComboboxContent');
|
|
1617
|
+
#host = inject(ElementRef);
|
|
1618
|
+
/** When a `[forComboboxList]` is registered the listbox semantics live there, not here. */
|
|
1619
|
+
hasList = this.ctx.hasList;
|
|
1620
|
+
/**
|
|
1621
|
+
* Reflects `aria-setsize` when the consumer wires up `[totalCount]` for
|
|
1622
|
+
* virtualization (editable anatomy only — the picker anatomy's list owns it).
|
|
1623
|
+
* Falls back to `null` (omitted) otherwise — leaving the default
|
|
1624
|
+
* option-count semantics screen readers already infer.
|
|
1625
|
+
*/
|
|
1626
|
+
ariaSetSize = computed(() => {
|
|
1627
|
+
const total = this.ctx.totalCount();
|
|
1628
|
+
return total === undefined ? null : String(total);
|
|
1629
|
+
}, /* @ts-ignore */
|
|
1630
|
+
...(ngDevMode ? [{ debugName: "ariaSetSize" }] : /* istanbul ignore next */ []));
|
|
1631
|
+
constructor() {
|
|
1632
|
+
const ctx = this.ctx;
|
|
1633
|
+
registerHandle(this.#host.nativeElement, (el) => ctx.registerContent(el), (el) => ctx.unregisterContent(el));
|
|
1634
|
+
// The picker anatomy opts back into the shell's focus bundles. `trigger()`
|
|
1635
|
+
// is read once on construction: the trigger renders outside the `@if (open())`
|
|
1636
|
+
// that gates this content, so it has already registered by the time the
|
|
1637
|
+
// surface mounts. Without a trigger (editable anatomy) the bundles are
|
|
1638
|
+
// omitted entirely and focus stays in the input — byte-for-byte unchanged.
|
|
1639
|
+
const hasTrigger = ctx.trigger() !== null;
|
|
1640
|
+
const config = {
|
|
1641
|
+
positioner: {
|
|
1642
|
+
kind: 'floating',
|
|
1643
|
+
reference: ctx.anchor,
|
|
1644
|
+
open: ctx.open,
|
|
1645
|
+
side: ctx.side,
|
|
1646
|
+
align: ctx.align,
|
|
1647
|
+
sideOffset: ctx.sideOffset,
|
|
1648
|
+
alignOffset: ctx.alignOffset,
|
|
1649
|
+
avoidCollisions: ctx.avoidCollisions,
|
|
1650
|
+
collisionPadding: ctx.collisionPadding,
|
|
1651
|
+
arrowPadding: ctx.arrowPadding,
|
|
1652
|
+
sticky: ctx.sticky,
|
|
1653
|
+
hideWhenDetached: ctx.hideWhenDetached,
|
|
1654
|
+
clipUntilPositioned: ctx.clipUntilPositioned,
|
|
1655
|
+
},
|
|
1656
|
+
dismiss: {
|
|
1657
|
+
dismissible: ctx.dismissible,
|
|
1658
|
+
requestClose: (reason) => ctx.requestClose(reason),
|
|
1659
|
+
// Escape is handled by the input directive (focus stays in the
|
|
1660
|
+
// input, so Escape there shouldn't bubble through nested layers
|
|
1661
|
+
// before the input sees it). Omitted here intentionally.
|
|
1662
|
+
emitPointerDownOutside: (veto) => ctx.emitPointerDownOutside(veto),
|
|
1663
|
+
emitFocusOutside: (veto) => ctx.emitFocusOutside(veto),
|
|
1664
|
+
emitInteractOutside: (veto) => ctx.emitInteractOutside(veto),
|
|
1665
|
+
// The input owns the visible focus and toggles via its own click /
|
|
1666
|
+
// focus handlers; the trigger (picker anatomy) toggles via its own
|
|
1667
|
+
// click handler. Without exemption a pointer-down on either would race
|
|
1668
|
+
// the dismissal layer.
|
|
1669
|
+
exemptElements: () => {
|
|
1670
|
+
const els = [];
|
|
1671
|
+
const input = ctx.input();
|
|
1672
|
+
if (input)
|
|
1673
|
+
els.push(input);
|
|
1674
|
+
const trigger = ctx.trigger();
|
|
1675
|
+
if (trigger)
|
|
1676
|
+
els.push(trigger);
|
|
1677
|
+
return els;
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
// Picker anatomy only: move focus into the input on open, return it to the
|
|
1681
|
+
// trigger on close. Editable anatomy keeps focus in the input throughout,
|
|
1682
|
+
// so both bundles stay absent (no imperative move, no hooks).
|
|
1683
|
+
...(hasTrigger
|
|
1684
|
+
? {
|
|
1685
|
+
initialFocus: {
|
|
1686
|
+
move: () => {
|
|
1687
|
+
const input = ctx.input();
|
|
1688
|
+
if (input) {
|
|
1689
|
+
input.focus();
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
return false;
|
|
1693
|
+
},
|
|
1694
|
+
veto: () => ctx.emitAutoFocusOnOpen(),
|
|
1695
|
+
},
|
|
1696
|
+
returnFocus: {
|
|
1697
|
+
enabled: ctx.returnFocus,
|
|
1698
|
+
target: () => ctx.trigger(),
|
|
1699
|
+
veto: () => ctx.emitAutoFocusOnClose(),
|
|
1700
|
+
// Tab already advanced focus past the closing surface; re-focusing
|
|
1701
|
+
// the trigger would steal it back.
|
|
1702
|
+
skip: () => ctx.lastCloseReason() === 'tab',
|
|
1703
|
+
},
|
|
1704
|
+
}
|
|
1705
|
+
: {}),
|
|
1706
|
+
};
|
|
1707
|
+
injectOverlayShell(config);
|
|
1708
|
+
}
|
|
1709
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxContent, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1710
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxContent, isStandalone: true, selector: "[forComboboxContent]", host: { properties: { "attr.role": "hasList() ? null : \"listbox\"", "attr.tabindex": "hasList() ? null : \"-1\"", "id": "ctx.contentId()", "attr.aria-labelledby": "hasList() ? null : (ctx.ariaLabel() ? null : ctx.inputId())", "attr.aria-label": "hasList() ? null : ctx.ariaLabel()", "attr.aria-multiselectable": "hasList() ? null : (ctx.multiple() ? \"true\" : null)", "attr.aria-setsize": "hasList() ? null : ariaSetSize()", "attr.data-state": "ctx.open() ? \"open\" : \"closed\"" } }, exportAs: ["forComboboxContent"], ngImport: i0 });
|
|
1711
|
+
}
|
|
1712
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxContent, decorators: [{
|
|
1713
|
+
type: Directive,
|
|
1714
|
+
args: [{
|
|
1715
|
+
selector: '[forComboboxContent]',
|
|
1716
|
+
exportAs: 'forComboboxContent',
|
|
1717
|
+
host: {
|
|
1718
|
+
'[attr.role]': 'hasList() ? null : "listbox"',
|
|
1719
|
+
'[attr.tabindex]': 'hasList() ? null : "-1"',
|
|
1720
|
+
'[id]': 'ctx.contentId()',
|
|
1721
|
+
'[attr.aria-labelledby]': 'hasList() ? null : (ctx.ariaLabel() ? null : ctx.inputId())',
|
|
1722
|
+
'[attr.aria-label]': 'hasList() ? null : ctx.ariaLabel()',
|
|
1723
|
+
'[attr.aria-multiselectable]': 'hasList() ? null : (ctx.multiple() ? "true" : null)',
|
|
1724
|
+
'[attr.aria-setsize]': 'hasList() ? null : ariaSetSize()',
|
|
1725
|
+
'[attr.data-state]': 'ctx.open() ? "open" : "closed"',
|
|
1726
|
+
},
|
|
1727
|
+
}]
|
|
1728
|
+
}], ctorParameters: () => [] });
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* The `role="listbox"` element of the picker anatomy. Nest it inside
|
|
1732
|
+
* `[forComboboxContent]` next to `[forComboboxInput]` so the popup surface can
|
|
1733
|
+
* hold a search field without violating `aria-required-owned-elements` (a
|
|
1734
|
+
* `listbox` may only own `option` / `group` children). The list owns the
|
|
1735
|
+
* options; `[forComboboxContent]` becomes a neutral popup surface.
|
|
1736
|
+
*
|
|
1737
|
+
* ```html
|
|
1738
|
+
* <div forComboboxContent>
|
|
1739
|
+
* <input forComboboxInput />
|
|
1740
|
+
* <div forComboboxList>
|
|
1741
|
+
* @for (item of filtered(); track item.id) {
|
|
1742
|
+
* <div forComboboxOption [value]="item">{{ item.label }}</div>
|
|
1743
|
+
* }
|
|
1744
|
+
* </div>
|
|
1745
|
+
* </div>
|
|
1746
|
+
* ```
|
|
1747
|
+
*
|
|
1748
|
+
* Carries `role="listbox"`, `tabindex="-1"` (focus stays in the input,
|
|
1749
|
+
* activedescendant-driven), `aria-multiselectable` in multi mode,
|
|
1750
|
+
* `aria-setsize` when virtualizing, and the labelled-role `aria-label` /
|
|
1751
|
+
* `aria-labelledby`. Its id is what the input's `aria-controls` references in
|
|
1752
|
+
* the picker anatomy.
|
|
1753
|
+
*
|
|
1754
|
+
* When no `[forComboboxList]` is present, `[forComboboxContent]` itself carries
|
|
1755
|
+
* the listbox semantics (the editable anatomy) — this part is additive and
|
|
1756
|
+
* non-breaking.
|
|
1757
|
+
*/
|
|
1758
|
+
class ForComboboxList {
|
|
1759
|
+
ctx = injectComboboxContext('ForComboboxList');
|
|
1760
|
+
#host = inject(ElementRef);
|
|
1761
|
+
/**
|
|
1762
|
+
* Reflects `aria-setsize` when the consumer wires up `[totalCount]` for
|
|
1763
|
+
* virtualization. Falls back to `null` (omitted) otherwise.
|
|
1764
|
+
*/
|
|
1765
|
+
ariaSetSize = computed(() => {
|
|
1766
|
+
const total = this.ctx.totalCount();
|
|
1767
|
+
return total === undefined ? null : String(total);
|
|
1768
|
+
}, /* @ts-ignore */
|
|
1769
|
+
...(ngDevMode ? [{ debugName: "ariaSetSize" }] : /* istanbul ignore next */ []));
|
|
1770
|
+
constructor() {
|
|
1771
|
+
registerHandle(this.#host.nativeElement, (el) => this.ctx.registerList(el), (el) => this.ctx.unregisterList(el));
|
|
1772
|
+
}
|
|
1773
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxList, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1774
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxList, isStandalone: true, selector: "[forComboboxList]", host: { attributes: { "role": "listbox", "tabindex": "-1" }, properties: { "id": "ctx.listId()", "attr.aria-labelledby": "ctx.ariaLabel() ? null : ctx.inputId()", "attr.aria-label": "ctx.ariaLabel()", "attr.aria-multiselectable": "ctx.multiple() ? \"true\" : null", "attr.aria-setsize": "ariaSetSize()" } }, exportAs: ["forComboboxList"], ngImport: i0 });
|
|
1775
|
+
}
|
|
1776
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxList, decorators: [{
|
|
1777
|
+
type: Directive,
|
|
1778
|
+
args: [{
|
|
1779
|
+
selector: '[forComboboxList]',
|
|
1780
|
+
exportAs: 'forComboboxList',
|
|
1781
|
+
host: {
|
|
1782
|
+
role: 'listbox',
|
|
1783
|
+
tabindex: '-1',
|
|
1784
|
+
'[id]': 'ctx.listId()',
|
|
1785
|
+
'[attr.aria-labelledby]': 'ctx.ariaLabel() ? null : ctx.inputId()',
|
|
1786
|
+
'[attr.aria-label]': 'ctx.ariaLabel()',
|
|
1787
|
+
'[attr.aria-multiselectable]': 'ctx.multiple() ? "true" : null',
|
|
1788
|
+
'[attr.aria-setsize]': 'ariaSetSize()',
|
|
1789
|
+
},
|
|
1790
|
+
}]
|
|
1791
|
+
}], ctorParameters: () => [] });
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Injection key the `[forComboboxIndicator]` uses to resolve its parent
|
|
1795
|
+
* option, decoupled from the concrete `ForComboboxOption` class.
|
|
1796
|
+
* `ForComboboxOption` provides itself under this token, so a design system
|
|
1797
|
+
* wrapping the option by subclassing re-points it at the subclass with a
|
|
1798
|
+
* single provider (`{ provide: FOR_COMBOBOX_OPTION, useExisting: MtxComboboxOption }`)
|
|
1799
|
+
* and the indicator keeps resolving — see `docs/wrapping-form-primitives.md`.
|
|
1800
|
+
*/
|
|
1801
|
+
const FOR_COMBOBOX_OPTION = new InjectionToken('FOR_COMBOBOX_OPTION');
|
|
1802
|
+
/**
|
|
1803
|
+
* One option inside a `[forComboboxContent]`. Apply on whatever element
|
|
1804
|
+
* fits the design — typically a `<div>` or `<li>`. Click activates:
|
|
1805
|
+
* single mode replaces `[(value)]` and closes the listbox; multi mode
|
|
1806
|
+
* toggles the value in/out and keeps the listbox open.
|
|
1807
|
+
*
|
|
1808
|
+
* Generic over the option value type `T` (default `string`). Inferred from
|
|
1809
|
+
* the `[value]` binding so consumers can pass either primitive ids or
|
|
1810
|
+
* full objects (`[value]="city"` infers `T = City`); the parent
|
|
1811
|
+
* `[forCombobox]` must be parameterized over the same `T`.
|
|
1812
|
+
*
|
|
1813
|
+
* `aria-selected` reflects different things in single vs. multi:
|
|
1814
|
+
* - **Single mode**: the option that's the current activedescendant
|
|
1815
|
+
* (Enter would activate it). Matches APG select-only-combobox.
|
|
1816
|
+
* - **Multi mode**: every option currently in `value()` carries
|
|
1817
|
+
* `aria-selected="true"` (multiple "selected" entries simultaneously).
|
|
1818
|
+
* Matches APG multi-select combobox.
|
|
1819
|
+
*
|
|
1820
|
+
* `data-state="checked" | "unchecked"` always reflects membership in
|
|
1821
|
+
* `value()` regardless of mode, so consumers can paint a checkmark with
|
|
1822
|
+
* pure CSS in either mode. `data-highlighted` marks the option that is
|
|
1823
|
+
* the current activedescendant — shared across the
|
|
1824
|
+
* library's roving / activedescendant primitives.
|
|
1825
|
+
*
|
|
1826
|
+
* Hovering an option also makes it the activedescendant, mirroring native
|
|
1827
|
+
* menu / select behavior so mouse and keyboard intent stay synchronized.
|
|
1828
|
+
*/
|
|
1829
|
+
class ForComboboxOption {
|
|
1830
|
+
#ctx = injectComboboxContext('ForComboboxOption');
|
|
1831
|
+
#host = inject(ElementRef);
|
|
1832
|
+
/**
|
|
1833
|
+
* Stable identifier serialized into `[(value)]` and the hidden input.
|
|
1834
|
+
* Defaults to `string` for back-compat; bind an object to specialize
|
|
1835
|
+
* the parent `[forCombobox]` over a richer `T`. The parent's
|
|
1836
|
+
* `[isItemEqualToValue]` decides how options are matched against the
|
|
1837
|
+
* committed selection.
|
|
1838
|
+
*/
|
|
1839
|
+
value = input.required(/* @ts-ignore */
|
|
1840
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1841
|
+
/**
|
|
1842
|
+
* Visible label used by `[forComboboxInput]` for inline autocomplete
|
|
1843
|
+
* matching, by `commitOnSelect` to populate the input on selection,
|
|
1844
|
+
* and by typeahead / display utilities. When omitted: for string
|
|
1845
|
+
* `value` falls back to the trimmed `textContent` of the host element;
|
|
1846
|
+
* for object `value` falls back to the parent's `itemToStringLabel(value)`.
|
|
1847
|
+
*/
|
|
1848
|
+
label = input(null, /* @ts-ignore */
|
|
1849
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1850
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
|
|
1851
|
+
/**
|
|
1852
|
+
* Absolute index of this option in the consumer's source array. Required
|
|
1853
|
+
* when wiring up a virtualized listbox so navigation past the rendered
|
|
1854
|
+
* window can resolve indices to options (and emit `(scrollToIndex)` when
|
|
1855
|
+
* needed). Leave `null` for non-virtualized lists — the directive then
|
|
1856
|
+
* falls back to DOM order.
|
|
1857
|
+
*/
|
|
1858
|
+
posInSet = input(null, /* @ts-ignore */
|
|
1859
|
+
...(ngDevMode ? [{ debugName: "posInSet" }] : /* istanbul ignore next */ []));
|
|
1860
|
+
id = hostId('for-combobox-option');
|
|
1861
|
+
selected = computed(() => this.#ctx.isSelected(this.value()), /* @ts-ignore */
|
|
1862
|
+
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
1863
|
+
/** True when this option is the current activedescendant. Reflected as `data-highlighted`. */
|
|
1864
|
+
highlighted = computed(() => this.#ctx.isActive(this.id()), /* @ts-ignore */
|
|
1865
|
+
...(ngDevMode ? [{ debugName: "highlighted" }] : /* istanbul ignore next */ []));
|
|
1866
|
+
effectiveDisabled = computed(() => this.disabled() || this.#ctx.effectiveDisabled(), /* @ts-ignore */
|
|
1867
|
+
...(ngDevMode ? [{ debugName: "effectiveDisabled" }] : /* istanbul ignore next */ []));
|
|
1868
|
+
ariaSelected = computed(() => {
|
|
1869
|
+
if (this.#ctx.multiple()) {
|
|
1870
|
+
return this.selected() ? 'true' : 'false';
|
|
1871
|
+
}
|
|
1872
|
+
return this.highlighted() ? 'true' : 'false';
|
|
1873
|
+
}, /* @ts-ignore */
|
|
1874
|
+
...(ngDevMode ? [{ debugName: "ariaSelected" }] : /* istanbul ignore next */ []));
|
|
1875
|
+
/** Reflects `aria-posinset` (1-based) when virtualizing. Falls back to DOM order otherwise. */
|
|
1876
|
+
ariaPosInSet = computed(() => {
|
|
1877
|
+
const explicit = this.posInSet();
|
|
1878
|
+
if (explicit !== null) {
|
|
1879
|
+
return String(explicit + 1);
|
|
1880
|
+
}
|
|
1881
|
+
if (this.#ctx.totalCount() === undefined) {
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
const items = this.#ctx.options();
|
|
1885
|
+
const idx = items.findIndex((it) => it.id() === this.id());
|
|
1886
|
+
return idx < 0 ? null : String(idx + 1);
|
|
1887
|
+
}, /* @ts-ignore */
|
|
1888
|
+
...(ngDevMode ? [{ debugName: "ariaPosInSet" }] : /* istanbul ignore next */ []));
|
|
1889
|
+
/** Reflects `aria-setsize` when the consumer wires up `[totalCount]`. */
|
|
1890
|
+
ariaSetSize = computed(() => {
|
|
1891
|
+
const total = this.#ctx.totalCount();
|
|
1892
|
+
return total === undefined ? null : String(total);
|
|
1893
|
+
}, /* @ts-ignore */
|
|
1894
|
+
...(ngDevMode ? [{ debugName: "ariaSetSize" }] : /* istanbul ignore next */ []));
|
|
1895
|
+
#effectiveLabel = computed(() => {
|
|
1896
|
+
const explicit = this.label();
|
|
1897
|
+
if (explicit !== null) {
|
|
1898
|
+
return explicit;
|
|
1899
|
+
}
|
|
1900
|
+
const v = this.value();
|
|
1901
|
+
if (typeof v === 'string') {
|
|
1902
|
+
// String mode: the trimmed `textContent` is the canonical fallback,
|
|
1903
|
+
// identical to the pre-generic behaviour. Lets consumers omit
|
|
1904
|
+
// `[label]` and have it just work for projected text.
|
|
1905
|
+
return (this.#host.nativeElement.textContent ?? '').trim();
|
|
1906
|
+
}
|
|
1907
|
+
// Object mode: lean on the parent's `itemToStringLabel` so the
|
|
1908
|
+
// resolved label is consistent with chip rendering and `commitOnSelect`.
|
|
1909
|
+
return this.#ctx.itemToStringLabel()(v);
|
|
1910
|
+
}, /* @ts-ignore */
|
|
1911
|
+
...(ngDevMode ? [{ debugName: "#effectiveLabel" }] : /* istanbul ignore next */ []));
|
|
1912
|
+
#handle = {
|
|
1913
|
+
host: this.#host.nativeElement,
|
|
1914
|
+
id: this.id,
|
|
1915
|
+
value: this.value,
|
|
1916
|
+
label: this.#effectiveLabel,
|
|
1917
|
+
disabled: this.effectiveDisabled,
|
|
1918
|
+
posInSet: this.posInSet,
|
|
1919
|
+
};
|
|
1920
|
+
constructor() {
|
|
1921
|
+
registerHandle(this.#handle, (h) => this.#ctx.registerOption(h), (h) => this.#ctx.unregisterOption(h));
|
|
1922
|
+
}
|
|
1923
|
+
onClick() {
|
|
1924
|
+
if (this.effectiveDisabled() || this.#ctx.readonly()) {
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
this.#ctx.activate(this.#handle);
|
|
1928
|
+
}
|
|
1929
|
+
onPointerMove() {
|
|
1930
|
+
if (this.effectiveDisabled() || this.#ctx.isPointerSuppressed()) {
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
if (!this.#ctx.isActive(this.id())) {
|
|
1934
|
+
this.#ctx.setActiveId(this.id());
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxOption, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1938
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForComboboxOption, isStandalone: true, selector: "[forComboboxOption]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, 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" }, listeners: { "click": "onClick()", "pointermove": "onPointerMove()" }, properties: { "id": "id()", "attr.aria-selected": "ariaSelected()", "attr.aria-disabled": "effectiveDisabled() ? \"true\" : null", "attr.aria-posinset": "ariaPosInSet()", "attr.aria-setsize": "ariaSetSize()", "attr.data-state": "selected() ? \"checked\" : \"unchecked\"", "attr.data-highlighted": "highlighted() ? \"\" : null", "attr.data-disabled": "effectiveDisabled() ? \"\" : null" } }, providers: [{ provide: FOR_COMBOBOX_OPTION, useExisting: ForComboboxOption }], exportAs: ["forComboboxOption"], ngImport: i0 });
|
|
1939
|
+
}
|
|
1940
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxOption, decorators: [{
|
|
1941
|
+
type: Directive,
|
|
1942
|
+
args: [{
|
|
1943
|
+
selector: '[forComboboxOption]',
|
|
1944
|
+
exportAs: 'forComboboxOption',
|
|
1945
|
+
providers: [{ provide: FOR_COMBOBOX_OPTION, useExisting: ForComboboxOption }],
|
|
1946
|
+
host: {
|
|
1947
|
+
role: 'option',
|
|
1948
|
+
'[id]': 'id()',
|
|
1949
|
+
'[attr.aria-selected]': 'ariaSelected()',
|
|
1950
|
+
'[attr.aria-disabled]': 'effectiveDisabled() ? "true" : null',
|
|
1951
|
+
'[attr.aria-posinset]': 'ariaPosInSet()',
|
|
1952
|
+
'[attr.aria-setsize]': 'ariaSetSize()',
|
|
1953
|
+
'[attr.data-state]': 'selected() ? "checked" : "unchecked"',
|
|
1954
|
+
'[attr.data-highlighted]': 'highlighted() ? "" : null',
|
|
1955
|
+
'[attr.data-disabled]': 'effectiveDisabled() ? "" : null',
|
|
1956
|
+
'(click)': 'onClick()',
|
|
1957
|
+
'(pointermove)': 'onPointerMove()',
|
|
1958
|
+
},
|
|
1959
|
+
}]
|
|
1960
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], posInSet: [{ type: i0.Input, args: [{ isSignal: true, alias: "posInSet", required: false }] }] } });
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Visibility helper inside a `[forComboboxOption]`. The directive flips a
|
|
1964
|
+
* `[hidden]` host binding so the consumer can keep the checkmark / icon
|
|
1965
|
+
* inline without extra `@if` glue:
|
|
1966
|
+
*
|
|
1967
|
+
* ```html
|
|
1968
|
+
* <div forComboboxOption value="apple">
|
|
1969
|
+
* <span forComboboxIndicator>✓</span>
|
|
1970
|
+
* Apple
|
|
1971
|
+
* </div>
|
|
1972
|
+
* ```
|
|
1973
|
+
*
|
|
1974
|
+
* Reflects the parent option's `data-state` (`"checked" | "unchecked"`) so
|
|
1975
|
+
* the consumer can also style it from CSS. Visibility while unselected is
|
|
1976
|
+
* enforced with an inline `display: none` (which beats any author `display`
|
|
1977
|
+
* rule a consumer applies via a class) in addition to the `hidden` attribute
|
|
1978
|
+
* that removes it from the a11y tree.
|
|
1979
|
+
*
|
|
1980
|
+
* In multi mode the indicator follows membership in `value()`, matching the
|
|
1981
|
+
* option's own `data-state` semantics (a checkmark per selected option).
|
|
1982
|
+
*/
|
|
1983
|
+
class ForComboboxIndicator {
|
|
1984
|
+
option = injectParentOption();
|
|
1985
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxIndicator, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1986
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxIndicator, isStandalone: true, selector: "[forComboboxIndicator]", host: { attributes: { "aria-hidden": "true" }, properties: { "attr.data-state": "option.selected() ? \"checked\" : \"unchecked\"", "hidden": "!option.selected()", "style.display": "option.selected() ? null : \"none\"" } }, exportAs: ["forComboboxIndicator"], ngImport: i0 });
|
|
1987
|
+
}
|
|
1988
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxIndicator, decorators: [{
|
|
1989
|
+
type: Directive,
|
|
1990
|
+
args: [{
|
|
1991
|
+
selector: '[forComboboxIndicator]',
|
|
1992
|
+
exportAs: 'forComboboxIndicator',
|
|
1993
|
+
host: {
|
|
1994
|
+
'aria-hidden': 'true',
|
|
1995
|
+
'[attr.data-state]': 'option.selected() ? "checked" : "unchecked"',
|
|
1996
|
+
'[hidden]': '!option.selected()',
|
|
1997
|
+
'[style.display]': 'option.selected() ? null : "none"',
|
|
1998
|
+
},
|
|
1999
|
+
}]
|
|
2000
|
+
}] });
|
|
2001
|
+
function injectParentOption() {
|
|
2002
|
+
const option = inject(FOR_COMBOBOX_OPTION, { optional: true });
|
|
2003
|
+
if (!option) {
|
|
2004
|
+
throw new Error('[forty-cdk/combobox] ForComboboxIndicator must be used inside a [forComboboxOption] element.');
|
|
2005
|
+
}
|
|
2006
|
+
return option;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Empty-state slot, shown when the listbox has no registered options.
|
|
2011
|
+
* The directive hides the message when options exist so the consumer can
|
|
2012
|
+
* keep it inline in the template — no `@if` needed. Visibility is enforced
|
|
2013
|
+
* with an inline `display: none` (which beats any author `display` rule a
|
|
2014
|
+
* consumer applies via a class) in addition to the `hidden` attribute that
|
|
2015
|
+
* removes it from the a11y tree:
|
|
2016
|
+
*
|
|
2017
|
+
* ```html
|
|
2018
|
+
* <div forComboboxContent>
|
|
2019
|
+
* @for (option of filtered(); track option.id) {
|
|
2020
|
+
* <div forComboboxOption [value]="option.id">{{ option.label }}</div>
|
|
2021
|
+
* }
|
|
2022
|
+
* <div forComboboxEmpty>No matches.</div>
|
|
2023
|
+
* </div>
|
|
2024
|
+
* ```
|
|
2025
|
+
*
|
|
2026
|
+
* Carries `role="status"` and `aria-live="polite"` so screen readers
|
|
2027
|
+
* announce the message when it appears.
|
|
2028
|
+
*/
|
|
2029
|
+
class ForComboboxEmpty {
|
|
2030
|
+
#ctx = injectComboboxContext('ForComboboxEmpty');
|
|
2031
|
+
shouldShow = computed(() => this.#ctx.options().length === 0, /* @ts-ignore */
|
|
2032
|
+
...(ngDevMode ? [{ debugName: "shouldShow" }] : /* istanbul ignore next */ []));
|
|
2033
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxEmpty, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2034
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxEmpty, isStandalone: true, selector: "[forComboboxEmpty]", host: { attributes: { "role": "status", "aria-live": "polite" }, properties: { "hidden": "!shouldShow()", "style.display": "shouldShow() ? null : \"none\"" } }, exportAs: ["forComboboxEmpty"], ngImport: i0 });
|
|
2035
|
+
}
|
|
2036
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxEmpty, decorators: [{
|
|
2037
|
+
type: Directive,
|
|
2038
|
+
args: [{
|
|
2039
|
+
selector: '[forComboboxEmpty]',
|
|
2040
|
+
exportAs: 'forComboboxEmpty',
|
|
2041
|
+
host: {
|
|
2042
|
+
role: 'status',
|
|
2043
|
+
'aria-live': 'polite',
|
|
2044
|
+
'[hidden]': '!shouldShow()',
|
|
2045
|
+
'[style.display]': 'shouldShow() ? null : "none"',
|
|
2046
|
+
},
|
|
2047
|
+
}]
|
|
2048
|
+
}] });
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Live-region slot for async-filtering feedback (loading, result count,
|
|
2052
|
+
* "no matches", error messages). Apply on a `<div>` inside the listbox or
|
|
2053
|
+
* next to the input. The directive sets `role="status"` and
|
|
2054
|
+
* `aria-live="polite"` so messages projected as content are announced to
|
|
2055
|
+
* screen readers when they change.
|
|
2056
|
+
*
|
|
2057
|
+
* The directive is **content-driven** — it does not pick or render a
|
|
2058
|
+
* message. Project whatever the consumer wants and use the exposed
|
|
2059
|
+
* `count` signal (or `forComboboxStatus` template ref) to interpolate
|
|
2060
|
+
* the option count when relevant.
|
|
2061
|
+
*
|
|
2062
|
+
* ```html
|
|
2063
|
+
* <div forCombobox [(query)]="query" [(value)]="value" [(open)]="open">
|
|
2064
|
+
* <input forComboboxInput placeholder="Search…" />
|
|
2065
|
+
* @if (open()) {
|
|
2066
|
+
* <div forComboboxContent>
|
|
2067
|
+
* <div forComboboxStatus #status="forComboboxStatus">
|
|
2068
|
+
* @if (loading()) {
|
|
2069
|
+
* Searching…
|
|
2070
|
+
* } @else if (status.count() === 0) {
|
|
2071
|
+
* No matches.
|
|
2072
|
+
* } @else {
|
|
2073
|
+
* {{ status.count() }} results.
|
|
2074
|
+
* }
|
|
2075
|
+
* </div>
|
|
2076
|
+
* @for (it of filtered(); track it.id) {
|
|
2077
|
+
* <div forComboboxOption [value]="it.id">{{ it.label }}</div>
|
|
2078
|
+
* }
|
|
2079
|
+
* </div>
|
|
2080
|
+
* }
|
|
2081
|
+
* </div>
|
|
2082
|
+
* ```
|
|
2083
|
+
*
|
|
2084
|
+
* For an empty-only slot that auto-hides when there are options, use
|
|
2085
|
+
* `[forComboboxEmpty]`. `[forComboboxStatus]` stays mounted regardless
|
|
2086
|
+
* so transitions like "loading → 5 results" are announced as a single
|
|
2087
|
+
* change to the same live region.
|
|
2088
|
+
*/
|
|
2089
|
+
class ForComboboxStatus {
|
|
2090
|
+
#ctx = injectComboboxContext('ForComboboxStatus');
|
|
2091
|
+
/**
|
|
2092
|
+
* Number of currently registered options. Reflects the live size of the
|
|
2093
|
+
* filtered listbox so the consumer can interpolate `{{ status.count() }}`
|
|
2094
|
+
* inside the live region.
|
|
2095
|
+
*/
|
|
2096
|
+
count = computed(() => this.#ctx.options().length, /* @ts-ignore */
|
|
2097
|
+
...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
|
|
2098
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxStatus, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2099
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxStatus, isStandalone: true, selector: "[forComboboxStatus]", host: { attributes: { "role": "status", "aria-live": "polite", "aria-atomic": "true" } }, exportAs: ["forComboboxStatus"], ngImport: i0 });
|
|
2100
|
+
}
|
|
2101
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxStatus, decorators: [{
|
|
2102
|
+
type: Directive,
|
|
2103
|
+
args: [{
|
|
2104
|
+
selector: '[forComboboxStatus]',
|
|
2105
|
+
exportAs: 'forComboboxStatus',
|
|
2106
|
+
host: {
|
|
2107
|
+
role: 'status',
|
|
2108
|
+
'aria-live': 'polite',
|
|
2109
|
+
'aria-atomic': 'true',
|
|
2110
|
+
},
|
|
2111
|
+
}]
|
|
2112
|
+
}] });
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* Optional clear button. Apply on a `<button type="button">` so Space /
|
|
2116
|
+
* Enter dispatch a native click. Clicking calls `clear()` on the root,
|
|
2117
|
+
* which resets `[(value)]` and `[(query)]` and the activedescendant.
|
|
2118
|
+
*
|
|
2119
|
+
* The directive hides the button when there's nothing to clear (no value,
|
|
2120
|
+
* empty query) so the consumer can leave it inline in the template without
|
|
2121
|
+
* an extra `@if`. Visibility is enforced with an inline `display: none`
|
|
2122
|
+
* (which beats any author `display` rule a consumer applies via a class) in
|
|
2123
|
+
* addition to the `hidden` attribute that removes it from the a11y tree.
|
|
2124
|
+
*/
|
|
2125
|
+
class ForComboboxClear {
|
|
2126
|
+
ctx = injectComboboxContext('ForComboboxClear');
|
|
2127
|
+
hasContent = computed(() => this.ctx.value().length > 0 || this.ctx.query().length > 0, /* @ts-ignore */
|
|
2128
|
+
...(ngDevMode ? [{ debugName: "hasContent" }] : /* istanbul ignore next */ []));
|
|
2129
|
+
/** Disabled when the combobox is disabled or read-only — the clear action is unavailable. */
|
|
2130
|
+
isDisabled = computed(() => this.ctx.effectiveDisabled() || this.ctx.readonly(), /* @ts-ignore */
|
|
2131
|
+
...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
|
|
2132
|
+
constructor() {
|
|
2133
|
+
reflectDisabled(this.isDisabled);
|
|
2134
|
+
}
|
|
2135
|
+
onClick() {
|
|
2136
|
+
if (this.isDisabled()) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
this.ctx.clear(true);
|
|
2140
|
+
// Restore focus to the input so the user can keep typing without
|
|
2141
|
+
// chasing the caret.
|
|
2142
|
+
this.ctx.input()?.focus();
|
|
2143
|
+
}
|
|
2144
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxClear, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2145
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxClear, isStandalone: true, selector: "[forComboboxClear]", host: { attributes: { "type": "button", "aria-label": "Clear" }, listeners: { "click": "onClick()" }, properties: { "hidden": "!hasContent()", "style.display": "hasContent() ? null : \"none\"", "attr.tabindex": "-1" } }, exportAs: ["forComboboxClear"], ngImport: i0 });
|
|
2146
|
+
}
|
|
2147
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxClear, decorators: [{
|
|
2148
|
+
type: Directive,
|
|
2149
|
+
args: [{
|
|
2150
|
+
selector: '[forComboboxClear]',
|
|
2151
|
+
exportAs: 'forComboboxClear',
|
|
2152
|
+
host: {
|
|
2153
|
+
type: 'button',
|
|
2154
|
+
'aria-label': 'Clear',
|
|
2155
|
+
'[hidden]': '!hasContent()',
|
|
2156
|
+
'[style.display]': 'hasContent() ? null : "none"',
|
|
2157
|
+
'[attr.tabindex]': '-1',
|
|
2158
|
+
'(click)': 'onClick()',
|
|
2159
|
+
},
|
|
2160
|
+
}]
|
|
2161
|
+
}], ctorParameters: () => [] });
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* Multi-mode chips area. Wrap the chips and the `<input>` together so the
|
|
2165
|
+
* combobox visually presents a single editable region:
|
|
2166
|
+
*
|
|
2167
|
+
* ```html
|
|
2168
|
+
* <div forCombobox multiple [(value)]="tags" [(query)]="query" [(open)]="open">
|
|
2169
|
+
* <div forComboboxChips>
|
|
2170
|
+
* @for (chip of selected(); track chip.value) {
|
|
2171
|
+
* <span forComboboxChip [value]="chip.value">
|
|
2172
|
+
* {{ chip.label }}
|
|
2173
|
+
* <button forComboboxChipRemove></button>
|
|
2174
|
+
* </span>
|
|
2175
|
+
* }
|
|
2176
|
+
* <input forComboboxInput />
|
|
2177
|
+
* </div>
|
|
2178
|
+
* …
|
|
2179
|
+
* </div>
|
|
2180
|
+
* ```
|
|
2181
|
+
*
|
|
2182
|
+
* Carries `role="group"` with `aria-label="Selected items"` (override via
|
|
2183
|
+
* `[ariaLabel]`) so screen readers announce the chip cluster as a single
|
|
2184
|
+
* unit. The directive itself doesn't manage focus or selection — the
|
|
2185
|
+
* chips and the input own that — but its presence groups them for
|
|
2186
|
+
* assistive tech.
|
|
2187
|
+
*/
|
|
2188
|
+
class ForComboboxChips {
|
|
2189
|
+
ctx = injectComboboxContext('ForComboboxChips');
|
|
2190
|
+
/**
|
|
2191
|
+
* Accessible name for the chip cluster, exposed as `role="group"`'s
|
|
2192
|
+
* `aria-label` so screen readers announce the selected chips as a single
|
|
2193
|
+
* unit. Defaults to `'Selected items'`; set `[ariaLabel]` to localize or
|
|
2194
|
+
* override it.
|
|
2195
|
+
*/
|
|
2196
|
+
ariaLabel = input('Selected items', /* @ts-ignore */
|
|
2197
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
2198
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChips, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2199
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForComboboxChips, isStandalone: true, selector: "[forComboboxChips]", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "group" }, properties: { "attr.aria-label": "ariaLabel()" } }, exportAs: ["forComboboxChips"], ngImport: i0 });
|
|
2200
|
+
}
|
|
2201
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChips, decorators: [{
|
|
2202
|
+
type: Directive,
|
|
2203
|
+
args: [{
|
|
2204
|
+
selector: '[forComboboxChips]',
|
|
2205
|
+
exportAs: 'forComboboxChips',
|
|
2206
|
+
host: {
|
|
2207
|
+
role: 'group',
|
|
2208
|
+
'[attr.aria-label]': 'ariaLabel()',
|
|
2209
|
+
},
|
|
2210
|
+
}]
|
|
2211
|
+
}], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }] } });
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* One chip representing a selected value in multi mode. Apply on a
|
|
2215
|
+
* `<span>` (or any inline-block element) inside `[forComboboxChips]`.
|
|
2216
|
+
* The chip is **out of the Tab cycle** (`tabindex="-1"`) by design:
|
|
2217
|
+
* the user reaches it via the input's Backspace heuristic (Backspace on
|
|
2218
|
+
* an empty input focuses the last chip), then navigates between chips
|
|
2219
|
+
* with ArrowLeft / ArrowRight or removes them with Backspace / Delete.
|
|
2220
|
+
*
|
|
2221
|
+
* Keyboard while the chip has focus (LTR; the ArrowLeft/Right roles swap
|
|
2222
|
+
* in RTL so they always follow visual order):
|
|
2223
|
+
* - **ArrowLeft** — focus the previous chip; bounces if first.
|
|
2224
|
+
* - **ArrowRight** — focus the next chip; if at the last, focus the input.
|
|
2225
|
+
* - **Backspace / Delete** — remove this chip + focus the previous chip,
|
|
2226
|
+
* or the next chip when there is no previous one (so removing the first
|
|
2227
|
+
* chip lands on the new first chip), falling back to the input only when
|
|
2228
|
+
* the removed chip was the last one standing.
|
|
2229
|
+
* - **Escape** — return focus to the input.
|
|
2230
|
+
*
|
|
2231
|
+
* Click on the chip body (excluding the remove button) just focuses the
|
|
2232
|
+
* chip — useful as an alternative to the Backspace path.
|
|
2233
|
+
*/
|
|
2234
|
+
class ForComboboxChip {
|
|
2235
|
+
ctx = injectComboboxContext('ForComboboxChip');
|
|
2236
|
+
#host = inject(ElementRef);
|
|
2237
|
+
/**
|
|
2238
|
+
* The value this chip represents — must match an entry in
|
|
2239
|
+
* `[forCombobox][(value)]` per the parent's `[isItemEqualToValue]`.
|
|
2240
|
+
* Generic over `T` (default `string`); inferred from the binding
|
|
2241
|
+
* (`[value]="someObject"` specializes `T`).
|
|
2242
|
+
*/
|
|
2243
|
+
value = input.required(/* @ts-ignore */
|
|
2244
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
2245
|
+
/**
|
|
2246
|
+
* `data-value` reflection — for string `T` this is the value verbatim
|
|
2247
|
+
* (unchanged from the pre-generic behaviour); for object `T` it uses
|
|
2248
|
+
* the parent's `itemToFormValue` so the attribute carries the same
|
|
2249
|
+
* wire format as the hidden inputs (typically JSON or a per-item id).
|
|
2250
|
+
*/
|
|
2251
|
+
dataValue = computed(() => {
|
|
2252
|
+
const v = this.value();
|
|
2253
|
+
return typeof v === 'string' ? v : this.ctx.itemToFormValue()(v);
|
|
2254
|
+
}, /* @ts-ignore */
|
|
2255
|
+
...(ngDevMode ? [{ debugName: "dataValue" }] : /* istanbul ignore next */ []));
|
|
2256
|
+
/** Resolved label of the underlying option, used by `[forComboboxChipRemove]` for its `aria-label`. */
|
|
2257
|
+
label = computed(() => {
|
|
2258
|
+
const v = this.value();
|
|
2259
|
+
const equals = this.ctx.isItemEqualToValue();
|
|
2260
|
+
const cached = this.ctx.cachedOptions().find((o) => equals(o.value, v));
|
|
2261
|
+
if (cached) {
|
|
2262
|
+
return cached.label;
|
|
2263
|
+
}
|
|
2264
|
+
return typeof v === 'string' ? v : this.ctx.itemToStringLabel()(v);
|
|
2265
|
+
}, /* @ts-ignore */
|
|
2266
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
2267
|
+
constructor() {
|
|
2268
|
+
const handle = {
|
|
2269
|
+
host: this.#host.nativeElement,
|
|
2270
|
+
value: this.value,
|
|
2271
|
+
};
|
|
2272
|
+
registerHandle(handle, (h) => this.ctx.registerChip(h), (h) => this.ctx.unregisterChip(h));
|
|
2273
|
+
}
|
|
2274
|
+
onKeyDown(event) {
|
|
2275
|
+
if (this.ctx.effectiveDisabled() || this.ctx.readonly()) {
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const chips = this.ctx.chips();
|
|
2279
|
+
const index = chips.findIndex((c) => c.host === this.#host.nativeElement);
|
|
2280
|
+
if (index < 0) {
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
// In RTL the chip cluster lays out right-to-left, so ArrowRight must
|
|
2284
|
+
// move to the visually-next chip (= DOM-previous) and ArrowLeft to the
|
|
2285
|
+
// visually-previous one (= DOM-next), with the input sitting at the
|
|
2286
|
+
// visual leftmost edge.
|
|
2287
|
+
const rtl = this.ctx.dir() === 'rtl';
|
|
2288
|
+
const prevKey = rtl ? 'ArrowRight' : 'ArrowLeft';
|
|
2289
|
+
const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight';
|
|
2290
|
+
switch (event.key) {
|
|
2291
|
+
case prevKey: {
|
|
2292
|
+
event.preventDefault();
|
|
2293
|
+
const prev = chips[index - 1];
|
|
2294
|
+
if (prev) {
|
|
2295
|
+
prev.host.focus();
|
|
2296
|
+
}
|
|
2297
|
+
break;
|
|
2298
|
+
}
|
|
2299
|
+
case nextKey: {
|
|
2300
|
+
event.preventDefault();
|
|
2301
|
+
const next = chips[index + 1];
|
|
2302
|
+
if (next) {
|
|
2303
|
+
next.host.focus();
|
|
2304
|
+
}
|
|
2305
|
+
else {
|
|
2306
|
+
// At the last chip (visual edge in either direction) — hop to the input.
|
|
2307
|
+
this.ctx.input()?.focus();
|
|
2308
|
+
}
|
|
2309
|
+
break;
|
|
2310
|
+
}
|
|
2311
|
+
case 'Backspace':
|
|
2312
|
+
case 'Delete': {
|
|
2313
|
+
event.preventDefault();
|
|
2314
|
+
const v = this.value();
|
|
2315
|
+
const prev = chips[index - 1];
|
|
2316
|
+
const next = chips[index + 1];
|
|
2317
|
+
this.ctx.removeValue(v);
|
|
2318
|
+
// Keep focus inside the chip cluster whenever a neighbour survives the
|
|
2319
|
+
// removal: prefer the previous chip, otherwise the next one (which
|
|
2320
|
+
// slides into the freed slot — so removing the first chip lands on the
|
|
2321
|
+
// new first chip rather than ejecting the user to the input). Only drop
|
|
2322
|
+
// focus into the input when the removed chip was the last one standing,
|
|
2323
|
+
// so the user can keep typing without chasing the caret.
|
|
2324
|
+
if (prev) {
|
|
2325
|
+
prev.host.focus();
|
|
2326
|
+
}
|
|
2327
|
+
else if (next) {
|
|
2328
|
+
next.host.focus();
|
|
2329
|
+
}
|
|
2330
|
+
else {
|
|
2331
|
+
this.ctx.input()?.focus();
|
|
2332
|
+
}
|
|
2333
|
+
break;
|
|
2334
|
+
}
|
|
2335
|
+
case 'Escape': {
|
|
2336
|
+
event.preventDefault();
|
|
2337
|
+
this.ctx.input()?.focus();
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChip, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2343
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: ForComboboxChip, isStandalone: true, selector: "[forComboboxChip]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "tabindex": "-1" }, listeners: { "keydown": "onKeyDown($event)" }, properties: { "attr.data-value": "dataValue()", "attr.data-disabled": "ctx.effectiveDisabled() ? \"\" : null" } }, exportAs: ["forComboboxChip"], ngImport: i0 });
|
|
2344
|
+
}
|
|
2345
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChip, decorators: [{
|
|
2346
|
+
type: Directive,
|
|
2347
|
+
args: [{
|
|
2348
|
+
selector: '[forComboboxChip]',
|
|
2349
|
+
exportAs: 'forComboboxChip',
|
|
2350
|
+
host: {
|
|
2351
|
+
tabindex: '-1',
|
|
2352
|
+
'[attr.data-value]': 'dataValue()',
|
|
2353
|
+
'[attr.data-disabled]': 'ctx.effectiveDisabled() ? "" : null',
|
|
2354
|
+
'(keydown)': 'onKeyDown($event)',
|
|
2355
|
+
},
|
|
2356
|
+
}]
|
|
2357
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: true }] }] } });
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* Remove button inside a `[forComboboxChip]`. Apply on a
|
|
2361
|
+
* `<button type="button">` so click and Space / Enter (when focused) all
|
|
2362
|
+
* trigger the same removal flow. The button is out of the Tab cycle
|
|
2363
|
+
* (`tabindex="-1"`) so the user navigates the chip cluster via the chip's
|
|
2364
|
+
* own ArrowLeft/Right + Backspace/Delete keys; this button exists for
|
|
2365
|
+
* mouse / touch users.
|
|
2366
|
+
*
|
|
2367
|
+
* The host carries a generated `aria-label` ("Remove `<chip label>`")
|
|
2368
|
+
* derived from the parent chip's resolved option label.
|
|
2369
|
+
*/
|
|
2370
|
+
class ForComboboxChipRemove {
|
|
2371
|
+
// The value type is opaque to chip-remove: it just forwards whatever the
|
|
2372
|
+
// parent chip exposes back into `removeValue`. At runtime the chip and
|
|
2373
|
+
// the parent `[forCombobox]` share the same `T` because the consumer
|
|
2374
|
+
// binds them with the same value source.
|
|
2375
|
+
ctx = injectComboboxContext('ForComboboxChipRemove');
|
|
2376
|
+
#chip = inject(ForComboboxChip, { optional: true });
|
|
2377
|
+
/** Disabled when the combobox is disabled or read-only — chip removal is unavailable. */
|
|
2378
|
+
isDisabled = computed(() => this.ctx.effectiveDisabled() || this.ctx.readonly(), /* @ts-ignore */
|
|
2379
|
+
...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
|
|
2380
|
+
constructor() {
|
|
2381
|
+
if (!this.#chip) {
|
|
2382
|
+
throw new Error('[forty-cdk/combobox] ForComboboxChipRemove must be used inside a [forComboboxChip] element.');
|
|
2383
|
+
}
|
|
2384
|
+
reflectDisabled(this.isDisabled);
|
|
2385
|
+
}
|
|
2386
|
+
ariaLabel = computed(() => `Remove ${this.#chip.label()}`, /* @ts-ignore */
|
|
2387
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
2388
|
+
onClick(event) {
|
|
2389
|
+
if (this.isDisabled()) {
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
// Don't let the click bubble up to the chip body — that would trigger
|
|
2393
|
+
// its keyboard-triggered handlers in some browsers via synthesized events.
|
|
2394
|
+
event.stopPropagation();
|
|
2395
|
+
this.ctx.removeValue(this.#chip.value());
|
|
2396
|
+
// Restore focus to the input so the user can continue typing.
|
|
2397
|
+
this.ctx.input()?.focus();
|
|
2398
|
+
}
|
|
2399
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChipRemove, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2400
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxChipRemove, isStandalone: true, selector: "[forComboboxChipRemove]", host: { attributes: { "type": "button", "tabindex": "-1" }, listeners: { "click": "onClick($event)" }, properties: { "attr.aria-label": "ariaLabel()" } }, exportAs: ["forComboboxChipRemove"], ngImport: i0 });
|
|
2401
|
+
}
|
|
2402
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxChipRemove, decorators: [{
|
|
2403
|
+
type: Directive,
|
|
2404
|
+
args: [{
|
|
2405
|
+
selector: '[forComboboxChipRemove]',
|
|
2406
|
+
exportAs: 'forComboboxChipRemove',
|
|
2407
|
+
host: {
|
|
2408
|
+
type: 'button',
|
|
2409
|
+
tabindex: '-1',
|
|
2410
|
+
'[attr.aria-label]': 'ariaLabel()',
|
|
2411
|
+
'(click)': 'onClick($event)',
|
|
2412
|
+
},
|
|
2413
|
+
}]
|
|
2414
|
+
}], ctorParameters: () => [] });
|
|
2415
|
+
|
|
2416
|
+
/**
|
|
2417
|
+
* Optional grouping wrapper inside a `[forComboboxContent]`. Renders
|
|
2418
|
+
* `role="group"` and references the descendant `[forComboboxGroupLabel]`
|
|
2419
|
+
* (if any) via `aria-labelledby`. Options inside a group still register
|
|
2420
|
+
* with the root listbox normally, so keyboard navigation traverses across
|
|
2421
|
+
* groups in DOM order without special handling.
|
|
2422
|
+
*/
|
|
2423
|
+
class ForComboboxGroup {
|
|
2424
|
+
#labelId = signal(null, /* @ts-ignore */
|
|
2425
|
+
...(ngDevMode ? [{ debugName: "#labelId" }] : /* istanbul ignore next */ []));
|
|
2426
|
+
/** The id of the registered group label (or `null` if none). */
|
|
2427
|
+
labelId = this.#labelId.asReadonly();
|
|
2428
|
+
/** Called by `ForComboboxGroupLabel` on mount. */
|
|
2429
|
+
registerLabel(id) {
|
|
2430
|
+
this.#labelId.set(id);
|
|
2431
|
+
}
|
|
2432
|
+
/** Called by `ForComboboxGroupLabel` on destroy. Idempotent. */
|
|
2433
|
+
unregisterLabel(id) {
|
|
2434
|
+
if (this.#labelId() === id) {
|
|
2435
|
+
this.#labelId.set(null);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2439
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxGroup, isStandalone: true, selector: "[forComboboxGroup]", host: { attributes: { "role": "group" }, properties: { "attr.aria-labelledby": "labelId() || null" } }, exportAs: ["forComboboxGroup"], ngImport: i0 });
|
|
2440
|
+
}
|
|
2441
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxGroup, decorators: [{
|
|
2442
|
+
type: Directive,
|
|
2443
|
+
args: [{
|
|
2444
|
+
selector: '[forComboboxGroup]',
|
|
2445
|
+
exportAs: 'forComboboxGroup',
|
|
2446
|
+
host: {
|
|
2447
|
+
role: 'group',
|
|
2448
|
+
'[attr.aria-labelledby]': 'labelId() || null',
|
|
2449
|
+
},
|
|
2450
|
+
}]
|
|
2451
|
+
}] });
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* Visible label for a `[forComboboxGroup]`. Generates a stable id and
|
|
2455
|
+
* registers with its parent group so the group's `aria-labelledby` points
|
|
2456
|
+
* at this element.
|
|
2457
|
+
*/
|
|
2458
|
+
class ForComboboxGroupLabel {
|
|
2459
|
+
/** Stable host id used by the parent group's `aria-labelledby`. */
|
|
2460
|
+
id;
|
|
2461
|
+
constructor() {
|
|
2462
|
+
const group = inject(ForComboboxGroup, { optional: true });
|
|
2463
|
+
if (!group) {
|
|
2464
|
+
throw new Error('[forty-cdk/combobox] ForComboboxGroupLabel must be used inside a [forComboboxGroup] element.');
|
|
2465
|
+
}
|
|
2466
|
+
this.id = registerA11yName(group, 'for-combobox-group-label');
|
|
2467
|
+
}
|
|
2468
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxGroupLabel, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2469
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxGroupLabel, isStandalone: true, selector: "[forComboboxGroupLabel]", host: { properties: { "id": "id()" } }, exportAs: ["forComboboxGroupLabel"], ngImport: i0 });
|
|
2470
|
+
}
|
|
2471
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxGroupLabel, decorators: [{
|
|
2472
|
+
type: Directive,
|
|
2473
|
+
args: [{
|
|
2474
|
+
selector: '[forComboboxGroupLabel]',
|
|
2475
|
+
exportAs: 'forComboboxGroupLabel',
|
|
2476
|
+
host: {
|
|
2477
|
+
'[id]': 'id()',
|
|
2478
|
+
},
|
|
2479
|
+
}]
|
|
2480
|
+
}], ctorParameters: () => [] });
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Decorative separator between options or groups inside a
|
|
2484
|
+
* `[forComboboxContent]`. Carries `role="separator"` and is intentionally
|
|
2485
|
+
* not registered with the listbox's option collection, so keyboard
|
|
2486
|
+
* navigation skips it automatically.
|
|
2487
|
+
*/
|
|
2488
|
+
class ForComboboxSeparator {
|
|
2489
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxSeparator, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2490
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: ForComboboxSeparator, isStandalone: true, selector: "[forComboboxSeparator]", host: { attributes: { "role": "separator", "aria-orientation": "horizontal" } }, exportAs: ["forComboboxSeparator"], ngImport: i0 });
|
|
2491
|
+
}
|
|
2492
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxSeparator, decorators: [{
|
|
2493
|
+
type: Directive,
|
|
2494
|
+
args: [{
|
|
2495
|
+
selector: '[forComboboxSeparator]',
|
|
2496
|
+
exportAs: 'forComboboxSeparator',
|
|
2497
|
+
host: {
|
|
2498
|
+
role: 'separator',
|
|
2499
|
+
'aria-orientation': 'horizontal',
|
|
2500
|
+
},
|
|
2501
|
+
}]
|
|
2502
|
+
}] });
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* Exact public names of every `ForCombobox` input, its models included. Spread it into the
|
|
2506
|
+
* `inputs` array of a `hostDirectives` entry so a wrapper component re-exposes the
|
|
2507
|
+
* primitive's full surface — the Signal Forms members `[formField]` binds among them —
|
|
2508
|
+
* without hand-maintaining the list. Always spread into an inline object literal as shown
|
|
2509
|
+
* below: the literal is what keeps the entry statically analyzable for consumers compiling
|
|
2510
|
+
* against the published package. An anti-drift spec fails when this list no longer matches
|
|
2511
|
+
* the directive's actual API. See `docs/wrapping-form-primitives.md` for both supported
|
|
2512
|
+
* wrapping patterns.
|
|
2513
|
+
*
|
|
2514
|
+
* @example
|
|
2515
|
+
* ```ts
|
|
2516
|
+
* @Component({
|
|
2517
|
+
* selector: 'div[myCombobox]',
|
|
2518
|
+
* template: '',
|
|
2519
|
+
* hostDirectives: [
|
|
2520
|
+
* {
|
|
2521
|
+
* directive: ForCombobox,
|
|
2522
|
+
* inputs: [...FOR_COMBOBOX_HOST_DIRECTIVE_INPUTS],
|
|
2523
|
+
* outputs: [...FOR_COMBOBOX_HOST_DIRECTIVE_OUTPUTS],
|
|
2524
|
+
* },
|
|
2525
|
+
* ],
|
|
2526
|
+
* })
|
|
2527
|
+
* export class MyCombobox {}
|
|
2528
|
+
* ```
|
|
2529
|
+
*/
|
|
2530
|
+
const FOR_COMBOBOX_HOST_DIRECTIVE_INPUTS = [
|
|
2531
|
+
'query',
|
|
2532
|
+
'value',
|
|
2533
|
+
'open',
|
|
2534
|
+
'align',
|
|
2535
|
+
'alignOffset',
|
|
2536
|
+
'ariaLabel',
|
|
2537
|
+
'arrowPadding',
|
|
2538
|
+
'autoHighlight',
|
|
2539
|
+
'autocompleteMode',
|
|
2540
|
+
'avoidCollisions',
|
|
2541
|
+
'clearOnQueryChange',
|
|
2542
|
+
'clipUntilPositioned',
|
|
2543
|
+
'collisionPadding',
|
|
2544
|
+
'commitOnSelect',
|
|
2545
|
+
'dir',
|
|
2546
|
+
'dirty',
|
|
2547
|
+
'disabled',
|
|
2548
|
+
'dismissible',
|
|
2549
|
+
'errors',
|
|
2550
|
+
'hideWhenDetached',
|
|
2551
|
+
'invalid',
|
|
2552
|
+
'isItemEqualToValue',
|
|
2553
|
+
'itemToFormValue',
|
|
2554
|
+
'itemToStringLabel',
|
|
2555
|
+
'loop',
|
|
2556
|
+
'multiple',
|
|
2557
|
+
'name',
|
|
2558
|
+
'openOnFocus',
|
|
2559
|
+
'openOnQuery',
|
|
2560
|
+
'pending',
|
|
2561
|
+
'readonly',
|
|
2562
|
+
'required',
|
|
2563
|
+
'returnFocus',
|
|
2564
|
+
'side',
|
|
2565
|
+
'sideOffset',
|
|
2566
|
+
'sticky',
|
|
2567
|
+
'totalCount',
|
|
2568
|
+
'visibleRange',
|
|
2569
|
+
'touched',
|
|
2570
|
+
];
|
|
2571
|
+
/**
|
|
2572
|
+
* Exact public names of every `ForCombobox` output, the Signal Forms `touch` output
|
|
2573
|
+
* included. Spread it into the `outputs` array of the same `hostDirectives` entry as
|
|
2574
|
+
* {@link FOR_COMBOBOX_HOST_DIRECTIVE_INPUTS}.
|
|
2575
|
+
*/
|
|
2576
|
+
const FOR_COMBOBOX_HOST_DIRECTIVE_OUTPUTS = [
|
|
2577
|
+
'queryChange',
|
|
2578
|
+
'valueChange',
|
|
2579
|
+
'openChange',
|
|
2580
|
+
'scrollToIndex',
|
|
2581
|
+
'escapeKeyDown',
|
|
2582
|
+
'pointerDownOutside',
|
|
2583
|
+
'focusOutside',
|
|
2584
|
+
'interactOutside',
|
|
2585
|
+
'autoFocusOnOpen',
|
|
2586
|
+
'autoFocusOnClose',
|
|
2587
|
+
'touchedChange',
|
|
2588
|
+
'touch',
|
|
2589
|
+
];
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Generated bundle index. Do not edit.
|
|
2593
|
+
*/
|
|
2594
|
+
|
|
2595
|
+
export { FOR_COMBOBOX_CONTEXT, FOR_COMBOBOX_DEFAULTS, FOR_COMBOBOX_HOST_DIRECTIVE_INPUTS, FOR_COMBOBOX_HOST_DIRECTIVE_OUTPUTS, FOR_COMBOBOX_OPTION, ForCombobox, ForComboboxAnchor, ForComboboxChip, ForComboboxChipRemove, ForComboboxChips, ForComboboxClear, ForComboboxContent, ForComboboxEmpty, ForComboboxGroup, ForComboboxGroupLabel, ForComboboxIndicator, ForComboboxInput, ForComboboxList, ForComboboxOption, ForComboboxSeparator, ForComboboxStatus, ForComboboxTrigger, provideForComboboxDefaults };
|
|
2596
|
+
//# sourceMappingURL=forty-cdk-combobox.mjs.map
|