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.
Files changed (59) hide show
  1. package/combobox/README.md +7 -5
  2. package/date-picker/README.md +3 -2
  3. package/fesm2022/forty-cdk-combobox.mjs +197 -129
  4. package/fesm2022/forty-cdk-combobox.mjs.map +1 -1
  5. package/fesm2022/forty-cdk-core.mjs +818 -245
  6. package/fesm2022/forty-cdk-core.mjs.map +1 -1
  7. package/fesm2022/forty-cdk-date-field.mjs +2 -12
  8. package/fesm2022/forty-cdk-date-field.mjs.map +1 -1
  9. package/fesm2022/forty-cdk-date-picker.mjs +7 -1
  10. package/fesm2022/forty-cdk-date-picker.mjs.map +1 -1
  11. package/fesm2022/forty-cdk-date-range-field.mjs +2 -7
  12. package/fesm2022/forty-cdk-date-range-field.mjs.map +1 -1
  13. package/fesm2022/forty-cdk-hover-card.mjs +7 -1
  14. package/fesm2022/forty-cdk-hover-card.mjs.map +1 -1
  15. package/fesm2022/forty-cdk-listbox.mjs +50 -30
  16. package/fesm2022/forty-cdk-listbox.mjs.map +1 -1
  17. package/fesm2022/forty-cdk-menu.mjs +21 -3
  18. package/fesm2022/forty-cdk-menu.mjs.map +1 -1
  19. package/fesm2022/forty-cdk-menubar.mjs +1 -0
  20. package/fesm2022/forty-cdk-menubar.mjs.map +1 -1
  21. package/fesm2022/forty-cdk-navigation-menu.mjs +10 -1
  22. package/fesm2022/forty-cdk-navigation-menu.mjs.map +1 -1
  23. package/fesm2022/forty-cdk-popover.mjs +7 -1
  24. package/fesm2022/forty-cdk-popover.mjs.map +1 -1
  25. package/fesm2022/forty-cdk-select.mjs +88 -74
  26. package/fesm2022/forty-cdk-select.mjs.map +1 -1
  27. package/fesm2022/forty-cdk-table.mjs +66 -30
  28. package/fesm2022/forty-cdk-table.mjs.map +1 -1
  29. package/fesm2022/forty-cdk-tabs.mjs +34 -12
  30. package/fesm2022/forty-cdk-tabs.mjs.map +1 -1
  31. package/fesm2022/forty-cdk-time-picker.mjs +7 -1
  32. package/fesm2022/forty-cdk-time-picker.mjs.map +1 -1
  33. package/fesm2022/forty-cdk-tooltip.mjs +7 -1
  34. package/fesm2022/forty-cdk-tooltip.mjs.map +1 -1
  35. package/fesm2022/forty-cdk-tree.mjs +34 -19
  36. package/fesm2022/forty-cdk-tree.mjs.map +1 -1
  37. package/hover-card/README.md +2 -1
  38. package/listbox/README.md +4 -4
  39. package/menu/README.md +2 -1
  40. package/navigation-menu/README.md +2 -1
  41. package/package.json +1 -5
  42. package/popover/README.md +2 -1
  43. package/select/README.md +6 -3
  44. package/shared/README.md +59 -0
  45. package/time-picker/README.md +2 -1
  46. package/tooltip/README.md +19 -10
  47. package/types/forty-cdk-combobox.d.ts +52 -30
  48. package/types/forty-cdk-core.d.ts +398 -174
  49. package/types/forty-cdk-listbox.d.ts +8 -3
  50. package/types/forty-cdk-menu.d.ts +4 -1
  51. package/types/forty-cdk-menubar.d.ts +1 -0
  52. package/types/forty-cdk-select.d.ts +10 -5
  53. package/types/forty-cdk-table.d.ts +17 -6
  54. package/types/forty-cdk-tabs.d.ts +19 -5
  55. package/types/forty-cdk-tree.d.ts +3 -2
  56. package/fesm2022/forty-cdk-signal-forms.mjs +0 -134
  57. package/fesm2022/forty-cdk-signal-forms.mjs.map +0 -1
  58. package/signal-forms/README.md +0 -72
  59. package/types/forty-cdk-signal-forms.d.ts +0 -69
@@ -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
- When the consumer models a single-select field as `T | null` (not `readonly T[]`), bridge it with `forSingleValueField` so the same `[formField]` wiring works unchanged: `[formField]="forSingleValueField(form.country)"`. See [Signal Forms helpers](../signal-forms/README.md).
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'` (which opens the popup) or keep the options mounted rather than gating them behind `@if (open())`.
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
- Typeahead, inline autocomplete, and `selected().label` all read from a merged snapshot that retains entries for options scrolled out of view, so completion against off-screen labels still works.
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
@@ -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
- <!-- present in the DOM only while open -->
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, isDevMode, signal, afterNextRender } from '@angular/core';
3
- import { tryReadHandle, createDefaults, VirtualizedNavigator as VirtualizedNavigator$1, FormUiControlBase, ElementRegistry, Collection, defaultItemToFormValue, injectTextDirection, InitialFocusState, CloseReasonState, createPointerSuppression, LabelSnapshot, singleSelected, injectHiddenInput, isInArray, toggleInArray, nextEnabledHandle, emitVetoableEvent, emitVetoableNativeEvent, registerHandle, hostButtonType, reflectDisabled, foldTypeaheadText, hostAriaLabel, hostLabelledBy, injectOverlayShell, hostId, accessibleTextContent, registerA11yName } from 'forty-cdk/core';
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 `tryReadHandle`).
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 read = tryReadHandle(() => ({ value: item.value() }));
113
- if (read === null) {
112
+ const value = item.value();
113
+ if (isUnset(value)) {
114
114
  return NOT_READY;
115
115
  }
116
- if (values.some((sel) => equals(read.value, sel))) {
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
- * 1. Primes the label cache so its `linkedSignal` `prev` slot gets seeded while
129
- * the listbox is open — without an eager pull the lazy cache never runs
130
- * during the open cycle in non-virtualized usage and persistence across
131
- * close → re-open would start from an empty `prev`. The virtualization
132
- * navigator (and its position-map) is primed only when the consumer set
133
- * `totalCount()`, so a plain combobox never builds it.
134
- * 2. Virtualized only: resolves a pending `(scrollToIndex)` navigation — once
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
- * 3. Non-virtualized: scrolls the auto-highlight-seeded option into view so a
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
- navigator.prime();
168
- if (navigator.tryResolvePending()) {
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, reads the
274
- * option through the single NG0950 read guard, and overrides scroll-into-view
275
- * with the host's pointer-suppression wrapper. Adds the combobox-only
276
- * auto-highlight seed that stays passive (never scrolls the window).
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) => tryReadHandle(() => ({
290
- id: o.id(),
291
- value: o.value(),
292
- label: o.label(),
293
- disabled: o.disabled(),
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
- * Always-on label snapshot — keeps `{ id, value, label }` tuples across
683
- * close/open and scroll-out-of-view to drive inline autocomplete matching
684
- * and the `selected` label fallback. The value-keyed fold itself lives in
685
- * `forty-cdk/core`, shared with `[forSelect]`.
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 LabelSnapshot({
735
+ #labelCache = new LabelCache({
688
736
  items: this.#items.items,
689
- totalCount: this.totalCount,
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
- // `cachedOptions()` already merges off-window entries from the indexed
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 (label-cache priming, virtualized
787
- // pending-nav resolution + passive seed, non-virtualized scroll-into-view).
788
- // Full rationale lives with `runAutoHighlightBridge` in
789
- // `combobox-auto-highlight.ts`.
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
- #cachedOptionsMemo = computed(() => {
1045
- if (this.totalCount() === undefined) {
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
- #inlineCompletionOptionsMemo = computed(() => {
1103
+ #completionEntriesMemo = computed(() => {
1104
+ const liveWindow = this.#labelCache.windowEntries();
1055
1105
  if (this.totalCount() === undefined) {
1056
- return this.#labelCache.liveEntries();
1106
+ return liveWindow;
1057
1107
  }
1058
- return this.#labelCache.mergedEntries(this.#requireNavigator().snapshotByPos());
1108
+ return mergeOffWindowEntries(liveWindow, this.#requireNavigator().snapshotByPos(), this.itemToFormValue());
1059
1109
  }, /* @ts-ignore */
1060
- ...(ngDevMode ? [{ debugName: "#inlineCompletionOptionsMemo" }] : /* istanbul ignore next */ []));
1061
- inlineCompletionOptions() {
1062
- return this.#inlineCompletionOptionsMemo();
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
- .inlineCompletionOptions()
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, picked by whether an inner `[forComboboxList]` is present:
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 across the whole
1700
- * open lifecycle and active-option highlighting is `aria-activedescendant`-
1701
- * driven, so the directive exposes no focus hooks. The surface itself is
1702
- * focusable (`tabindex="-1"`), so a click on non-option padding can move focus
1703
- * onto it; the input's inline Escape handler then never sees the key, so the
1704
- * shell wires a fallback Escape channel that closes the popup and returns focus
1705
- * to the input.
1706
- * - **Picker anatomy** — on open, focus moves into the input (the search field
1707
- * inside the panel); on close it returns to the trigger. Both moves are
1708
- * vetoable via `(autoFocusOnOpen)` / `(autoFocusOnClose)` on `[forCombobox]`,
1709
- * and the return is gated by `[returnFocus]`. Escape from the input is owned by
1710
- * the input directive; the shell's fallback channel covers presses that land on
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
- // The picker anatomy opts back into the shell's focus bundles. `trigger()`
1727
- // is read once on construction: the trigger renders outside the `@if (open())`
1728
- // that gates this content, so it has already registered by the time the
1729
- // surface mounts. Without a trigger (editable anatomy) the bundles are
1730
- // omitted entirely and focus stays in the input — byte-for-byte unchanged.
1731
- // That assumption is dev-verified: a trigger that registers late (after this
1732
- // content was constructed) trips a dev-mode `console.warn`.
1733
- const hasTrigger = ctx.trigger() !== null;
1734
- if (isDevMode() && !hasTrigger) {
1735
- let warned = false;
1736
- effect(() => {
1737
- if (!warned && ctx.trigger() !== null) {
1738
- warned = true;
1739
- console.warn('[forty-cdk/combobox] [forComboboxTrigger] registered after [forComboboxContent] was constructed, ' +
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. Editable anatomy keeps focus in the input throughout,
1795
- // so both bundles stay absent (no imperative move, no hooks).
1796
- ...(hasTrigger
1797
- ? {
1798
- initialFocus: {
1799
- move: () => {
1800
- const input = ctx.input();
1801
- if (input) {
1802
- input.focus();
1803
- return true;
1804
- }
1805
- return false;
1806
- },
1807
- veto: () => ctx.emitAutoFocusOnOpen(),
1808
- },
1809
- returnFocus: {
1810
- enabled: ctx.returnFocus,
1811
- target: () => ctx.trigger(),
1812
- veto: () => ctx.emitAutoFocusOnClose(),
1813
- // On Tab and on outside dismissal (pointer-down-outside /
1814
- // focus-outside) focus already landed where the user tabbed or
1815
- // clicked; re-focusing the trigger would steal it back (native
1816
- // <select> parity, mirroring popover #1310).
1817
- skip: () => {
1818
- const reason = ctx.lastCloseReason();
1819
- return (reason === 'tab' || reason === 'pointerDownOutside' || reason === 'focusOutside');
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.required(/* @ts-ignore */
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(() => this.#ctx.isSelected(this.value()), /* @ts-ignore */
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: 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 });
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: 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 }] }] } });
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.cachedOptions().find((o) => equals(o.value, v));
2618
+ const cached = this.ctx.selectedEntries().find((o) => equals(o.value, v));
2551
2619
  if (cached) {
2552
2620
  return cached.label;
2553
2621
  }