forty-cdk 0.18.0 → 0.19.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/combobox/README.md +7 -5
- package/date-picker/README.md +3 -2
- package/fesm2022/forty-cdk-combobox.mjs +197 -129
- package/fesm2022/forty-cdk-combobox.mjs.map +1 -1
- package/fesm2022/forty-cdk-core.mjs +818 -245
- package/fesm2022/forty-cdk-core.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-field.mjs +2 -12
- package/fesm2022/forty-cdk-date-field.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-picker.mjs +7 -1
- package/fesm2022/forty-cdk-date-picker.mjs.map +1 -1
- package/fesm2022/forty-cdk-date-range-field.mjs +2 -7
- package/fesm2022/forty-cdk-date-range-field.mjs.map +1 -1
- package/fesm2022/forty-cdk-hover-card.mjs +7 -1
- package/fesm2022/forty-cdk-hover-card.mjs.map +1 -1
- package/fesm2022/forty-cdk-listbox.mjs +50 -30
- package/fesm2022/forty-cdk-listbox.mjs.map +1 -1
- package/fesm2022/forty-cdk-menu.mjs +21 -3
- package/fesm2022/forty-cdk-menu.mjs.map +1 -1
- package/fesm2022/forty-cdk-menubar.mjs +1 -0
- package/fesm2022/forty-cdk-menubar.mjs.map +1 -1
- package/fesm2022/forty-cdk-navigation-menu.mjs +10 -1
- package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -1
- package/fesm2022/forty-cdk-popover.mjs +7 -1
- package/fesm2022/forty-cdk-popover.mjs.map +1 -1
- package/fesm2022/forty-cdk-select.mjs +88 -74
- package/fesm2022/forty-cdk-select.mjs.map +1 -1
- package/fesm2022/forty-cdk-table.mjs +66 -30
- package/fesm2022/forty-cdk-table.mjs.map +1 -1
- package/fesm2022/forty-cdk-tabs.mjs +34 -12
- package/fesm2022/forty-cdk-tabs.mjs.map +1 -1
- package/fesm2022/forty-cdk-time-picker.mjs +7 -1
- package/fesm2022/forty-cdk-time-picker.mjs.map +1 -1
- package/fesm2022/forty-cdk-tooltip.mjs +7 -1
- package/fesm2022/forty-cdk-tooltip.mjs.map +1 -1
- package/fesm2022/forty-cdk-tree.mjs +34 -19
- package/fesm2022/forty-cdk-tree.mjs.map +1 -1
- package/hover-card/README.md +2 -1
- package/listbox/README.md +4 -4
- package/menu/README.md +2 -1
- package/navigation-menu/README.md +2 -1
- package/package.json +1 -5
- package/popover/README.md +2 -1
- package/select/README.md +6 -3
- package/shared/README.md +59 -0
- package/time-picker/README.md +2 -1
- package/tooltip/README.md +19 -10
- package/types/forty-cdk-combobox.d.ts +52 -30
- package/types/forty-cdk-core.d.ts +398 -174
- package/types/forty-cdk-listbox.d.ts +8 -3
- package/types/forty-cdk-menu.d.ts +4 -1
- package/types/forty-cdk-menubar.d.ts +1 -0
- package/types/forty-cdk-select.d.ts +10 -5
- package/types/forty-cdk-table.d.ts +17 -6
- package/types/forty-cdk-tabs.d.ts +19 -5
- package/types/forty-cdk-tree.d.ts +3 -2
- package/fesm2022/forty-cdk-signal-forms.mjs +0 -134
- package/fesm2022/forty-cdk-signal-forms.mjs.map +0 -1
- package/signal-forms/README.md +0 -72
- package/types/forty-cdk-signal-forms.d.ts +0 -69
package/combobox/README.md
CHANGED
|
@@ -20,11 +20,11 @@ Two anatomies share the same core:
|
|
|
20
20
|
The editable (default) anatomy — an `<input>` that filters a portaled listbox in place:
|
|
21
21
|
|
|
22
22
|
```html
|
|
23
|
-
<div forCombobox [(query)]="query" [(value)]="value">
|
|
23
|
+
<div forCombobox #combobox="forCombobox" [(query)]="query" [(value)]="value">
|
|
24
24
|
<input forComboboxInput placeholder="Search…" />
|
|
25
25
|
<button forComboboxClear>×</button>
|
|
26
26
|
|
|
27
|
-
<!-- @if (open()) { -->
|
|
27
|
+
<!-- @if (combobox.open()) { -->
|
|
28
28
|
<div forComboboxContent>
|
|
29
29
|
<div forComboboxOption [value]="item.id" [label]="item.label">
|
|
30
30
|
<span forComboboxIndicator>✓</span>
|
|
@@ -106,7 +106,7 @@ Static and `@for`-rendered options share the same registry, navigation order (DO
|
|
|
106
106
|
|
|
107
107
|
For a legacy `<form action="…">` flow, set `[name]` — the directive mirrors `[(value)]` into N `<input type="hidden">` siblings (one per array entry; zero when empty). String values land verbatim in the hidden input; object values default to `JSON.stringify` (override via `[itemToFormValue]`, see below).
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
A single-select field is modeled as the same `readonly T[]`, kept at length ≤ 1, and bound with `[formField]` directly — single mode needs no adapter. A `FieldTree<T | null>` cannot bind here; map to that shape at the edge that needs it. See [the selection value-type contract](../../../docs/selection-value-type-contract.md).
|
|
110
110
|
|
|
111
111
|
## API
|
|
112
112
|
|
|
@@ -191,6 +191,8 @@ Why the list part is required, not optional: a `role="listbox"` may only own `op
|
|
|
191
191
|
|
|
192
192
|
**Focus hand-off.** Registering a trigger opts the combobox into the standard trigger-anchored focus model: on open, focus moves into the input (the search field inside the panel); on close, focus returns to the trigger. Both moves are vetoable via `(autoFocusOnOpen)` / `(autoFocusOnClose)` on `[forCombobox]`, and the return is gated by `[returnFocus]` (default `true`). Escape stays owned by the input. See [Focus & the `(autoFocusOnOpen)` / `(autoFocusOnClose)` hooks](#focus--the-autofocusonopen--autofocusonclose-hooks).
|
|
193
193
|
|
|
194
|
+
**A trigger that registers late still owns the hand-off.** The trigger does not have to be declared before `[forComboboxContent]`, and does not have to exist when the panel first mounts — project it through `<ng-content>` from a wrapper, put it under a `@defer`, or gate it on an `@if` over loaded data. One caveat: focus moving _into_ the panel is a mount-time event, so a trigger that arrives while the panel is **already open** does not retroactively pull focus out of wherever you left it. From that moment on it does own the return focus, `(autoFocusOnClose)` and the Escape fallback, and the next open moves focus into the input as usual.
|
|
195
|
+
|
|
194
196
|
**Trigger keyboard.** Click / Enter / Space toggle (open moves focus into the input). ArrowDown opens with the first enabled option highlighted; ArrowUp opens with the last.
|
|
195
197
|
|
|
196
198
|
**Anchor preference.** With a trigger present the panel anchors to it by default. An explicit `[forComboboxAnchor]` still wins (explicit anchor → trigger → input), so you can wrap a decorated trigger box and anchor against it.
|
|
@@ -415,7 +417,7 @@ The `autocompleteMode` input mirrors the WAI-ARIA `aria-autocomplete` property:
|
|
|
415
417
|
|
|
416
418
|
Inline completion preserves the user's typed prefix as unselected and selects the appended remainder, so the next keystroke replaces the selection (matching native browser autofill behavior). Backspace deletes the selection without re-completing, so the user can always shorten the query.
|
|
417
419
|
|
|
418
|
-
> **Pure `'inline'` needs a warm cache.** `'inline'` never opens the popup (per APG — `aria-autocomplete="inline"` has no listbox), so in the default `@if (open())` anatomy no `[forComboboxOption]` ever renders and the label cache starts cold. A first keystroke into a combobox that has never been opened completes against nothing; inline completion only works once the options have rendered at least once (the user opened the popup via ArrowDown or `[openOnFocus]`, warming the cache). If completion must work from the very first keystroke, use `'both'`
|
|
420
|
+
> **Pure `'inline'` needs a warm cache.** `'inline'` never opens the popup (per APG — `aria-autocomplete="inline"` has no listbox), so in the default `@if (open())` anatomy no `[forComboboxOption]` ever renders and the label cache starts cold. A first keystroke into a combobox that has never been opened completes against nothing; inline completion only works once the options have rendered at least once (the user opened the popup via ArrowDown or `[openOnFocus]`, warming the cache). If completion must work from the very first keystroke, use `'both'` — it opens the popup, so the options render and the cache warms. Leaving `[forComboboxContent]` permanently mounted instead is not a supported shape and warns in dev mode: mount **is** the open state for this surface, so it never runs `animate.enter` / `animate.leave`, and its dismissible layer stays active while closed.
|
|
419
421
|
|
|
420
422
|
## Dismiss events
|
|
421
423
|
|
|
@@ -518,7 +520,7 @@ How navigation flows when virtualizing:
|
|
|
518
520
|
3. Your virtualizer scrolls; the directive's `@for` mounts the option for index 999.
|
|
519
521
|
4. As soon as that option registers (at the matching `posInSet`), the directive seeds `aria-activedescendant` to its id.
|
|
520
522
|
|
|
521
|
-
|
|
523
|
+
Inline autocomplete matches against the most recently rendered window overlaid with the position snapshot, so completion against off-screen labels still works. `selected().label` reads a separate, selection-keyed cache instead — bounded by the selection, so the label of a selected option survives close / re-open and any number of query rebuilds, but it is **not** resolved from the position snapshot: a value that enters the selection while its option is outside the rendered window (a `[(value)]` write restoring a saved selection, say) falls back to `[itemToStringLabel]` until that option renders once. Supply `[itemToStringLabel]` whenever the selection can be seeded from outside the list.
|
|
522
524
|
|
|
523
525
|
```html
|
|
524
526
|
<div
|
package/date-picker/README.md
CHANGED
|
@@ -18,18 +18,19 @@ All date math and formatting go through a `DateAdapter<D>`, shared with `ForCale
|
|
|
18
18
|
## Anatomy
|
|
19
19
|
|
|
20
20
|
```html
|
|
21
|
-
<div forDatePicker [(value)]="date" [(open)]="open" name="dob">
|
|
21
|
+
<div forDatePicker #picker="forDatePicker" [(value)]="date" [(open)]="open" name="dob">
|
|
22
22
|
<!-- optional: wrap a decorated field box in [forDatePickerAnchor] to position against it -->
|
|
23
23
|
<button forDatePickerTrigger>
|
|
24
24
|
<span forDatePickerValue placeholder="Pick a date"></span>
|
|
25
25
|
</button>
|
|
26
26
|
|
|
27
|
-
<!--
|
|
27
|
+
<!-- @if (picker.open()) { -->
|
|
28
28
|
<div forDatePickerContent>
|
|
29
29
|
<div forCalendar [(value)]="date">
|
|
30
30
|
<!-- …calendar header + grid… -->
|
|
31
31
|
</div>
|
|
32
32
|
</div>
|
|
33
|
+
<!-- } -->
|
|
33
34
|
</div>
|
|
34
35
|
```
|
|
35
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { linkedSignal, untracked, InjectionToken, inject, computed, model, input, booleanAttribute, numberAttribute, output, effect, Directive, ElementRef, DOCUMENT,
|
|
3
|
-
import {
|
|
2
|
+
import { linkedSignal, untracked, InjectionToken, inject, computed, model, input, booleanAttribute, numberAttribute, output, effect, Directive, ElementRef, DOCUMENT, signal, afterNextRender } from '@angular/core';
|
|
3
|
+
import { isUnset, runVirtualizedNavigatorBridge, createDefaults, VirtualizedNavigator as VirtualizedNavigator$1, FormUiControlBase, ElementRegistry, Collection, defaultItemToFormValue, injectTextDirection, InitialFocusState, CloseReasonState, createPointerSuppression, LabelCache, singleSelected, injectHiddenInput, isInArray, toggleInArray, nextEnabledHandle, emitVetoableEvent, emitVetoableNativeEvent, registerHandle, hostButtonType, reflectDisabled, foldTypeaheadText, hostAriaLabel, hostLabelledBy, warnIfMountedWhileClosed, injectOverlayShell, unsetInput, hostId, accessibleTextContent, assertInputBound, registerA11yName } from 'forty-cdk/core';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Build the host's activedescendant pointer as a `linkedSignal` so the
|
|
@@ -59,7 +59,7 @@ function createActiveIdSignal(source) {
|
|
|
59
59
|
* Resolve the auto-highlight seed for the **non-virtualized** combobox: the id
|
|
60
60
|
* of the option `aria-activedescendant` should fall on when the listbox is open
|
|
61
61
|
* and no pointer survives. Returns `null` when no enabled option qualifies (or
|
|
62
|
-
* when a `'selected'` seed can't yet read its options — see `
|
|
62
|
+
* when a `'selected'` seed can't yet read its options — see `isUnset`).
|
|
63
63
|
*
|
|
64
64
|
* Pure over its inputs — no signal reads, no DOM access — so the host can call
|
|
65
65
|
* it from inside its `#activeId` linkedSignal computation without leaking the
|
|
@@ -109,11 +109,11 @@ function findSelectedEnabled(items, values, equals) {
|
|
|
109
109
|
if (item.disabled()) {
|
|
110
110
|
continue;
|
|
111
111
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
112
|
+
const value = item.value();
|
|
113
|
+
if (isUnset(value)) {
|
|
114
114
|
return NOT_READY;
|
|
115
115
|
}
|
|
116
|
-
if (values.some((sel) => equals(
|
|
116
|
+
if (values.some((sel) => equals(value, sel))) {
|
|
117
117
|
return item;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -125,13 +125,20 @@ function findSelectedEnabled(items, values, equals) {
|
|
|
125
125
|
* {@link createActiveIdSignal}; this only performs the side effects that escape
|
|
126
126
|
* the reactive graph. It reacts to `items()`, `autoHighlight()` and `open()`:
|
|
127
127
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
128
|
+
* The label cache is deliberately **not** pulled here: pulling it tracks the
|
|
129
|
+
* selection, and this effect writes activedescendant and scrolls, so it would
|
|
130
|
+
* re-run those writes on every commit of `value`. The root owns a separate
|
|
131
|
+
* read-only effect for that pull, and `require-sanctioned-pull-marker` now
|
|
132
|
+
* enforces the split. The position-map pull runs through the shared
|
|
133
|
+
* {@link runVirtualizedNavigatorBridge} and **does** share this effect with
|
|
134
|
+
* those writes — the one place in the library where a pull and a write live
|
|
135
|
+
* together. It is the asymmetry that makes it safe: the position map's sources
|
|
136
|
+
* (`items` / `totalCount` / the data version) are the ones this bridge already
|
|
137
|
+
* tracks through its own `items()` read, so priming it widens nothing, whereas
|
|
138
|
+
* the label cache would have added the selection. It runs only when the consumer
|
|
139
|
+
* set `totalCount()`, so a plain combobox never builds the navigator.
|
|
140
|
+
*
|
|
141
|
+
* 1. Virtualized only: resolves a pending `(scrollToIndex)` navigation — once
|
|
135
142
|
* the option for the requested posInSet mounts, `tryResolvePending` seeds
|
|
136
143
|
* activedescendant to its id and scrolls it into view. This is the single
|
|
137
144
|
* sanctioned activedescendant write from an effect, and it is a legitimate
|
|
@@ -141,7 +148,7 @@ function findSelectedEnabled(items, values, equals) {
|
|
|
141
148
|
* linkedSignal. `seedFromIndexedSnapshot` then seeds the topmost / bottommost
|
|
142
149
|
* *rendered* enabled option (ordered by absolute `posInSet`) deliberately
|
|
143
150
|
* passively — it only moves the pointer, never the consumer's scroll position.
|
|
144
|
-
*
|
|
151
|
+
* 2. Non-virtualized: scrolls the auto-highlight-seeded option into view so a
|
|
145
152
|
* seed that lands below the fold is visible, for parity with `navigate()`.
|
|
146
153
|
* The seed itself comes from the linkedSignal; this is its imperative tail.
|
|
147
154
|
* The activedescendant is read `untracked` so the scroll never re-triggers
|
|
@@ -157,15 +164,18 @@ function findSelectedEnabled(items, values, equals) {
|
|
|
157
164
|
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
158
165
|
*/
|
|
159
166
|
function runAutoHighlightBridge(deps) {
|
|
160
|
-
deps.labelCache.prime();
|
|
161
167
|
const items = deps.items();
|
|
162
168
|
const open = deps.open();
|
|
163
169
|
const autoHighlight = deps.autoHighlight();
|
|
164
170
|
const virtualized = deps.virtualized();
|
|
165
171
|
if (virtualized) {
|
|
166
172
|
const navigator = deps.requireNavigator();
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
const resolved = runVirtualizedNavigatorBridge({
|
|
174
|
+
items: deps.items,
|
|
175
|
+
virtualized: deps.virtualized,
|
|
176
|
+
requireNavigator: () => navigator,
|
|
177
|
+
});
|
|
178
|
+
if (resolved) {
|
|
169
179
|
return;
|
|
170
180
|
}
|
|
171
181
|
if (autoHighlight && open && untracked(() => deps.getActiveId()) === null && items.length > 0) {
|
|
@@ -267,13 +277,48 @@ function provideForComboboxDefaults(defaults = {}) {
|
|
|
267
277
|
return provideDefaults(defaults);
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Overlay the virtualized position snapshot onto the label cache's window
|
|
282
|
+
* entries, so inline autocomplete keeps matching options scrolled out of view.
|
|
283
|
+
*
|
|
284
|
+
* The label cache holds one window at a time, while the navigator's position map
|
|
285
|
+
* accumulates every position it has folded (bounded by `totalCount`, purged on a
|
|
286
|
+
* dataset rebuild). Window entries take precedence — they are the freshest read —
|
|
287
|
+
* and off-window entries follow, sorted by absolute position so a completion walk
|
|
288
|
+
* meets them in list order. De-duplication is by serialized form value, not by
|
|
289
|
+
* `id`: an option that unmounts and remounts under a new id but the same value is
|
|
290
|
+
* one option.
|
|
291
|
+
*
|
|
292
|
+
* Pass an empty map (the non-virtualized case) to get `entries` back unchanged.
|
|
293
|
+
*
|
|
294
|
+
* Internal — not re-exported from `combobox/public-api.ts`.
|
|
295
|
+
*/
|
|
296
|
+
function mergeOffWindowEntries(entries, indexed, toFormValue) {
|
|
297
|
+
if (indexed.size === 0) {
|
|
298
|
+
return entries;
|
|
299
|
+
}
|
|
300
|
+
const seen = new Set(entries.map((entry) => toFormValue(entry.value)));
|
|
301
|
+
const merged = [...entries];
|
|
302
|
+
for (const pos of [...indexed.keys()].sort((a, b) => a - b)) {
|
|
303
|
+
const entry = indexed.get(pos);
|
|
304
|
+
const key = toFormValue(entry.value);
|
|
305
|
+
if (seen.has(key)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
seen.add(key);
|
|
309
|
+
merged.push(entry);
|
|
310
|
+
}
|
|
311
|
+
return merged;
|
|
312
|
+
}
|
|
313
|
+
|
|
270
314
|
/**
|
|
271
315
|
* Virtualization navigation engine for `ForCombobox`. A thin adapter over the
|
|
272
316
|
* shared `_internal/virtualized-navigator` engine. Maps the combobox option
|
|
273
|
-
* handle (whose `posInSet` is optional) onto the engine's accessors,
|
|
274
|
-
* option
|
|
275
|
-
* with the host's pointer-suppression wrapper. Adds the
|
|
276
|
-
* auto-highlight seed that stays passive (never scrolls the
|
|
317
|
+
* handle (whose `posInSet` is optional) onto the engine's accessors, skips an
|
|
318
|
+
* option whose `[value]` binding is not written yet, and overrides
|
|
319
|
+
* scroll-into-view with the host's pointer-suppression wrapper. Adds the
|
|
320
|
+
* combobox-only auto-highlight seed that stays passive (never scrolls the
|
|
321
|
+
* window).
|
|
277
322
|
*
|
|
278
323
|
* Internal — not re-exported from `combobox/index.ts` or `public-api.ts`.
|
|
279
324
|
*/
|
|
@@ -286,12 +331,11 @@ class VirtualizedNavigator {
|
|
|
286
331
|
posOf: (o) => o.posInSet?.() ?? null,
|
|
287
332
|
idOf: (o) => o.id(),
|
|
288
333
|
hostOf: (o) => o.host,
|
|
289
|
-
readEntry: (o) =>
|
|
290
|
-
id
|
|
291
|
-
value
|
|
292
|
-
label: o.label(),
|
|
293
|
-
|
|
294
|
-
})),
|
|
334
|
+
readEntry: (o) => {
|
|
335
|
+
const id = o.id();
|
|
336
|
+
const value = o.value();
|
|
337
|
+
return isUnset(value) ? null : { id, value, label: o.label(), disabled: o.disabled() };
|
|
338
|
+
},
|
|
295
339
|
scrollIntoView: (host) => deps.scrollActiveIntoView(host),
|
|
296
340
|
}, { deferFoldOnTotalTransition: true });
|
|
297
341
|
}
|
|
@@ -679,14 +723,18 @@ class ForCombobox extends FormUiControlBase {
|
|
|
679
723
|
#lastPositionedId = null;
|
|
680
724
|
#pointerSuppression = createPointerSuppression();
|
|
681
725
|
/**
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
726
|
+
* Bounded option-label cache, shared with `[forSelect]` through
|
|
727
|
+
* `forty-cdk/core`. Keeps `{ id, value, label, disabled }` tuples across
|
|
728
|
+
* close/open in two projections: the selection-keyed one backs
|
|
729
|
+
* {@link selected}'s chip labels (bounded by the selection, so a long-lived
|
|
730
|
+
* remote-search combobox retains the labels of what is selected and nothing
|
|
731
|
+
* else), and the last-window one backs {@link completionEntries} for inline
|
|
732
|
+
* autocomplete. Off-window matching while virtualizing comes from the
|
|
733
|
+
* navigator's position map instead — see {@link completionEntries}.
|
|
686
734
|
*/
|
|
687
|
-
#labelCache = new
|
|
735
|
+
#labelCache = new LabelCache({
|
|
688
736
|
items: this.#items.items,
|
|
689
|
-
|
|
737
|
+
value: this.value,
|
|
690
738
|
itemToFormValue: this.itemToFormValue,
|
|
691
739
|
});
|
|
692
740
|
/**
|
|
@@ -727,10 +775,7 @@ class ForCombobox extends FormUiControlBase {
|
|
|
727
775
|
if (values.length === 0) {
|
|
728
776
|
return [];
|
|
729
777
|
}
|
|
730
|
-
|
|
731
|
-
// snapshot when virtualizing, so a selected option scrolled out of view
|
|
732
|
-
// still resolves here without a separate position-map lookup.
|
|
733
|
-
const cached = this.cachedOptions();
|
|
778
|
+
const cached = this.selectedEntries();
|
|
734
779
|
const equals = this.compareWith();
|
|
735
780
|
const toLabel = this.itemToStringLabel();
|
|
736
781
|
return values.map((v) => {
|
|
@@ -782,14 +827,22 @@ class ForCombobox extends FormUiControlBase {
|
|
|
782
827
|
serialize: (item) => this.itemToFormValue()(item),
|
|
783
828
|
disabled: this.effectiveDisabled,
|
|
784
829
|
});
|
|
830
|
+
// @sanctioned-pull(label-cache-window): the option window exists only while
|
|
831
|
+
// the listbox is open, and the closed-state fallback reading it has no
|
|
832
|
+
// reader during that cycle.
|
|
833
|
+
effect(() => {
|
|
834
|
+
this.#labelCache.prime();
|
|
835
|
+
});
|
|
785
836
|
// The activedescendant *decision* is a pure derivation in `#activeId`; this
|
|
786
|
-
// effect runs only its imperative tail (
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
837
|
+
// effect runs only its imperative tail (virtualized pending-nav resolution +
|
|
838
|
+
// passive seed, non-virtualized scroll-into-view). It is the library's one
|
|
839
|
+
// pull sharing an effect with writes: the position map's sources are the ones
|
|
840
|
+
// the bridge already tracks, so priming it widens nothing. Full rationale
|
|
841
|
+
// lives with `runAutoHighlightBridge` in `combobox-auto-highlight.ts`.
|
|
842
|
+
// @sanctioned-pull(navigator-position-map): the rendered window is transient,
|
|
843
|
+
// so a window nothing reads during is lost to the lazy fold.
|
|
790
844
|
effect(() => {
|
|
791
845
|
runAutoHighlightBridge({
|
|
792
|
-
labelCache: this.#labelCache,
|
|
793
846
|
requireNavigator: () => this.#requireNavigator(),
|
|
794
847
|
items: this.#items.items,
|
|
795
848
|
open: this.open,
|
|
@@ -901,6 +954,9 @@ class ForCombobox extends FormUiControlBase {
|
|
|
901
954
|
return;
|
|
902
955
|
}
|
|
903
956
|
const v = handle.value();
|
|
957
|
+
if (isUnset(v)) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
904
960
|
if (this.multiple()) {
|
|
905
961
|
// Toggle in/out of the array. Stay open so the user can keep picking.
|
|
906
962
|
this.value.set(toggleInArray(this.value(), v, this.compareWith()));
|
|
@@ -1041,25 +1097,19 @@ class ForCombobox extends FormUiControlBase {
|
|
|
1041
1097
|
active.host.scrollIntoView?.({ block: 'nearest' });
|
|
1042
1098
|
this.#lastPositionedId = id;
|
|
1043
1099
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
return this.#labelCache.entries();
|
|
1047
|
-
}
|
|
1048
|
-
return this.#labelCache.mergedEntries(this.#requireNavigator().snapshotByPos());
|
|
1049
|
-
}, /* @ts-ignore */
|
|
1050
|
-
...(ngDevMode ? [{ debugName: "#cachedOptionsMemo" }] : /* istanbul ignore next */ []));
|
|
1051
|
-
cachedOptions() {
|
|
1052
|
-
return this.#cachedOptionsMemo();
|
|
1100
|
+
selectedEntries() {
|
|
1101
|
+
return this.#labelCache.selectedEntries();
|
|
1053
1102
|
}
|
|
1054
|
-
#
|
|
1103
|
+
#completionEntriesMemo = computed(() => {
|
|
1104
|
+
const liveWindow = this.#labelCache.windowEntries();
|
|
1055
1105
|
if (this.totalCount() === undefined) {
|
|
1056
|
-
return
|
|
1106
|
+
return liveWindow;
|
|
1057
1107
|
}
|
|
1058
|
-
return
|
|
1108
|
+
return mergeOffWindowEntries(liveWindow, this.#requireNavigator().snapshotByPos(), this.itemToFormValue());
|
|
1059
1109
|
}, /* @ts-ignore */
|
|
1060
|
-
...(ngDevMode ? [{ debugName: "#
|
|
1061
|
-
|
|
1062
|
-
return this.#
|
|
1110
|
+
...(ngDevMode ? [{ debugName: "#completionEntriesMemo" }] : /* istanbul ignore next */ []));
|
|
1111
|
+
completionEntries() {
|
|
1112
|
+
return this.#completionEntriesMemo();
|
|
1063
1113
|
}
|
|
1064
1114
|
clear(clearQuery = true) {
|
|
1065
1115
|
if (this.effectiveDisabled() || this.readonly()) {
|
|
@@ -1516,7 +1566,7 @@ class ForComboboxInput {
|
|
|
1516
1566
|
}
|
|
1517
1567
|
}
|
|
1518
1568
|
const match = this.ctx
|
|
1519
|
-
.
|
|
1569
|
+
.completionEntries()
|
|
1520
1570
|
.find((o) => !o.disabled && foldTypeaheadText(o.label).startsWith(folded));
|
|
1521
1571
|
return match ? match.label : null;
|
|
1522
1572
|
}
|
|
@@ -1677,7 +1727,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
|
|
|
1677
1727
|
* `@floating-ui/dom` against the anchor (explicit `[forComboboxAnchor]` →
|
|
1678
1728
|
* `[forComboboxTrigger]` → input).
|
|
1679
1729
|
*
|
|
1680
|
-
* Two anatomies
|
|
1730
|
+
* Two anatomies. The **role split** is picked by whether an inner
|
|
1731
|
+
* `[forComboboxList]` is present:
|
|
1681
1732
|
*
|
|
1682
1733
|
* - **Editable (no list)** — content itself carries `role="listbox"`,
|
|
1683
1734
|
* `tabindex="-1"`, `aria-multiselectable`, and the labelled role
|
|
@@ -1696,19 +1747,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
|
|
|
1696
1747
|
* trigger are exempt from outside checks.
|
|
1697
1748
|
*
|
|
1698
1749
|
* Focus:
|
|
1699
|
-
* - **Editable anatomy** — focus normally stays in the input
|
|
1700
|
-
* open lifecycle and active-option highlighting is
|
|
1701
|
-
* driven, so the directive exposes no focus hooks. The
|
|
1702
|
-
* focusable (`tabindex="-1"`), so a click on non-option
|
|
1703
|
-
* onto it; the input's inline Escape handler then never
|
|
1704
|
-
* shell wires a fallback Escape channel that closes the
|
|
1705
|
-
* to the input.
|
|
1706
|
-
* - **Picker anatomy** — on open, focus moves into the input
|
|
1707
|
-
* inside the panel); on close it returns to the trigger.
|
|
1708
|
-
* vetoable via `(autoFocusOnOpen)` / `(autoFocusOnClose)` on
|
|
1709
|
-
* and the return is gated by `[returnFocus]`. Escape from the
|
|
1710
|
-
* the input directive; the shell's fallback channel covers
|
|
1711
|
-
* the surface or list instead.
|
|
1750
|
+
* - **Editable anatomy (no trigger)** — focus normally stays in the input
|
|
1751
|
+
* across the whole open lifecycle and active-option highlighting is
|
|
1752
|
+
* `aria-activedescendant`-driven, so the directive exposes no focus hooks. The
|
|
1753
|
+
* surface itself is focusable (`tabindex="-1"`), so a click on non-option
|
|
1754
|
+
* padding can move focus onto it; the input's inline Escape handler then never
|
|
1755
|
+
* sees the key, so the shell wires a fallback Escape channel that closes the
|
|
1756
|
+
* popup and returns focus to the input.
|
|
1757
|
+
* - **Picker anatomy (trigger present)** — on open, focus moves into the input
|
|
1758
|
+
* (the search field inside the panel); on close it returns to the trigger.
|
|
1759
|
+
* Both moves are vetoable via `(autoFocusOnOpen)` / `(autoFocusOnClose)` on
|
|
1760
|
+
* `[forCombobox]`, and the return is gated by `[returnFocus]`. Escape from the
|
|
1761
|
+
* input is owned by the input directive; the shell's fallback channel covers
|
|
1762
|
+
* presses that land on the surface or list instead.
|
|
1763
|
+
*
|
|
1764
|
+
* So the two splits are keyed independently — roles off `hasList`, focus off
|
|
1765
|
+
* the trigger — and the trigger check is a `computed`, consulted at each
|
|
1766
|
+
* decision point rather than snapshotted at construction
|
|
1767
|
+
* ([#1581](https://github.com/tutkli/forty-cdk/issues/1581)) — so a trigger
|
|
1768
|
+
* declared after this content, projected through `<ng-content>`, or gated by a
|
|
1769
|
+
* `@defer` / data-driven `@if` upgrades the surface instead of leaving it with
|
|
1770
|
+
* no focus management at all. Initial focus stays a mount-time event (the shell
|
|
1771
|
+
* decides it in `afterNextRender`, by which point a trigger constructed in the
|
|
1772
|
+
* same pass has registered), so a trigger arriving in a later pass while the
|
|
1773
|
+
* surface is already open does not retroactively pull focus out of wherever the
|
|
1774
|
+
* user left it — it takes over the return focus, `(autoFocusOnClose)` and the
|
|
1775
|
+
* Escape fallback from there on.
|
|
1712
1776
|
*
|
|
1713
1777
|
* The lifecycle (positioner + dismissible layer, plus the picker anatomy's
|
|
1714
1778
|
* focus bundles) is owned by the shared `injectOverlayShell` helper.
|
|
@@ -1723,27 +1787,20 @@ class ForComboboxContent {
|
|
|
1723
1787
|
constructor() {
|
|
1724
1788
|
const ctx = this.ctx;
|
|
1725
1789
|
registerHandle(this.#host.nativeElement, (el) => ctx.registerContent(el), (el) => ctx.unregisterContent(el));
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
//
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
'so the picker focus behavior was not wired for this content: focus will not move into ' +
|
|
1741
|
-
'[forComboboxInput] on open, will not return to the trigger on close, and (autoFocusOnOpen) / ' +
|
|
1742
|
-
'(autoFocusOnClose) will not fire. Declare [forComboboxTrigger] before (and outside) the ' +
|
|
1743
|
-
'@if (open()) block that gates [forComboboxContent] so it registers first.');
|
|
1744
|
-
}
|
|
1745
|
-
});
|
|
1746
|
-
}
|
|
1790
|
+
warnIfMountedWhileClosed({
|
|
1791
|
+
primitive: 'combobox',
|
|
1792
|
+
piece: '[forComboboxContent]',
|
|
1793
|
+
condition: 'combobox.open()',
|
|
1794
|
+
open: ctx.open,
|
|
1795
|
+
});
|
|
1796
|
+
// Both focus bundles are always handed to the shell; the anatomy gates them
|
|
1797
|
+
// from inside their own callbacks, which the shell runs after construction
|
|
1798
|
+
// (`initialFocus` in `afterNextRender`, `returnFocus` on destroy). So the
|
|
1799
|
+
// gate reads a fresh `trigger()` at each decision point instead of a
|
|
1800
|
+
// construction-time snapshot, and the editable anatomy still performs no
|
|
1801
|
+
// imperative move and fires neither hook.
|
|
1802
|
+
const hasTrigger = computed(() => ctx.trigger() !== null, /* @ts-ignore */
|
|
1803
|
+
...(ngDevMode ? [{ debugName: "hasTrigger" }] : /* istanbul ignore next */ []));
|
|
1747
1804
|
const config = {
|
|
1748
1805
|
positioner: {
|
|
1749
1806
|
kind: 'floating',
|
|
@@ -1766,7 +1823,7 @@ class ForComboboxContent {
|
|
|
1766
1823
|
requestClose: (reason) => ctx.requestClose(reason),
|
|
1767
1824
|
emitEscapeKeyDown: (event) => {
|
|
1768
1825
|
ctx.emitEscapeKeyDown(event);
|
|
1769
|
-
if (!hasTrigger && !ctx.open()) {
|
|
1826
|
+
if (!hasTrigger() && !ctx.open()) {
|
|
1770
1827
|
ctx.input()?.focus();
|
|
1771
1828
|
}
|
|
1772
1829
|
},
|
|
@@ -1791,36 +1848,36 @@ class ForComboboxContent {
|
|
|
1791
1848
|
},
|
|
1792
1849
|
},
|
|
1793
1850
|
// Picker anatomy only: move focus into the input on open, return it to the
|
|
1794
|
-
// trigger on close.
|
|
1795
|
-
// so
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1851
|
+
// trigger on close. The editable anatomy keeps focus in the input
|
|
1852
|
+
// throughout, so its gate vetoes / skips before either hook is emitted.
|
|
1853
|
+
initialFocus: {
|
|
1854
|
+
move: () => {
|
|
1855
|
+
const input = ctx.input();
|
|
1856
|
+
if (input) {
|
|
1857
|
+
input.focus();
|
|
1858
|
+
return true;
|
|
1859
|
+
}
|
|
1860
|
+
return false;
|
|
1861
|
+
},
|
|
1862
|
+
veto: () => !hasTrigger() || ctx.emitAutoFocusOnOpen(),
|
|
1863
|
+
},
|
|
1864
|
+
returnFocus: {
|
|
1865
|
+
enabled: ctx.returnFocus,
|
|
1866
|
+
target: () => ctx.trigger(),
|
|
1867
|
+
veto: () => ctx.emitAutoFocusOnClose(),
|
|
1868
|
+
// The anatomy gate comes first: the editable anatomy never reaches
|
|
1869
|
+
// `veto`, so it never emits. Then, on Tab and on outside dismissal
|
|
1870
|
+
// (pointer-down-outside / focus-outside) focus already landed where the
|
|
1871
|
+
// user tabbed or clicked; re-focusing the trigger would steal it back
|
|
1872
|
+
// (native <select> parity, mirroring popover #1310).
|
|
1873
|
+
skip: () => {
|
|
1874
|
+
if (!hasTrigger()) {
|
|
1875
|
+
return true;
|
|
1876
|
+
}
|
|
1877
|
+
const reason = ctx.lastCloseReason();
|
|
1878
|
+
return reason === 'tab' || reason === 'pointerDownOutside' || reason === 'focusOutside';
|
|
1879
|
+
},
|
|
1880
|
+
},
|
|
1824
1881
|
};
|
|
1825
1882
|
injectOverlayShell(config);
|
|
1826
1883
|
}
|
|
@@ -1943,8 +2000,12 @@ class ForComboboxOption {
|
|
|
1943
2000
|
* the parent `[forCombobox]` over a richer `T`. The parent's
|
|
1944
2001
|
* `[compareWith]` decides how options are matched against the
|
|
1945
2002
|
* committed selection.
|
|
2003
|
+
*
|
|
2004
|
+
* Mandatory: it is seeded with the `unsetInput` sentinel rather than declared
|
|
2005
|
+
* `input.required` so the parent can skip an option that has registered but
|
|
2006
|
+
* not been bound yet, and an unbound option throws in dev mode.
|
|
1946
2007
|
*/
|
|
1947
|
-
value = input
|
|
2008
|
+
value = input(unsetInput(), /* @ts-ignore */
|
|
1948
2009
|
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1949
2010
|
/**
|
|
1950
2011
|
* Visible label used by `[forComboboxInput]` for inline autocomplete
|
|
@@ -1966,7 +2027,10 @@ class ForComboboxOption {
|
|
|
1966
2027
|
posInSet = input(null, /* @ts-ignore */
|
|
1967
2028
|
...(ngDevMode ? [{ debugName: "posInSet" }] : /* istanbul ignore next */ []));
|
|
1968
2029
|
id = hostId('for-combobox-option');
|
|
1969
|
-
selected = computed(() =>
|
|
2030
|
+
selected = computed(() => {
|
|
2031
|
+
const value = this.value();
|
|
2032
|
+
return isUnset(value) ? false : this.#ctx.isSelected(value);
|
|
2033
|
+
}, /* @ts-ignore */
|
|
1970
2034
|
...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
1971
2035
|
/** True when this option is the current activedescendant. Reflected as `data-highlighted`. */
|
|
1972
2036
|
highlighted = computed(() => this.#ctx.isActive(this.id()), /* @ts-ignore */
|
|
@@ -2001,6 +2065,9 @@ class ForComboboxOption {
|
|
|
2001
2065
|
return explicit;
|
|
2002
2066
|
}
|
|
2003
2067
|
const v = this.value();
|
|
2068
|
+
if (isUnset(v)) {
|
|
2069
|
+
return '';
|
|
2070
|
+
}
|
|
2004
2071
|
if (typeof v === 'string') {
|
|
2005
2072
|
// String mode: the trimmed `textContent` is the canonical fallback,
|
|
2006
2073
|
// identical to the pre-generic behaviour. Lets consumers omit
|
|
@@ -2021,6 +2088,7 @@ class ForComboboxOption {
|
|
|
2021
2088
|
posInSet: this.posInSet,
|
|
2022
2089
|
};
|
|
2023
2090
|
constructor() {
|
|
2091
|
+
assertInputBound(this.value, 'combobox', '[forComboboxOption]', 'value');
|
|
2024
2092
|
registerHandle(this.#handle, (h) => this.#ctx.registerOption(h), (h) => this.#ctx.unregisterOption(h));
|
|
2025
2093
|
}
|
|
2026
2094
|
onClick() {
|
|
@@ -2038,7 +2106,7 @@ class ForComboboxOption {
|
|
|
2038
2106
|
}
|
|
2039
2107
|
}
|
|
2040
2108
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxOption, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2041
|
-
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:
|
|
2109
|
+
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: false, 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 });
|
|
2042
2110
|
}
|
|
2043
2111
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ForComboboxOption, decorators: [{
|
|
2044
2112
|
type: Directive,
|
|
@@ -2060,7 +2128,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
|
|
|
2060
2128
|
'(pointermove)': 'onPointerMove()',
|
|
2061
2129
|
},
|
|
2062
2130
|
}]
|
|
2063
|
-
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required:
|
|
2131
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], 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 }] }] } });
|
|
2064
2132
|
|
|
2065
2133
|
/**
|
|
2066
2134
|
* Visibility helper inside a `[forComboboxOption]`. The directive flips a
|
|
@@ -2547,7 +2615,7 @@ class ForComboboxChip {
|
|
|
2547
2615
|
label = computed(() => {
|
|
2548
2616
|
const v = this.value();
|
|
2549
2617
|
const equals = this.ctx.compareWith();
|
|
2550
|
-
const cached = this.ctx.
|
|
2618
|
+
const cached = this.ctx.selectedEntries().find((o) => equals(o.value, v));
|
|
2551
2619
|
if (cached) {
|
|
2552
2620
|
return cached.label;
|
|
2553
2621
|
}
|