@radix-ng/primitives 1.0.0-beta.3 → 1.0.0-beta.4

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 (97) hide show
  1. package/README.md +1 -1
  2. package/fesm2022/radix-ng-primitives-accordion.mjs +5 -3
  3. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  4. package/fesm2022/radix-ng-primitives-alert-dialog.mjs +3 -2
  5. package/fesm2022/radix-ng-primitives-alert-dialog.mjs.map +1 -1
  6. package/fesm2022/radix-ng-primitives-autocomplete.mjs +617 -659
  7. package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -1
  8. package/fesm2022/radix-ng-primitives-calendar.mjs +5 -3
  9. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  10. package/fesm2022/radix-ng-primitives-combobox.mjs +1305 -572
  11. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
  12. package/fesm2022/radix-ng-primitives-config.mjs +13 -4
  13. package/fesm2022/radix-ng-primitives-config.mjs.map +1 -1
  14. package/fesm2022/radix-ng-primitives-context-menu.mjs +51 -10
  15. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  16. package/fesm2022/radix-ng-primitives-core.mjs +1345 -64
  17. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  18. package/fesm2022/radix-ng-primitives-date-field.mjs +5 -3
  19. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  20. package/fesm2022/radix-ng-primitives-dialog.mjs +240 -112
  21. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  22. package/fesm2022/radix-ng-primitives-direction-provider.mjs +70 -0
  23. package/fesm2022/radix-ng-primitives-direction-provider.mjs.map +1 -0
  24. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs +519 -184
  25. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs.map +1 -1
  26. package/fesm2022/radix-ng-primitives-drawer.mjs +3 -3
  27. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  28. package/fesm2022/radix-ng-primitives-field.mjs +3 -2
  29. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  30. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +517 -0
  31. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
  32. package/fesm2022/radix-ng-primitives-focus-scope.mjs +296 -70
  33. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  34. package/fesm2022/radix-ng-primitives-menu.mjs +861 -286
  35. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  36. package/fesm2022/radix-ng-primitives-menubar.mjs +32 -4
  37. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  38. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +144 -159
  39. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  40. package/fesm2022/radix-ng-primitives-popover.mjs +220 -205
  41. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  42. package/fesm2022/radix-ng-primitives-popper.mjs +94 -51
  43. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  44. package/fesm2022/radix-ng-primitives-presence.mjs +1 -1
  45. package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
  46. package/fesm2022/radix-ng-primitives-preview-card.mjs +141 -173
  47. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  48. package/fesm2022/radix-ng-primitives-roving-focus.mjs +4 -2
  49. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  50. package/fesm2022/radix-ng-primitives-scroll-area.mjs +5 -4
  51. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
  52. package/fesm2022/radix-ng-primitives-select.mjs +211 -156
  53. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  54. package/fesm2022/radix-ng-primitives-slider.mjs +5 -3
  55. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  56. package/fesm2022/radix-ng-primitives-stepper.mjs +5 -3
  57. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  58. package/fesm2022/radix-ng-primitives-time-field.mjs +5 -3
  59. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  60. package/fesm2022/radix-ng-primitives-toast.mjs +15 -36
  61. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
  62. package/fesm2022/radix-ng-primitives-toggle-group.mjs +5 -3
  63. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  64. package/fesm2022/radix-ng-primitives-toolbar.mjs +5 -3
  65. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  66. package/fesm2022/radix-ng-primitives-tooltip.mjs +73 -110
  67. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  68. package/package.json +10 -1
  69. package/types/radix-ng-primitives-accordion.d.ts +4 -3
  70. package/types/radix-ng-primitives-autocomplete.d.ts +217 -152
  71. package/types/radix-ng-primitives-calendar.d.ts +5 -3
  72. package/types/radix-ng-primitives-combobox.d.ts +672 -283
  73. package/types/radix-ng-primitives-config.d.ts +1 -1
  74. package/types/radix-ng-primitives-context-menu.d.ts +15 -5
  75. package/types/radix-ng-primitives-core.d.ts +762 -14
  76. package/types/radix-ng-primitives-date-field.d.ts +3 -2
  77. package/types/radix-ng-primitives-dialog.d.ts +77 -32
  78. package/types/radix-ng-primitives-direction-provider.d.ts +41 -0
  79. package/types/radix-ng-primitives-dismissable-layer.d.ts +147 -99
  80. package/types/radix-ng-primitives-field.d.ts +1 -0
  81. package/types/radix-ng-primitives-floating-focus-manager.d.ts +175 -0
  82. package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
  83. package/types/radix-ng-primitives-menu.d.ts +186 -103
  84. package/types/radix-ng-primitives-navigation-menu.d.ts +37 -75
  85. package/types/radix-ng-primitives-popover.d.ts +59 -92
  86. package/types/radix-ng-primitives-popper.d.ts +39 -9
  87. package/types/radix-ng-primitives-preview-card.d.ts +39 -72
  88. package/types/radix-ng-primitives-roving-focus.d.ts +7 -6
  89. package/types/radix-ng-primitives-scroll-area.d.ts +2 -2
  90. package/types/radix-ng-primitives-select.d.ts +145 -108
  91. package/types/radix-ng-primitives-slider.d.ts +5 -4
  92. package/types/radix-ng-primitives-stepper.d.ts +4 -3
  93. package/types/radix-ng-primitives-time-field.d.ts +3 -2
  94. package/types/radix-ng-primitives-toast.d.ts +7 -7
  95. package/types/radix-ng-primitives-toggle-group.d.ts +5 -4
  96. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  97. package/types/radix-ng-primitives-tooltip.d.ts +24 -67
@@ -1,11 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Directive, inject, booleanAttribute, Injector, model, input, computed, numberAttribute, output, signal, effect, untracked, isDevMode, ElementRef, DestroyRef, afterNextRender, afterRenderEffect, NgModule } from '@angular/core';
2
+ import { Directive, signal, computed, untracked, effect, inject, booleanAttribute, Injector, ElementRef, model, input, numberAttribute, output, isDevMode, DestroyRef, ChangeDetectionStrategy, Component, afterNextRender, afterRenderEffect, NgModule } from '@angular/core';
3
3
  import * as i1 from '@radix-ng/primitives/popper';
4
- import { RdxPopperAnchor, RdxPopperArrow, RdxPopper, injectPopperContentWrapperContext, RdxPopperContent, RdxPopperContentWrapper } from '@radix-ng/primitives/popper';
4
+ import { RdxPopperAnchor, RdxPopperArrow, RdxPopper, RdxPopperContentWrapper, provideRdxPopperContentWrapper, provideRdxPopperContentConfig, injectPopperContentWrapperContext, RdxPopperContent } from '@radix-ng/primitives/popper';
5
5
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
6
- import { createContext, useTransitionStatus, injectId, useFilter, useListHighlight, isItemEqualToValue, isNullish, itemToStringLabel, useScrollLock } from '@radix-ng/primitives/core';
6
+ import * as i2 from '@radix-ng/primitives/core';
7
+ import { useFilter, injectId, useListHighlight, useTransitionStatus, createContext, createFloatingRootContext, isItemEqualToValue, isNullish, itemToStringLabel, createCancelableChangeEventDetails, provideFloatingTree, provideFloatingRootContext, setupInternalBackdrop, RDX_FLOATING_ROOT_CONTEXT, RDX_FLOATING_REGISTRATION, useAnchoredScrollLock, RdxFloatingNodeRegistration, rdxDevError } from '@radix-ng/primitives/core';
8
+ import { injectDirection } from '@radix-ng/primitives/direction-provider';
7
9
  import * as i1$1 from '@radix-ng/primitives/dismissable-layer';
8
- import { RdxDismissableLayerBranch, RdxDismissableLayer, provideRdxDismissableLayerConfig } from '@radix-ng/primitives/dismissable-layer';
10
+ import { RdxFloatingInsideElement, RdxDismiss } from '@radix-ng/primitives/dismissable-layer';
9
11
  import { injectFieldRootContext } from '@radix-ng/primitives/field';
10
12
  import * as i1$2 from '@radix-ng/primitives/portal';
11
13
  import { RdxPortalPresence } from '@radix-ng/primitives/portal';
@@ -52,60 +54,606 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
52
54
  }]
53
55
  }] });
54
56
 
57
+ /**
58
+ * The shared Combobox engine (ADR 0014): item registry, filtering, highlight-model navigation,
59
+ * open/close transition, and the reactive effects that tie them together — everything Combobox and
60
+ * Autocomplete have in common. Value/selection semantics, open orchestration, and forms integration
61
+ * stay in each root; the root configures the engine and reads its state for the DI context.
62
+ *
63
+ * Must be called in an injection context (it runs effects and `inject`-based hooks).
64
+ *
65
+ * @internal Not part of the public API — exported only for the autocomplete entry; may change without notice.
66
+ */
67
+ function useComboboxEngine(config) {
68
+ const { injector } = config;
69
+ const defaultFilter = useFilter();
70
+ const listId = injectId(config.listIdPrefix);
71
+ const labelId = signal(undefined, ...(ngDevMode ? [{ debugName: "labelId" }] : /* istanbul ignore next */ []));
72
+ const inputElement = signal(null, ...(ngDevMode ? [{ debugName: "inputElement" }] : /* istanbul ignore next */ []));
73
+ // Where the text input lives relative to the popup (Base UI's `inputInsidePopup`), reported by the
74
+ // input on mount from its positioner ancestor. Drives the Trigger's role:
75
+ // - `outside` → the input is the tab stop; the Trigger is a `tabindex="-1"` toggle.
76
+ // - `inside` → the Trigger is the focusable `role="combobox"` control (`aria-haspopup="dialog"`).
77
+ // - `unknown` → no input has mounted yet (e.g. the input lives in a not-yet-opened popup); the
78
+ // Trigger stays a normal focusable button so it is reachable by `Tab` before the first open.
79
+ const inputLayout = signal('unknown', ...(ngDevMode ? [{ debugName: "inputLayout" }] : /* istanbul ignore next */ []));
80
+ // Whether the popup was opened by a touch interaction. When the input lives inside the popup, a
81
+ // touch-open focuses the popup (not the input) so Android doesn't raise the virtual keyboard
82
+ // (Base UI; iOS handles this itself). Reset to `false` on keyboard/mouse opens.
83
+ const openedByTouch = signal(false, ...(ngDevMode ? [{ debugName: "openedByTouch" }] : /* istanbul ignore next */ []));
84
+ // Whether the popup directive is currently mounted (open through the close/exit animation, until the
85
+ // presence machine unmounts it). Distinguishes "Escape closed the popup" (still mounted this tick)
86
+ // from "Escape on an already-closed combobox" (unmounted) — Base UI's `mounted`, since `open()`
87
+ // flips synchronously when the input's Escape handler (or the dismiss mechanism) closes the popup.
88
+ const popupMounted = signal(false, ...(ngDevMode ? [{ debugName: "popupMounted" }] : /* istanbul ignore next */ []));
89
+ let triggerElement = null;
90
+ // Tracks whether the last interaction was the keyboard, so the highlight doesn't jump to an item
91
+ // the cursor happens to rest on when arrow-key navigation scrolls the list under a still pointer.
92
+ let keyboardActive = false;
93
+ const _items = signal([], ...(ngDevMode ? [{ debugName: "_items" }] : /* istanbul ignore next */ []));
94
+ const orderedItems = computed(() => {
95
+ const items = [..._items()];
96
+ return items.sort((a, b) => domOrder(a.element, b.element));
97
+ }, ...(ngDevMode ? [{ debugName: "orderedItems" }] : /* istanbul ignore next */ []));
98
+ const matchesFilter = (item) => {
99
+ if (!config.filteringEnabled()) {
100
+ return true;
101
+ }
102
+ const filter = config.filter();
103
+ if (filter === null) {
104
+ return true;
105
+ }
106
+ const query = config.query();
107
+ // Custom filter: Base UI shape `(value, query, itemToString)`. Default: locale-aware contains
108
+ // on the item's own text (the element content), no value→text round-trip.
109
+ return filter
110
+ ? filter(item.value(), query, config.itemToString)
111
+ : defaultFilter.contains(item.textValue(), query);
112
+ };
113
+ const visibleItems = computed(() => {
114
+ const matching = orderedItems().filter((item) => matchesFilter(item));
115
+ const limit = config.limit();
116
+ return limit >= 0 ? matching.slice(0, limit) : matching;
117
+ }, ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
118
+ const visibleSet = computed(() => new Set(visibleItems()), ...(ngDevMode ? [{ debugName: "visibleSet" }] : /* istanbul ignore next */ []));
119
+ const isVisible = (item) => visibleSet().has(item);
120
+ const filteredItems = computed(() => {
121
+ const data = config.items();
122
+ if (data === undefined) {
123
+ return visibleItems().map((item) => item.value());
124
+ }
125
+ const limit = config.limit();
126
+ const cap = (arr) => (limit >= 0 ? arr.slice(0, limit) : arr);
127
+ if (!config.filteringEnabled()) {
128
+ return cap(data);
129
+ }
130
+ const filter = config.filter();
131
+ if (filter === null) {
132
+ return cap(data);
133
+ }
134
+ const query = config.query();
135
+ if (!query) {
136
+ return cap(data);
137
+ }
138
+ // Virtualized: no DOM to read text from, so resolve each value through `itemToString`.
139
+ return cap(data.filter((value) => filter
140
+ ? filter(value, query, config.itemToString)
141
+ : defaultFilter.contains(config.itemToString(value), query)));
142
+ }, ...(ngDevMode ? [{ debugName: "filteredItems" }] : /* istanbul ignore next */ []));
143
+ const visibleCount = computed(() => (config.virtualized() ? filteredItems().length : visibleItems().length), ...(ngDevMode ? [{ debugName: "visibleCount" }] : /* istanbul ignore next */ []));
144
+ const highlight = useListHighlight({
145
+ items: orderedItems,
146
+ isNavigable: (item) => isVisible(item) && !item.disabled(),
147
+ getId: (item) => item.id,
148
+ loop: config.loopFocus,
149
+ injector
150
+ });
151
+ const highlightedItem = highlight.highlightedItem;
152
+ const highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : /* istanbul ignore next */ []));
153
+ const highlightReason = signal('none', ...(ngDevMode ? [{ debugName: "highlightReason" }] : /* istanbul ignore next */ []));
154
+ const itemId = (index) => `${listId}-item-${index}`;
155
+ const activeId = computed(() => {
156
+ if (config.virtualized()) {
157
+ const index = highlightedIndex();
158
+ return index >= 0 ? itemId(index) : undefined;
159
+ }
160
+ return highlight.activeId();
161
+ }, ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
162
+ // `'first-match'` highlights the first item whose label prefix-matches the query (inline modes),
163
+ // so inline completion lands on a real prefix even when the list is static.
164
+ const pendingHighlightEdge = signal(null, ...(ngDevMode ? [{ debugName: "pendingHighlightEdge" }] : /* istanbul ignore next */ []));
165
+ // Inline completion (autocomplete `both` / `inline`): a transient preview of the highlighted item's
166
+ // label mirrored into the input. `null` when off. `suppressInline` skips it for a deleting edit.
167
+ const inlinePreview = signal(null, ...(ngDevMode ? [{ debugName: "inlinePreview" }] : /* istanbul ignore next */ []));
168
+ let suppressInline = false;
169
+ /** The first visible, navigable item whose label starts with the query (for inline completion). */
170
+ const firstMatchItem = () => {
171
+ const query = config.query();
172
+ if (!query) {
173
+ return null;
174
+ }
175
+ const lower = query.toLowerCase();
176
+ return (visibleItems().find((item) => !item.disabled() && item.textValue().toLowerCase().startsWith(lower)) ?? null);
177
+ };
178
+ /** The first visible, navigable item (auto-highlight fallback when no prefix match exists). */
179
+ const firstVisibleNavigable = () => visibleItems().find((item) => !item.disabled()) ?? null;
180
+ /** Resolves a pending edge to a virtualized index (`'first-match'` → first prefix match, else 0). */
181
+ const resolveVirtualizedEdge = (edge, count) => {
182
+ if (edge === 'last') {
183
+ return count - 1;
184
+ }
185
+ if (edge === 'first-match') {
186
+ const query = config.query();
187
+ if (query) {
188
+ const lower = query.toLowerCase();
189
+ const index = filteredItems().findIndex((value) => config.itemToString(value).toLowerCase().startsWith(lower));
190
+ if (index >= 0) {
191
+ return index;
192
+ }
193
+ }
194
+ }
195
+ return 0;
196
+ };
197
+ /** The active highlight's label, DOM-ref or virtualized (resolved from the index). `null` when none. */
198
+ const activeLabel = () => {
199
+ if (config.virtualized()) {
200
+ const index = highlightedIndex();
201
+ const value = index >= 0 ? filteredItems()[index] : undefined;
202
+ return value === undefined ? null : config.itemToString(value);
203
+ }
204
+ return highlightedItem()?.textValue() ?? null;
205
+ };
206
+ const recomputeInlinePreview = (label, query, reason) => {
207
+ // Pointer hover must not rewrite the input (matches Base UI); only typing / keyboard nav complete it.
208
+ if (!config.inlineMode() || suppressInline || !label || reason === 'pointer') {
209
+ inlinePreview.set(null);
210
+ return;
211
+ }
212
+ if (query && label.toLowerCase().startsWith(query.toLowerCase())) {
213
+ // Type-ahead: keep the typed prefix (preserving its casing) and complete the rest.
214
+ inlinePreview.set(query + label.slice(query.length));
215
+ return;
216
+ }
217
+ // Keyboard navigation to a non-prefix item: show its full label so the input reflects it.
218
+ if (reason === 'keyboard') {
219
+ inlinePreview.set(label);
220
+ return;
221
+ }
222
+ inlinePreview.set(null);
223
+ };
224
+ const transition = useTransitionStatus(config.onOpenChangeComplete);
225
+ // --- effects ---
226
+ // Emit open changes and drive the open/close transition (skip the initial run).
227
+ let previousOpen = untracked(config.open);
228
+ effect(() => {
229
+ const open = config.open();
230
+ if (open === previousOpen) {
231
+ return;
232
+ }
233
+ previousOpen = open;
234
+ untracked(() => {
235
+ // Drop a deferred open-edge highlight when the popup closes — otherwise a pending
236
+ // 'first' (e.g. ArrowDown opened an empty list, then it closed) would unexpectedly
237
+ // highlight on the next plain open, even without autoHighlight.
238
+ if (!open) {
239
+ pendingHighlightEdge.set(null);
240
+ }
241
+ config.onOpenChange(open);
242
+ transition.start(open);
243
+ });
244
+ }, { injector });
245
+ // Emit highlight changes (skip the initial run). Tracks both the DOM-ref highlight and the
246
+ // virtualized index; only one is active per mode, so the other never fires spuriously.
247
+ let highlightInitialized = false;
248
+ effect(() => {
249
+ const item = highlightedItem();
250
+ const index = highlightedIndex();
251
+ if (!highlightInitialized) {
252
+ highlightInitialized = true;
253
+ return;
254
+ }
255
+ untracked(() => {
256
+ if (config.virtualized()) {
257
+ const value = index >= 0 ? (filteredItems()[index] ?? null) : null;
258
+ // No active highlight (e.g. filtering pushed the index out of range) carries no
259
+ // interaction — report 'none', not a stale 'keyboard'/'pointer' reason.
260
+ config.onItemHighlighted({ value, index, reason: value === null ? 'none' : highlightReason() });
261
+ }
262
+ else {
263
+ const value = item ? item.value() : null;
264
+ const itemIndex = item ? visibleItems().indexOf(item) : -1;
265
+ // DOM-mode self-heal in `useListHighlight` clears `highlighted` without touching
266
+ // `highlightReason`; treat a null highlight as 'none' so the emit isn't mis-reported.
267
+ config.onItemHighlighted({
268
+ value,
269
+ index: itemIndex,
270
+ reason: item === null ? 'none' : highlightReason()
271
+ });
272
+ }
273
+ });
274
+ }, { injector });
275
+ // Apply a deferred open-edge highlight once items (DOM refs) or filtered data have registered.
276
+ effect(() => {
277
+ const edge = pendingHighlightEdge();
278
+ const count = config.virtualized() ? filteredItems().length : orderedItems().length;
279
+ if (!config.open() || edge === null || count === 0) {
280
+ return;
281
+ }
282
+ untracked(() => {
283
+ // Programmatic move — reset the reason so the emit reports 'none', not a stale interaction.
284
+ highlightReason.set('none');
285
+ if (config.virtualized()) {
286
+ highlightedIndex.set(resolveVirtualizedEdge(edge, count));
287
+ }
288
+ else if (edge === 'last') {
289
+ highlight.last();
290
+ }
291
+ else if (edge === 'first-match') {
292
+ highlight.set(firstMatchItem() ?? firstVisibleNavigable());
293
+ }
294
+ else {
295
+ highlight.first();
296
+ }
297
+ pendingHighlightEdge.set(null);
298
+ });
299
+ }, { injector });
300
+ // Inline completion: mirror the active item's label into the input. Tracks the DOM-ref highlight,
301
+ // the virtualized index, the query, the reason, and `inlineMode` — so virtualized navigation and a
302
+ // `both → list` mode switch both recompute (and clear) the preview. No-op (null) when off (combobox).
303
+ effect(() => {
304
+ config.inlineMode();
305
+ highlightedItem();
306
+ highlightedIndex();
307
+ const query = config.query();
308
+ const reason = highlightReason();
309
+ untracked(() => recomputeInlinePreview(activeLabel(), query, reason));
310
+ }, { injector });
311
+ // autoHighlight 'always': keep the first navigable item highlighted whenever the popup is open.
312
+ effect(() => {
313
+ orderedItems();
314
+ visibleCount();
315
+ if (config.autoHighlightMode() === 'always' && config.open()) {
316
+ untracked(() => {
317
+ if (config.virtualized()) {
318
+ const length = filteredItems().length;
319
+ const index = highlightedIndex();
320
+ if ((index < 0 || index >= length) && length > 0) {
321
+ highlightReason.set('none');
322
+ highlightedIndex.set(0);
323
+ }
324
+ }
325
+ else if (highlightedItem() === null) {
326
+ highlightReason.set('none');
327
+ highlight.first();
328
+ }
329
+ });
330
+ }
331
+ }, { injector });
332
+ // Virtualized self-heal: clear a highlight that filtering has pushed out of range.
333
+ effect(() => {
334
+ if (!config.virtualized()) {
335
+ return;
336
+ }
337
+ const length = filteredItems().length;
338
+ untracked(() => {
339
+ const index = highlightedIndex();
340
+ if (index >= length && index !== -1) {
341
+ highlightReason.set('none');
342
+ highlightedIndex.set(-1);
343
+ }
344
+ });
345
+ }, { injector });
346
+ // --- navigation facade (mode-aware: index-based when virtualized, else DOM-ref) ---
347
+ const stepIndex = (direction) => {
348
+ const length = filteredItems().length;
349
+ if (length === 0) {
350
+ highlightedIndex.set(-1);
351
+ return;
352
+ }
353
+ const current = highlightedIndex();
354
+ if (current < 0) {
355
+ highlightedIndex.set(direction === 1 ? 0 : length - 1);
356
+ return;
357
+ }
358
+ let next = current + direction;
359
+ const loop = config.loopFocus();
360
+ if (next < 0) {
361
+ next = loop ? length - 1 : 0;
362
+ }
363
+ else if (next >= length) {
364
+ next = loop ? 0 : length - 1;
365
+ }
366
+ highlightedIndex.set(next);
367
+ };
368
+ // --- grid navigation (DOM-ref mode only) ---
369
+ /** Visible items grouped into rows by their enclosing row element (DOM order). */
370
+ const gridRows = () => {
371
+ const rows = new Map();
372
+ for (const item of visibleItems()) {
373
+ const key = config.rowOf(item.element);
374
+ const bucket = rows.get(key);
375
+ if (bucket) {
376
+ bucket.push(item);
377
+ }
378
+ else {
379
+ rows.set(key, [item]);
380
+ }
381
+ }
382
+ return [...rows.values()];
383
+ };
384
+ /** Grid vertical move: keep the column index, jump to the adjacent row (wraps when looping). */
385
+ const gridVertical = (direction) => {
386
+ const rows = gridRows();
387
+ if (rows.length === 0) {
388
+ return;
389
+ }
390
+ const current = highlightedItem();
391
+ if (!current) {
392
+ const row = direction === 1 ? rows[0] : rows[rows.length - 1];
393
+ highlight.set(row[0] ?? null);
394
+ return;
395
+ }
396
+ let rowIndex = rows.findIndex((row) => row.includes(current));
397
+ const col = rowIndex >= 0 ? rows[rowIndex].indexOf(current) : 0;
398
+ const loop = config.loopFocus();
399
+ rowIndex += direction;
400
+ if (rowIndex < 0) {
401
+ rowIndex = loop ? rows.length - 1 : 0;
402
+ }
403
+ else if (rowIndex >= rows.length) {
404
+ rowIndex = loop ? 0 : rows.length - 1;
405
+ }
406
+ const targetRow = rows[rowIndex];
407
+ highlight.set(targetRow[Math.min(col, targetRow.length - 1)] ?? null);
408
+ };
409
+ return {
410
+ listId,
411
+ labelId: labelId.asReadonly(),
412
+ setLabelId(id) {
413
+ labelId.set(id);
414
+ },
415
+ inputElement: inputElement.asReadonly(),
416
+ setInputElement(el) {
417
+ inputElement.set(el);
418
+ },
419
+ inputLayout: inputLayout.asReadonly(),
420
+ setInputLayout(layout) {
421
+ inputLayout.set(layout);
422
+ },
423
+ openedByTouch: openedByTouch.asReadonly(),
424
+ setOpenedByTouch(value) {
425
+ openedByTouch.set(value);
426
+ },
427
+ popupMounted: popupMounted.asReadonly(),
428
+ setPopupMounted(value) {
429
+ popupMounted.set(value);
430
+ },
431
+ get triggerElement() {
432
+ return triggerElement;
433
+ },
434
+ setTrigger(el) {
435
+ triggerElement = el;
436
+ },
437
+ orderedItems,
438
+ visibleItems,
439
+ visibleCount,
440
+ filteredItems,
441
+ isVisible,
442
+ registerItem(item) {
443
+ _items.update((items) => [...items, item]);
444
+ },
445
+ unregisterItem(item) {
446
+ _items.update((items) => items.filter((i) => i !== item));
447
+ },
448
+ highlight,
449
+ highlightedItem,
450
+ highlightedIndex: highlightedIndex.asReadonly(),
451
+ activeId,
452
+ itemId,
453
+ setHighlightReason(reason) {
454
+ highlightReason.set(reason);
455
+ },
456
+ setPendingHighlightEdge(edge) {
457
+ pendingHighlightEdge.set(edge);
458
+ },
459
+ /** Transient inline-completion preview (autocomplete inline modes); `null` when inactive. */
460
+ inlinePreview: inlinePreview.asReadonly(),
461
+ /** Suppress inline completion for the current edit (e.g. while a delete key is held). */
462
+ setSuppressInline(value) {
463
+ suppressInline = value;
464
+ },
465
+ /** Clear the inline preview synchronously (on select / clear / close, before the effect runs). */
466
+ clearInlinePreview() {
467
+ inlinePreview.set(null);
468
+ },
469
+ highlightNext(reason = 'keyboard') {
470
+ highlightReason.set(reason);
471
+ if (config.virtualized()) {
472
+ stepIndex(1);
473
+ }
474
+ else if (config.grid()) {
475
+ gridVertical(1);
476
+ }
477
+ else {
478
+ highlight.next();
479
+ }
480
+ },
481
+ highlightPrevious(reason = 'keyboard') {
482
+ highlightReason.set(reason);
483
+ if (config.virtualized()) {
484
+ stepIndex(-1);
485
+ }
486
+ else if (config.grid()) {
487
+ gridVertical(-1);
488
+ }
489
+ else {
490
+ highlight.previous();
491
+ }
492
+ },
493
+ // Grid horizontal moves (next/previous cell in DOM order). No-op outside grid mode.
494
+ highlightNextColumn(reason = 'keyboard') {
495
+ if (!config.grid() || config.virtualized()) {
496
+ return;
497
+ }
498
+ highlightReason.set(reason);
499
+ highlight.next();
500
+ },
501
+ highlightPreviousColumn(reason = 'keyboard') {
502
+ if (!config.grid() || config.virtualized()) {
503
+ return;
504
+ }
505
+ highlightReason.set(reason);
506
+ highlight.previous();
507
+ },
508
+ highlightFirst(reason = 'keyboard') {
509
+ highlightReason.set(reason);
510
+ if (config.virtualized()) {
511
+ highlightedIndex.set(filteredItems().length > 0 ? 0 : -1);
512
+ }
513
+ else {
514
+ highlight.first();
515
+ }
516
+ },
517
+ highlightLast(reason = 'keyboard') {
518
+ highlightReason.set(reason);
519
+ if (config.virtualized()) {
520
+ const length = filteredItems().length;
521
+ highlightedIndex.set(length > 0 ? length - 1 : -1);
522
+ }
523
+ else {
524
+ highlight.last();
525
+ }
526
+ },
527
+ highlightIndex(index, reason) {
528
+ if (index < 0 || index >= filteredItems().length) {
529
+ return;
530
+ }
531
+ highlightReason.set(reason);
532
+ highlightedIndex.set(index);
533
+ },
534
+ setHighlight(item, reason) {
535
+ highlightReason.set(reason);
536
+ highlight.set(item);
537
+ },
538
+ clearHighlightState() {
539
+ highlight.clear();
540
+ highlightedIndex.set(-1);
541
+ },
542
+ isKeyboardActive() {
543
+ return keyboardActive;
544
+ },
545
+ setKeyboardActive(value) {
546
+ keyboardActive = value;
547
+ },
548
+ transitionStatus: transition.status,
549
+ registerTransitionElement: transition.registerElement,
550
+ focusInput() {
551
+ inputElement()?.focus();
552
+ },
553
+ selectInputText() {
554
+ inputElement()?.select();
555
+ },
556
+ /**
557
+ * Restore focus after a selection: the input when it sits outside the popup, otherwise the
558
+ * trigger. Skipped when the consumer moved focus during the `onValueChange` callback — pass the
559
+ * `document.activeElement` captured *before* the emit so we don't clobber a consumer's choice
560
+ * (e.g. focusing an external message field after inserting an emoji).
561
+ */
562
+ restoreFocusAfterSelect(previousActiveElement) {
563
+ if (typeof document !== 'undefined' && document.activeElement !== previousActiveElement) {
564
+ return;
565
+ }
566
+ const input = inputElement();
567
+ if (input && !input.closest(config.popupSelector)) {
568
+ input.focus();
569
+ }
570
+ else {
571
+ triggerElement?.focus();
572
+ }
573
+ }
574
+ };
575
+ }
576
+ /** DOM-order comparator for two elements (precedes → -1, follows → 1). */
577
+ function domOrder(a, b) {
578
+ const position = a.compareDocumentPosition(b);
579
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
580
+ return -1;
581
+ }
582
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
583
+ return 1;
584
+ }
585
+ return 0;
586
+ }
587
+
588
+ // The engine stays private to the root (it owns mutable internals); the context factory — a free
589
+ // function, so it can't reach a `private` field — reads it through this registry instead.
590
+ const engineRegistry = new WeakMap();
55
591
  const context = () => {
56
592
  const root = inject(RdxComboboxRoot);
593
+ const engine = engineRegistry.get(root);
57
594
  return {
58
- listId: root.listId,
59
- labelId: root.labelId,
60
- setLabelId: (id) => root.labelId.set(id),
595
+ listId: engine.listId,
596
+ labelId: engine.labelId,
597
+ setLabelId: (id) => engine.setLabelId(id),
61
598
  dir: root.dir,
62
599
  value: root.value,
63
600
  inputValue: root.inputValue,
64
601
  open: root.open,
602
+ present: root.present,
65
603
  multiple: root.multiple,
66
604
  selectionMode: root.mode,
67
605
  disabledState: root.disabledState,
68
- readonly: root.readonly,
606
+ readonly: root.readOnly,
69
607
  requiredState: root.requiredState,
70
608
  openOnInputClick: root.openOnInputClick,
71
609
  modal: root.modal,
72
610
  virtualized: root.virtualized,
73
- filteredItems: root.filteredItems,
74
- highlightedItem: root.highlightedItem,
75
- highlightedIndex: root.highlightedIndex.asReadonly(),
76
- activeId: root.activeId,
77
- itemId: (index) => root.itemId(index),
78
- isKeyboardActive: () => root.isKeyboardActive(),
79
- setKeyboardActive: (value) => root.setKeyboardActive(value),
80
- transitionStatus: root.transitionStatus,
81
- registerTransitionElement: root.registerTransitionElement,
82
- visibleCount: root.visibleCount,
83
- isVisible: (item) => root.isVisible(item),
611
+ grid: root.grid,
612
+ filteredItems: engine.filteredItems,
613
+ highlightedItem: engine.highlightedItem,
614
+ highlightedIndex: engine.highlightedIndex,
615
+ activeId: engine.activeId,
616
+ itemId: (index) => engine.itemId(index),
617
+ isKeyboardActive: () => engine.isKeyboardActive(),
618
+ setKeyboardActive: (value) => engine.setKeyboardActive(value),
619
+ transitionStatus: engine.transitionStatus,
620
+ registerTransitionElement: engine.registerTransitionElement,
621
+ visibleCount: engine.visibleCount,
622
+ isVisible: (item) => engine.isVisible(item),
84
623
  isSelected: (value) => root.isSelected(value),
85
- registerItem: (item) => root.registerItem(item),
86
- unregisterItem: (item) => root.unregisterItem(item),
87
- highlight: root.highlight,
88
- highlightNext: () => root.highlightNext('keyboard'),
89
- highlightPrevious: () => root.highlightPrevious('keyboard'),
90
- highlightFirst: () => root.highlightFirst('keyboard'),
91
- highlightLast: () => root.highlightLast('keyboard'),
92
- highlightIndex: (index, reason) => root.highlightIndex(index, reason),
93
- setHighlight: (item, reason) => root.setHighlight(item, reason),
94
- clearHighlight: () => root.clearHighlightState(),
624
+ registerItem: (item) => engine.registerItem(item),
625
+ unregisterItem: (item) => engine.unregisterItem(item),
626
+ highlight: engine.highlight,
627
+ highlightNext: () => engine.highlightNext('keyboard'),
628
+ highlightPrevious: () => engine.highlightPrevious('keyboard'),
629
+ highlightNextColumn: () => engine.highlightNextColumn('keyboard'),
630
+ highlightPreviousColumn: () => engine.highlightPreviousColumn('keyboard'),
631
+ highlightFirst: () => engine.highlightFirst('keyboard'),
632
+ highlightLast: () => engine.highlightLast('keyboard'),
633
+ highlightIndex: (index, reason) => engine.highlightIndex(index, reason),
634
+ setHighlight: (item, reason) => engine.setHighlight(item, reason),
635
+ clearHighlight: () => engine.clearHighlightState(),
95
636
  highlightItemOnHover: root.highlightItemOnHover,
96
637
  keepHighlight: root.keepHighlight,
97
- inputElement: root.inputElement.asReadonly(),
98
- setInputElement: (el) => root.inputElement.set(el),
99
- registerTrigger: (el) => (root.triggerElement = el),
100
- focusInput: () => root.focusInput(),
101
- openPopup: () => root.setOpen(true),
102
- openForBrowse: () => root.openForBrowse(),
103
- closePopup: (revert = true) => root.closePopup(revert),
638
+ inputElement: engine.inputElement,
639
+ setInputElement: (el) => engine.setInputElement(el),
640
+ inputLayout: engine.inputLayout,
641
+ setInputLayout: (layout) => engine.setInputLayout(layout),
642
+ openedByTouch: engine.openedByTouch,
643
+ setOpenedByTouch: (value) => engine.setOpenedByTouch(value),
644
+ popupMounted: engine.popupMounted,
645
+ setPopupMounted: (value) => engine.setPopupMounted(value),
646
+ registerTrigger: (el) => engine.setTrigger(el),
647
+ focusInput: () => engine.focusInput(),
648
+ openPopup: (reason, event) => root.setOpen(true, reason, event),
649
+ openForBrowse: (reason, event) => root.openForBrowse(reason, event),
650
+ closePopup: (revert = true, reason, event) => root.closePopup(revert, reason, event),
104
651
  setInputValue: (value) => root.setInputValue(value),
105
- openAndHighlight: (edge) => root.openAndHighlight(edge),
106
- select: (item) => root.handleSelect(item),
107
- selectIndex: (index) => root.selectIndex(index),
108
- selectHighlighted: () => root.selectHighlighted(),
652
+ openAndHighlight: (edge, reason, event) => root.openAndHighlight(edge, reason, event),
653
+ navigateByKeyboard: (direction, event) => root.navigateByKeyboard(direction, event),
654
+ select: (item, event) => root.handleSelect(item, event),
655
+ selectIndex: (index, event) => root.selectIndex(index, event),
656
+ selectHighlighted: (event) => root.selectHighlighted(event),
109
657
  clearSelection: () => root.clearSelection(),
110
658
  removeValue: (value) => root.removeValue(value),
111
659
  removeLastValue: () => root.removeLastValue(),
@@ -128,15 +676,45 @@ function coerceAutoHighlight(value) {
128
676
  return value === 'always' || value === 'input-change' ? value : booleanAttribute(value);
129
677
  }
130
678
  /**
131
- * Root of a Combobox — a filterable select. Owns selection, input text, open state, filtering, and
132
- * highlight-model navigation, and exposes them to the parts through {@link RdxComboboxRootContext}.
133
- * Implements `ControlValueAccessor` for forms.
679
+ * Root of a Combobox — a filterable select. Owns selection, input text, open state, and the forms
680
+ * integration, and delegates filtering / highlight-model navigation / the open-close transition to the
681
+ * shared {@link useComboboxEngine} (ADR 0014). Exposes everything to the parts through
682
+ * {@link RdxComboboxRootContext}. Implements `ControlValueAccessor`.
134
683
  *
135
684
  * @group Components
136
685
  */
137
686
  class RdxComboboxRoot {
687
+ /** The list element id referenced by `aria-controls` / `aria-activedescendant` (engine-backed). */
688
+ get listId() {
689
+ return this.engine.listId;
690
+ }
691
+ /** The currently highlighted item (engine-backed; read by parts and tests). */
692
+ get highlightedItem() {
693
+ return this.engine.highlightedItem;
694
+ }
695
+ /** Number of items the list currently shows (engine-backed). */
696
+ get visibleCount() {
697
+ return this.engine.visibleCount;
698
+ }
699
+ /** The filtered item values an external virtualizer should render (engine-backed). */
700
+ get filteredItems() {
701
+ return this.engine.filteredItems;
702
+ }
703
+ /** Highlighted index into {@link filteredItems} in virtualized mode (engine-backed). */
704
+ get highlightedIndex() {
705
+ return this.engine.highlightedIndex;
706
+ }
707
+ /** The active option's id for `aria-activedescendant` (engine-backed). */
708
+ get activeId() {
709
+ return this.engine.activeId;
710
+ }
138
711
  constructor() {
139
712
  this.injector = inject(Injector);
713
+ /** Per-popup floating root context (ADR 0015) — `open` / `triggers` / reference for the dismissal engine. */
714
+ this.floatingContext = createFloatingRootContext({
715
+ ownerDocument: inject(ElementRef).nativeElement.ownerDocument,
716
+ open: () => this.open()
717
+ });
140
718
  /** Selected value(s). A single value in single mode, an array in `multiple` mode. */
141
719
  this.value = model(null, ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
142
720
  /** Initial value when uncontrolled. */
@@ -161,11 +739,12 @@ class RdxComboboxRoot {
161
739
  /** In `'none'` mode, whether pressing an item fills the input with its label. */
162
740
  this.fillInputOnItemPress = input(true, { ...(ngDevMode ? { debugName: "fillInputOnItemPress" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
163
741
  /** Text direction. */
164
- this.dir = input('ltr', ...(ngDevMode ? [{ debugName: "dir" }] : /* istanbul ignore next */ []));
742
+ this.dirInput = input(undefined, { ...(ngDevMode ? { debugName: "dirInput" } : /* istanbul ignore next */ {}), alias: 'dir' });
743
+ this.dir = injectDirection(this.dirInput);
165
744
  /** Whether the combobox is disabled. */
166
745
  this.disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
167
- /** Whether the combobox is read-only. */
168
- this.readonly = input(false, { ...(ngDevMode ? { debugName: "readonly" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
746
+ /** Whether the combobox is read-only. Base UI prop name. */
747
+ this.readOnly = input(false, { ...(ngDevMode ? { debugName: "readOnly" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
169
748
  /** Whether a value is required (for forms). */
170
749
  this.required = input(false, { ...(ngDevMode ? { debugName: "required" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
171
750
  /** Whether keyboard navigation wraps at the list boundaries. */
@@ -228,8 +807,14 @@ class RdxComboboxRoot {
228
807
  * items outside the rendered window are not skipped by keyboard navigation.
229
808
  */
230
809
  this.virtualized = input(false, { ...(ngDevMode ? { debugName: "virtualized" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
231
- /** How item values are compared for equality (function or object key). */
232
- this.by = input(...(ngDevMode ? [undefined, { debugName: "by" }] : /* istanbul ignore next */ []));
810
+ /**
811
+ * Whether the list is laid out as a 2D grid: `ArrowUp`/`ArrowDown` move between rows (keeping the
812
+ * column), `ArrowLeft`/`ArrowRight` move within a row. Wrap items in `RdxComboboxRow`; the list
813
+ * switches to `role="grid"`. Not supported together with {@link virtualized}.
814
+ */
815
+ this.grid = input(false, { ...(ngDevMode ? { debugName: "grid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
816
+ /** How item values are compared for equality (a comparator function or an object key). Base UI prop name. */
817
+ this.isItemEqualToValue = input(...(ngDevMode ? [undefined, { debugName: "isItemEqualToValue" }] : /* istanbul ignore next */ []));
233
818
  /** Converts a value to its display label. Defaults to the matching item's text. */
234
819
  this.itemToStringLabel = input(...(ngDevMode ? [undefined, { debugName: "itemToStringLabel" }] : /* istanbul ignore next */ []));
235
820
  /** Emits when the selection changes. */
@@ -245,97 +830,51 @@ class RdxComboboxRoot {
245
830
  this.onItemHighlighted = output();
246
831
  /** Emits after the open/close transition (including any exit animation) finishes. */
247
832
  this.onOpenChangeComplete = output();
248
- this.transition = useTransitionStatus((open) => this.onOpenChangeComplete.emit(open));
249
- /** Open/close transition phase, for `data-starting-style` / `data-ending-style`. */
250
- this.transitionStatus = this.transition.status;
251
- /** Registers the popup element whose animation determines transition completion. */
252
- this.registerTransitionElement = this.transition.registerElement;
253
- this.listId = injectId('rdx-combobox-list-');
254
- this.labelId = signal(undefined, ...(ngDevMode ? [{ debugName: "labelId" }] : /* istanbul ignore next */ []));
255
- this.inputElement = signal(null, ...(ngDevMode ? [{ debugName: "inputElement" }] : /* istanbul ignore next */ []));
256
833
  this.cvaDisabled = signal(false, ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : /* istanbul ignore next */ []));
257
834
  this.disabledState = computed(() => this.disabled() || this.cvaDisabled(), ...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
258
835
  this.requiredState = computed(() => this.required(), ...(ngDevMode ? [{ debugName: "requiredState" }] : /* istanbul ignore next */ []));
259
- this.defaultFilter = useFilter();
836
+ this.preventUnmountOnClose = signal(false, ...(ngDevMode ? [{ debugName: "preventUnmountOnClose" }] : /* istanbul ignore next */ []));
837
+ this.present = computed(() => this.open() || this.preventUnmountOnClose(), ...(ngDevMode ? [{ debugName: "present" }] : /* istanbul ignore next */ []));
260
838
  /**
261
839
  * Whether the input text is a fresh user query rather than the current selection's label. While
262
840
  * `false` (just opened, or showing a selected label), the list is unfiltered so the user can
263
841
  * browse; it flips `true` on the first keystroke.
264
842
  */
265
843
  this.typed = signal(false, ...(ngDevMode ? [{ debugName: "typed" }] : /* istanbul ignore next */ []));
266
- this._items = signal([], ...(ngDevMode ? [{ debugName: "_items" }] : /* istanbul ignore next */ []));
267
- /** Registered items, sorted into DOM order. */
268
- this.orderedItems = computed(() => {
269
- const items = [...this._items()];
270
- return items.sort((a, b) => {
271
- const position = a.element.compareDocumentPosition(b.element);
272
- if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
273
- return -1;
274
- }
275
- if (position & Node.DOCUMENT_POSITION_PRECEDING) {
276
- return 1;
277
- }
278
- return 0;
279
- });
280
- }, ...(ngDevMode ? [{ debugName: "orderedItems" }] : /* istanbul ignore next */ []));
281
- /** Matching items in DOM order, capped at `limit`. The set of items the list shows. */
282
- this.visibleItems = computed(() => {
283
- const matching = this.orderedItems().filter((item) => this.matchesFilter(item));
284
- const limit = this.limit();
285
- return limit >= 0 ? matching.slice(0, limit) : matching;
286
- }, ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ []));
287
- this.visibleSet = computed(() => new Set(this.visibleItems()), ...(ngDevMode ? [{ debugName: "visibleSet" }] : /* istanbul ignore next */ []));
288
- /**
289
- * The filtered item values an external virtualizer should render (the analogue of Base UI's
290
- * `useFilteredItems`). Driven by {@link items} when provided (virtualized mode), capped by
291
- * {@link limit}; otherwise mirrors the values of the mounted {@link visibleItems}.
292
- */
293
- this.filteredItems = computed(() => {
294
- const data = this.items();
295
- if (data === undefined) {
296
- return this.visibleItems().map((item) => item.value());
297
- }
298
- const limit = this.limit();
299
- const cap = (arr) => (limit >= 0 ? arr.slice(0, limit) : arr);
300
- const filter = this.filter();
301
- if (filter === null) {
302
- return cap(data);
303
- }
304
- const query = this.typed() ? (this.inputValue() ?? '') : '';
305
- if (!query) {
306
- return cap(data);
307
- }
308
- const matcher = filter ?? this.defaultFilter.contains;
309
- return cap(data.filter((value) => matcher(this.textFor(value), query)));
310
- }, ...(ngDevMode ? [{ debugName: "filteredItems" }] : /* istanbul ignore next */ []));
311
- this.visibleCount = computed(() => this.virtualized() ? this.filteredItems().length : this.visibleItems().length, ...(ngDevMode ? [{ debugName: "visibleCount" }] : /* istanbul ignore next */ []));
312
- this.highlight = useListHighlight({
313
- items: this.orderedItems,
314
- isNavigable: (item) => this.isVisible(item) && !item.disabled(),
315
- getId: (item) => item.id,
316
- loop: this.loopFocus,
317
- injector: this.injector
844
+ /** The active query: the typed text once the user starts typing, otherwise empty (browse mode). */
845
+ this.query = computed(() => (this.typed() ? (this.inputValue() ?? '') : ''), ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
846
+ /** Built-in filtering always applies for combobox (the `none` filter is handled inside the engine). */
847
+ this.filteringEnabled = signal(true, ...(ngDevMode ? [{ debugName: "filteringEnabled" }] : /* istanbul ignore next */ []));
848
+ /** Combobox never inline-completes (that's an autocomplete mode). */
849
+ this.noInline = signal(false, ...(ngDevMode ? [{ debugName: "noInline" }] : /* istanbul ignore next */ []));
850
+ /** The shared engine: item registry, filtering, highlight navigation, and the open-close transition. */
851
+ this.engine = useComboboxEngine({
852
+ injector: this.injector,
853
+ listIdPrefix: 'rdx-combobox-list-',
854
+ popupSelector: '[rdxComboboxPopup]',
855
+ open: this.open,
856
+ query: this.query,
857
+ filteringEnabled: this.filteringEnabled,
858
+ loopFocus: this.loopFocus,
859
+ autoHighlightMode: this.autoHighlightMode,
860
+ virtualized: this.virtualized,
861
+ items: this.items,
862
+ filter: this.filter,
863
+ limit: this.limit,
864
+ grid: this.grid,
865
+ rowOf: (element) => element.closest('[rdxComboboxRow]'),
866
+ inlineMode: this.noInline,
867
+ itemToString: (value) => this.labelFor(value),
868
+ onItemHighlighted: (details) => this.onItemHighlighted.emit(details),
869
+ onOpenChange: () => { },
870
+ onOpenChangeComplete: (open) => this.onOpenChangeComplete.emit(open)
318
871
  });
319
- this.highlightedItem = this.highlight.highlightedItem;
320
- /** Highlighted index into {@link filteredItems} in virtualized mode (`-1` when cleared). */
321
- this.highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : /* istanbul ignore next */ []));
322
- /** Why the highlight last moved; read when emitting {@link onItemHighlighted}. */
323
- this.highlightReason = signal('none', ...(ngDevMode ? [{ debugName: "highlightReason" }] : /* istanbul ignore next */ []));
324
- this.activeId = computed(() => {
325
- if (this.virtualized()) {
326
- const index = this.highlightedIndex();
327
- return index >= 0 ? this.itemId(index) : undefined;
328
- }
329
- return this.highlight.activeId();
330
- }, ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
331
- /** Edge to highlight once the list has mounted (items register asynchronously after opening). */
332
- this.pendingHighlightEdge = signal(null, ...(ngDevMode ? [{ debugName: "pendingHighlightEdge" }] : /* istanbul ignore next */ []));
333
- // Tracks whether the last interaction was the keyboard, so the highlight doesn't jump to an item
334
- // the cursor happens to rest on when arrow-key navigation scrolls the list under a still pointer.
335
- this.keyboardActive = false;
336
- /** The trigger element, used as a focus fallback when the input lives inside the popup. */
337
- this.triggerElement = null;
338
872
  this.chipsFocusLast = null;
873
+ // Expose the (private) engine to the context factory, which is a free function.
874
+ engineRegistry.set(this, this.engine);
875
+ // Keep the dismissal reference in sync with the input (the anchor) so a press / focus on it counts
876
+ // as "inside" and never dismisses (ADR 0015).
877
+ effect(() => this.floatingContext.setReferenceElement(this.engine.inputElement() ?? null));
339
878
  // Apply uncontrolled defaults once.
340
879
  effect(() => {
341
880
  const initial = this.defaultValue();
@@ -348,105 +887,6 @@ class RdxComboboxRoot {
348
887
  this.open.set(true);
349
888
  }
350
889
  });
351
- // Emit open changes and drive the open/close transition (skip the initial run).
352
- let previousOpen = untracked(this.open);
353
- effect(() => {
354
- const open = this.open();
355
- if (open === previousOpen) {
356
- return;
357
- }
358
- previousOpen = open;
359
- untracked(() => {
360
- this.onOpenChange.emit(open);
361
- this.transition.start(open);
362
- });
363
- });
364
- // Emit highlight changes (skip the initial run). Tracks both the DOM-ref highlight and the
365
- // virtualized index; only one is active per mode, so the other never fires spuriously.
366
- let highlightInitialized = false;
367
- effect(() => {
368
- const item = this.highlightedItem();
369
- const index = this.highlightedIndex();
370
- if (!highlightInitialized) {
371
- highlightInitialized = true;
372
- return;
373
- }
374
- untracked(() => {
375
- const reason = this.highlightReason();
376
- if (this.virtualized()) {
377
- const value = index >= 0 ? (this.filteredItems()[index] ?? null) : null;
378
- this.onItemHighlighted.emit({ value, index, reason });
379
- }
380
- else {
381
- const value = item ? item.value() : null;
382
- const itemIndex = item ? this.visibleItems().indexOf(item) : -1;
383
- this.onItemHighlighted.emit({ value, index: itemIndex, reason });
384
- }
385
- });
386
- });
387
- // Apply a deferred open-edge highlight once items (DOM refs) or filtered data have registered.
388
- effect(() => {
389
- const edge = this.pendingHighlightEdge();
390
- const count = this.virtualized() ? this.filteredItems().length : this.orderedItems().length;
391
- if (!this.open() || edge === null || count === 0) {
392
- return;
393
- }
394
- untracked(() => {
395
- // Programmatic move — reset the reason in both modes so the emit reports 'none', not a
396
- // stale 'keyboard'/'pointer' left by the previous user interaction.
397
- this.highlightReason.set('none');
398
- if (this.virtualized()) {
399
- this.highlightedIndex.set(edge === 'first' ? 0 : count - 1);
400
- }
401
- else if (edge === 'first') {
402
- this.highlight.first();
403
- }
404
- else {
405
- this.highlight.last();
406
- }
407
- this.pendingHighlightEdge.set(null);
408
- });
409
- });
410
- // autoHighlight 'always': keep the first navigable item highlighted whenever the popup is
411
- // open. `visibleCount` re-runs this when filtering changes; the current highlight is read
412
- // untracked to re-establish a highlight only after the self-heal clears it (no loop).
413
- effect(() => {
414
- this.orderedItems();
415
- this.visibleCount();
416
- if (this.autoHighlightMode() === 'always' && this.open()) {
417
- untracked(() => {
418
- if (this.virtualized()) {
419
- // Re-seed when the index is cleared OR has fallen out of range, so this works
420
- // regardless of whether the self-heal effect ran first (no ordering dependency).
421
- const length = this.filteredItems().length;
422
- const index = this.highlightedIndex();
423
- if ((index < 0 || index >= length) && length > 0) {
424
- this.highlightReason.set('none');
425
- this.highlightedIndex.set(0);
426
- }
427
- }
428
- else if (this.highlightedItem() === null) {
429
- this.highlightReason.set('none');
430
- this.highlight.first();
431
- }
432
- });
433
- }
434
- });
435
- // Virtualized self-heal: clear a highlight that filtering has pushed out of range, so
436
- // `activeId` never references an index past the end of the filtered list.
437
- effect(() => {
438
- if (!this.virtualized()) {
439
- return;
440
- }
441
- const length = this.filteredItems().length;
442
- untracked(() => {
443
- const index = this.highlightedIndex();
444
- if (index >= length && index !== -1) {
445
- this.highlightReason.set('none');
446
- this.highlightedIndex.set(-1);
447
- }
448
- });
449
- });
450
890
  // Virtualized object values can't be labelled from the DOM (items aren't registered) — without
451
891
  // `itemToStringLabel`, selection/revert fall back to a generic label. Warn once in dev.
452
892
  if (isDevMode()) {
@@ -464,46 +904,41 @@ class RdxComboboxRoot {
464
904
  }
465
905
  }
466
906
  /** Opens the popup for browsing (resets the query to "pristine" and selects the input text). */
467
- openForBrowse() {
907
+ openForBrowse(reason = 'none', event = new Event('combobox.open-change')) {
468
908
  if (!this.open()) {
469
909
  this.typed.set(false);
470
910
  }
471
- this.setOpen(true);
472
- this.selectInputText();
911
+ this.setOpen(true, reason, event);
912
+ this.engine.selectInputText();
473
913
  if (this.autoHighlightMode() === 'always') {
474
- this.pendingHighlightEdge.set('first');
914
+ this.engine.setPendingHighlightEdge('first');
475
915
  }
476
916
  }
477
917
  /** Opens the popup and highlights the given edge once the list mounts. */
478
- openAndHighlight(edge) {
918
+ openAndHighlight(edge, reason = 'list-navigation', event = new Event('combobox.open-change')) {
479
919
  if (!this.open()) {
480
920
  this.typed.set(false);
481
921
  }
482
- this.setOpen(true);
483
- this.selectInputText();
484
- this.pendingHighlightEdge.set(edge);
922
+ this.setOpen(true, reason, event);
923
+ this.engine.selectInputText();
924
+ this.engine.setPendingHighlightEdge(edge);
485
925
  }
486
- /** Whether the item matches the active query (ignores the `limit` cap). */
487
- matchesFilter(item) {
488
- const filter = this.filter();
489
- if (filter === null) {
490
- return true;
926
+ /**
927
+ * Keyboard list navigation shared by the input and the chips: opens the popup and highlights the
928
+ * leading/trailing edge when closed, otherwise steps the highlight. `direction` is `1` (down) or
929
+ * `-1` (up). Centralized so the input and chip key handlers can't drift apart.
930
+ */
931
+ navigateByKeyboard(direction, event = new Event('combobox.open-change')) {
932
+ this.engine.setKeyboardActive(true);
933
+ if (!this.open()) {
934
+ this.openAndHighlight(direction === 1 ? 'first' : 'last', 'list-navigation', event);
935
+ }
936
+ else if (direction === 1) {
937
+ this.engine.highlightNext();
938
+ }
939
+ else {
940
+ this.engine.highlightPrevious();
491
941
  }
492
- // Until the user types a fresh query, show the whole list (the input may still hold the
493
- // selected item's label, which must not filter everything down to just that item).
494
- const query = this.typed() ? (this.inputValue() ?? '') : '';
495
- const matcher = filter ?? this.defaultFilter.contains;
496
- return matcher(item.textValue(), query);
497
- }
498
- /** Whether the item is shown in the list (matches the query and is within `limit`). */
499
- isVisible(item) {
500
- return this.visibleSet().has(item);
501
- }
502
- isKeyboardActive() {
503
- return this.keyboardActive;
504
- }
505
- setKeyboardActive(value) {
506
- this.keyboardActive = value;
507
942
  }
508
943
  isSelected(value) {
509
944
  if (this.mode() === 'none') {
@@ -511,28 +946,34 @@ class RdxComboboxRoot {
511
946
  }
512
947
  const current = this.value();
513
948
  if (this.multiple()) {
514
- return Array.isArray(current) && current.some((v) => isItemEqualToValue(v, value, this.by()));
949
+ return Array.isArray(current) && current.some((v) => isItemEqualToValue(v, value, this.isItemEqualToValue()));
515
950
  }
516
- return !isNullish(current) && isItemEqualToValue(current, value, this.by());
517
- }
518
- registerItem(item) {
519
- this._items.update((items) => [...items, item]);
520
- }
521
- unregisterItem(item) {
522
- this._items.update((items) => items.filter((i) => i !== item));
951
+ return !isNullish(current) && isItemEqualToValue(current, value, this.isItemEqualToValue());
523
952
  }
524
- setOpen(open) {
525
- if (this.disabledState() || this.readonly()) {
526
- return;
953
+ setOpen(open, reason = 'none', event = new Event('combobox.open-change')) {
954
+ if (open === this.open()) {
955
+ return true;
956
+ }
957
+ if (open && (this.disabledState() || this.readOnly())) {
958
+ return false;
527
959
  }
960
+ const change = this.createOpenChangeEvent(open, reason, event);
961
+ this.onOpenChange.emit(change.payload);
962
+ if (change.eventDetails.isCanceled()) {
963
+ return false;
964
+ }
965
+ this.preventUnmountOnClose.set(open ? false : change.shouldPreventUnmountOnClose());
528
966
  this.open.set(open);
967
+ return true;
529
968
  }
530
- closePopup(revert = true) {
969
+ closePopup(revert = true, reason = 'none', event = new Event('combobox.open-change')) {
531
970
  if (!this.open()) {
532
971
  return;
533
972
  }
534
- this.open.set(false);
535
- this.clearHighlightState();
973
+ if (!this.setOpen(false, reason, event)) {
974
+ return;
975
+ }
976
+ this.engine.clearHighlightState();
536
977
  if (revert) {
537
978
  this.revertInputValue();
538
979
  }
@@ -543,9 +984,19 @@ class RdxComboboxRoot {
543
984
  this.inputValue.set(value);
544
985
  this.typed.set(true);
545
986
  this.onInputValueChange.emit(value);
987
+ // Base UI: emptying the field clears a single selection — but only when the input is OUTSIDE the
988
+ // popup. With the input inside the popup, the search box and the committed value are independent,
989
+ // so clearing the search must not deselect. (multiple keeps its chips; `none` has no committed
990
+ // value.) The guarded `commitValue` is a no-op when read-only / disabled.
991
+ if (value === '' &&
992
+ this.mode() === 'single' &&
993
+ !isNullish(this.value()) &&
994
+ this.engine.inputLayout() !== 'inside') {
995
+ this.commitValue(null);
996
+ }
546
997
  // Auto-highlight the first match as the query changes (deferred so it lands after items mount).
547
998
  if (this.autoHighlightMode() !== 'off') {
548
- this.pendingHighlightEdge.set('first');
999
+ this.engine.setPendingHighlightEdge('first');
549
1000
  }
550
1001
  }
551
1002
  /** Sets the input text programmatically (a selection label / revert) — not a user query. */
@@ -554,10 +1005,6 @@ class RdxComboboxRoot {
554
1005
  this.typed.set(false);
555
1006
  this.onInputValueChange.emit(value);
556
1007
  }
557
- /** Selects all input text so the next keystroke replaces a stale selection label. */
558
- selectInputText() {
559
- this.inputElement()?.select();
560
- }
561
1008
  /** Resets the input text to the current selection's label (single mode) or empty. */
562
1009
  revertInputValue() {
563
1010
  if (this.multiple()) {
@@ -572,37 +1019,31 @@ class RdxComboboxRoot {
572
1019
  if (custom) {
573
1020
  return custom(value);
574
1021
  }
575
- const item = this.orderedItems().find((i) => isItemEqualToValue(i.value(), value, this.by()));
1022
+ const item = this.engine.orderedItems().find((i) => isItemEqualToValue(i.value(), value, this.isItemEqualToValue()));
576
1023
  return item ? item.textValue() : itemToStringLabel(value);
577
1024
  }
578
- /** Filter/label text for a raw item value (virtualized mode, no DOM element to read from). */
579
- textFor(value) {
580
- const custom = this.itemToStringLabel();
581
- return custom ? custom(value) : itemToStringLabel(value);
582
- }
583
- /** Deterministic id for the item at `index` in virtualized mode (matches `aria-activedescendant`). */
584
- itemId(index) {
585
- return `${this.listId}-item-${index}`;
586
- }
587
- handleSelect(item) {
588
- if (this.disabledState() || this.readonly() || item.disabled()) {
1025
+ handleSelect(item, event = new Event('combobox.item-press')) {
1026
+ if (this.disabledState() || this.readOnly() || item.disabled()) {
589
1027
  return;
590
1028
  }
591
- this.handleSelectValue(item.value(), item.textValue() || this.labelFor(item.value()));
1029
+ this.handleSelectValue(item.value(), item.textValue() || this.labelFor(item.value()), event);
592
1030
  }
593
1031
  /** Selects the filtered item at `index` (virtualized mode). The label comes from {@link labelFor}. */
594
- selectIndex(index) {
595
- if (this.disabledState() || this.readonly()) {
1032
+ selectIndex(index, event = new Event('combobox.item-press')) {
1033
+ if (this.disabledState() || this.readOnly()) {
596
1034
  return;
597
1035
  }
598
- const value = this.filteredItems()[index];
1036
+ const value = this.engine.filteredItems()[index];
599
1037
  if (value === undefined) {
600
1038
  return;
601
1039
  }
602
- this.handleSelectValue(value, this.labelFor(value));
1040
+ this.handleSelectValue(value, this.labelFor(value), event);
603
1041
  }
604
1042
  /** Commits a selection from a resolved value/label, independent of whether a DOM item exists. */
605
- handleSelectValue(value, textValue) {
1043
+ handleSelectValue(value, textValue, event = new Event('combobox.item-press')) {
1044
+ // Capture focus *before* emitting `onValueChange` so focus restoration can be skipped when the
1045
+ // consumer moves focus in that callback (e.g. focusing an external field after an emoji press).
1046
+ const activeBefore = typeof document !== 'undefined' ? document.activeElement : null;
606
1047
  if (this.mode() === 'none') {
607
1048
  // No value is committed; `onValueChange` fires as a pointer/keyboard activation signal so
608
1049
  // command-palette consumers can react. Optionally fill the input, then close.
@@ -610,15 +1051,14 @@ class RdxComboboxRoot {
610
1051
  if (this.fillInputOnItemPress()) {
611
1052
  this.setLabel(textValue);
612
1053
  }
613
- this.open.set(false);
614
- this.clearHighlightState();
615
- this.restoreFocusAfterSelect();
1054
+ this.closePopup(false, 'item-press', event);
1055
+ this.engine.restoreFocusAfterSelect(activeBefore);
616
1056
  this.maybeSubmit();
617
1057
  return;
618
1058
  }
619
1059
  if (this.multiple()) {
620
1060
  const current = Array.isArray(this.value()) ? [...this.value()] : [];
621
- const index = current.findIndex((v) => isItemEqualToValue(v, value, this.by()));
1061
+ const index = current.findIndex((v) => isItemEqualToValue(v, value, this.isItemEqualToValue()));
622
1062
  if (index === -1) {
623
1063
  current.push(value);
624
1064
  }
@@ -627,124 +1067,57 @@ class RdxComboboxRoot {
627
1067
  }
628
1068
  this.commitValue(current);
629
1069
  this.setLabel('');
630
- this.focusInput();
1070
+ // Keep the input focused for adding more values — unless the consumer moved focus.
1071
+ if (typeof document === 'undefined' || document.activeElement === activeBefore) {
1072
+ this.engine.focusInput();
1073
+ }
631
1074
  }
632
1075
  else {
633
1076
  this.commitValue(value);
634
1077
  this.setLabel(textValue);
635
- this.open.set(false);
636
- this.clearHighlightState();
637
- this.restoreFocusAfterSelect();
1078
+ this.closePopup(false, 'item-press', event);
1079
+ this.engine.restoreFocusAfterSelect(activeBefore);
638
1080
  }
639
1081
  this.maybeSubmit();
640
1082
  }
641
1083
  /** Requests submit of the closest form when `submitOnItemClick` is enabled. */
642
1084
  maybeSubmit() {
643
1085
  if (this.submitOnItemClick()) {
644
- this.inputElement()?.form?.requestSubmit?.();
1086
+ this.engine.inputElement()?.form?.requestSubmit?.();
645
1087
  }
646
1088
  }
647
- selectHighlighted() {
1089
+ selectHighlighted(event = new Event('combobox.item-press')) {
648
1090
  if (this.virtualized()) {
649
- const index = this.highlightedIndex();
1091
+ const index = this.engine.highlightedIndex();
650
1092
  if (index >= 0) {
651
- this.selectIndex(index);
1093
+ this.selectIndex(index, event);
652
1094
  }
653
1095
  return;
654
1096
  }
655
- const item = this.highlightedItem();
1097
+ const item = this.engine.highlightedItem();
656
1098
  if (item) {
657
- this.handleSelect(item);
658
- }
659
- }
660
- // --- Highlight navigation facade (mode-aware: index-based when virtualized, else DOM-ref) ---
661
- highlightNext(reason = 'keyboard') {
662
- this.highlightReason.set(reason);
663
- if (this.virtualized()) {
664
- this.stepIndex(1);
665
- }
666
- else {
667
- this.highlight.next();
668
- }
669
- }
670
- highlightPrevious(reason = 'keyboard') {
671
- this.highlightReason.set(reason);
672
- if (this.virtualized()) {
673
- this.stepIndex(-1);
674
- }
675
- else {
676
- this.highlight.previous();
677
- }
678
- }
679
- highlightFirst(reason = 'keyboard') {
680
- this.highlightReason.set(reason);
681
- if (this.virtualized()) {
682
- this.highlightedIndex.set(this.filteredItems().length > 0 ? 0 : -1);
683
- }
684
- else {
685
- this.highlight.first();
686
- }
687
- }
688
- highlightLast(reason = 'keyboard') {
689
- this.highlightReason.set(reason);
690
- if (this.virtualized()) {
691
- const length = this.filteredItems().length;
692
- this.highlightedIndex.set(length > 0 ? length - 1 : -1);
693
- }
694
- else {
695
- this.highlight.last();
696
- }
697
- }
698
- /** Highlights a specific index in virtualized mode (e.g. pointer hover). Ignored if out of range. */
699
- highlightIndex(index, reason) {
700
- if (index < 0 || index >= this.filteredItems().length) {
701
- return;
1099
+ this.handleSelect(item, event);
702
1100
  }
703
- this.highlightReason.set(reason);
704
- this.highlightedIndex.set(index);
705
1101
  }
706
- /** Highlights a DOM-ref item (non-virtualized pointer hover). */
707
- setHighlight(item, reason) {
708
- this.highlightReason.set(reason);
709
- this.highlight.set(item);
710
- }
711
- /** Clears whichever highlight model is active. */
712
- clearHighlightState() {
713
- this.highlight.clear();
714
- this.highlightedIndex.set(-1);
715
- }
716
- /** Steps the virtualized highlight index by `direction`, wrapping when {@link loopFocus}. */
717
- stepIndex(direction) {
718
- const length = this.filteredItems().length;
719
- if (length === 0) {
720
- this.highlightedIndex.set(-1);
721
- return;
722
- }
723
- const current = this.highlightedIndex();
724
- if (current < 0) {
725
- this.highlightedIndex.set(direction === 1 ? 0 : length - 1);
1102
+ clearSelection() {
1103
+ // Read-only / disabled comboboxes are not user-mutable (Base UI blocks Clear here too).
1104
+ if (this.disabledState() || this.readOnly()) {
726
1105
  return;
727
1106
  }
728
- let next = current + direction;
729
- const loop = this.loopFocus();
730
- if (next < 0) {
731
- next = loop ? length - 1 : 0;
732
- }
733
- else if (next >= length) {
734
- next = loop ? 0 : length - 1;
1107
+ // In `none` mode there is no committed value to clear — only the input text. Otherwise reset
1108
+ // the selection. Also drop any highlight (Base UI resets the active/selected indices here).
1109
+ if (this.mode() !== 'none') {
1110
+ this.commitValue(this.multiple() ? [] : null);
735
1111
  }
736
- this.highlightedIndex.set(next);
737
- }
738
- clearSelection() {
739
- this.commitValue(this.multiple() ? [] : null);
740
1112
  this.setLabel('');
741
- this.focusInput();
1113
+ this.engine.clearHighlightState();
1114
+ this.engine.focusInput();
742
1115
  }
743
1116
  removeValue(value) {
744
1117
  if (!this.multiple() || !Array.isArray(this.value())) {
745
1118
  return;
746
1119
  }
747
- const next = this.value().filter((v) => !isItemEqualToValue(v, value, this.by()));
1120
+ const next = this.value().filter((v) => !isItemEqualToValue(v, value, this.isItemEqualToValue()));
748
1121
  this.commitValue(next);
749
1122
  }
750
1123
  removeLastValue() {
@@ -757,21 +1130,7 @@ class RdxComboboxRoot {
757
1130
  }
758
1131
  }
759
1132
  focusInput() {
760
- this.inputElement()?.focus();
761
- }
762
- /**
763
- * Restores focus after a selection closes the popup, so the keyboard can reopen it. When the
764
- * input lives inside the popup it is about to unmount, so focus goes to the trigger instead;
765
- * otherwise it returns to the input. Done synchronously while the input is still in the DOM.
766
- */
767
- restoreFocusAfterSelect() {
768
- const input = this.inputElement();
769
- if (input && !input.closest('[rdxComboboxPopup]')) {
770
- input.focus();
771
- }
772
- else {
773
- this.triggerElement?.focus();
774
- }
1133
+ this.engine.focusInput();
775
1134
  }
776
1135
  /** Registered by `RdxComboboxChips` so the input can hand keyboard focus to the chips. */
777
1136
  registerChipsNav(fn) {
@@ -784,11 +1143,40 @@ class RdxComboboxRoot {
784
1143
  markAsTouched() {
785
1144
  this.onTouched?.();
786
1145
  }
1146
+ /**
1147
+ * The single guarded value-mutation point for all user-driven changes (selection toggle, Clear,
1148
+ * chip removal, Backspace). Read-only / disabled comboboxes never mutate here — programmatic form
1149
+ * writes go through {@link writeValue}, which is intentionally not guarded. (ADR 0014, Finding 1.)
1150
+ */
787
1151
  commitValue(value) {
1152
+ if (this.disabledState() || this.readOnly()) {
1153
+ return;
1154
+ }
788
1155
  this.value.set(value);
789
1156
  this.onValueChange.emit(value);
790
1157
  this.onChange?.(value);
791
1158
  }
1159
+ createOpenChangeEvent(open, reason, event) {
1160
+ const change = createCancelableChangeEventDetails(reason, event, this.resolveOpenChangeTrigger(event));
1161
+ return {
1162
+ payload: {
1163
+ open,
1164
+ reason,
1165
+ event: change.eventDetails.event,
1166
+ trigger: change.eventDetails.trigger,
1167
+ eventDetails: change.eventDetails
1168
+ },
1169
+ eventDetails: change.eventDetails,
1170
+ shouldPreventUnmountOnClose: change.shouldPreventUnmountOnClose
1171
+ };
1172
+ }
1173
+ resolveOpenChangeTrigger(event) {
1174
+ const target = event.target;
1175
+ if (target instanceof HTMLElement) {
1176
+ return target;
1177
+ }
1178
+ return this.engine.triggerElement ?? this.engine.inputElement() ?? undefined;
1179
+ }
792
1180
  // ControlValueAccessor
793
1181
  writeValue(value) {
794
1182
  untracked(() => this.value.set(value));
@@ -803,9 +1191,12 @@ class RdxComboboxRoot {
803
1191
  this.cvaDisabled.set(isDisabled);
804
1192
  }
805
1193
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxRoot, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
806
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxRoot, isStandalone: true, selector: "[rdxComboboxRoot]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, defaultValue: { classPropertyName: "defaultValue", publicName: "defaultValue", isSignal: true, isRequired: false, transformFunction: null }, inputValue: { classPropertyName: "inputValue", publicName: "inputValue", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaultOpen: { classPropertyName: "defaultOpen", publicName: "defaultOpen", isSignal: true, isRequired: false, transformFunction: null }, multipleInput: { classPropertyName: "multipleInput", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, selectionMode: { classPropertyName: "selectionMode", publicName: "selectionMode", isSignal: true, isRequired: false, transformFunction: null }, fillInputOnItemPress: { classPropertyName: "fillInputOnItemPress", publicName: "fillInputOnItemPress", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, loopFocus: { classPropertyName: "loopFocus", publicName: "loopFocus", isSignal: true, isRequired: false, transformFunction: null }, autoHighlight: { classPropertyName: "autoHighlight", publicName: "autoHighlight", isSignal: true, isRequired: false, transformFunction: null }, highlightItemOnHover: { classPropertyName: "highlightItemOnHover", publicName: "highlightItemOnHover", isSignal: true, isRequired: false, transformFunction: null }, keepHighlight: { classPropertyName: "keepHighlight", publicName: "keepHighlight", isSignal: true, isRequired: false, transformFunction: null }, openOnInputClick: { classPropertyName: "openOnInputClick", publicName: "openOnInputClick", isSignal: true, isRequired: false, transformFunction: null }, modal: { classPropertyName: "modal", publicName: "modal", isSignal: true, isRequired: false, transformFunction: null }, submitOnItemClick: { classPropertyName: "submitOnItemClick", publicName: "submitOnItemClick", isSignal: true, isRequired: false, transformFunction: null }, filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null }, limit: { classPropertyName: "limit", publicName: "limit", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, virtualized: { classPropertyName: "virtualized", publicName: "virtualized", isSignal: true, isRequired: false, transformFunction: null }, by: { classPropertyName: "by", publicName: "by", isSignal: true, isRequired: false, transformFunction: null }, itemToStringLabel: { classPropertyName: "itemToStringLabel", publicName: "itemToStringLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", inputValue: "inputValueChange", open: "openChange", onValueChange: "onValueChange", onInputValueChange: "onInputValueChange", onOpenChange: "onOpenChange", onItemHighlighted: "onItemHighlighted", onOpenChangeComplete: "onOpenChangeComplete" }, host: { properties: { "attr.data-disabled": "disabledState() ? \"\" : undefined" } }, providers: [
1194
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxRoot, isStandalone: true, selector: "[rdxComboboxRoot]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, defaultValue: { classPropertyName: "defaultValue", publicName: "defaultValue", isSignal: true, isRequired: false, transformFunction: null }, inputValue: { classPropertyName: "inputValue", publicName: "inputValue", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null }, defaultOpen: { classPropertyName: "defaultOpen", publicName: "defaultOpen", isSignal: true, isRequired: false, transformFunction: null }, multipleInput: { classPropertyName: "multipleInput", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, selectionMode: { classPropertyName: "selectionMode", publicName: "selectionMode", isSignal: true, isRequired: false, transformFunction: null }, fillInputOnItemPress: { classPropertyName: "fillInputOnItemPress", publicName: "fillInputOnItemPress", isSignal: true, isRequired: false, transformFunction: null }, dirInput: { classPropertyName: "dirInput", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readOnly: { classPropertyName: "readOnly", publicName: "readOnly", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, loopFocus: { classPropertyName: "loopFocus", publicName: "loopFocus", isSignal: true, isRequired: false, transformFunction: null }, autoHighlight: { classPropertyName: "autoHighlight", publicName: "autoHighlight", isSignal: true, isRequired: false, transformFunction: null }, highlightItemOnHover: { classPropertyName: "highlightItemOnHover", publicName: "highlightItemOnHover", isSignal: true, isRequired: false, transformFunction: null }, keepHighlight: { classPropertyName: "keepHighlight", publicName: "keepHighlight", isSignal: true, isRequired: false, transformFunction: null }, openOnInputClick: { classPropertyName: "openOnInputClick", publicName: "openOnInputClick", isSignal: true, isRequired: false, transformFunction: null }, modal: { classPropertyName: "modal", publicName: "modal", isSignal: true, isRequired: false, transformFunction: null }, submitOnItemClick: { classPropertyName: "submitOnItemClick", publicName: "submitOnItemClick", isSignal: true, isRequired: false, transformFunction: null }, filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null }, limit: { classPropertyName: "limit", publicName: "limit", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, virtualized: { classPropertyName: "virtualized", publicName: "virtualized", isSignal: true, isRequired: false, transformFunction: null }, grid: { classPropertyName: "grid", publicName: "grid", 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 } }, outputs: { value: "valueChange", inputValue: "inputValueChange", open: "openChange", onValueChange: "onValueChange", onInputValueChange: "onInputValueChange", onOpenChange: "onOpenChange", onItemHighlighted: "onItemHighlighted", onOpenChangeComplete: "onOpenChangeComplete" }, host: { properties: { "attr.data-disabled": "disabledState() ? \"\" : undefined" } }, providers: [
807
1195
  provideComboboxRootContext(context),
808
- { provide: NG_VALUE_ACCESSOR, useExisting: RdxComboboxRoot, multi: true }
1196
+ { provide: NG_VALUE_ACCESSOR, useExisting: RdxComboboxRoot, multi: true },
1197
+ // New floating foundation (ADR 0015/0017) — the dismissal capability reads this shared context.
1198
+ provideFloatingTree(),
1199
+ provideFloatingRootContext(() => inject(RdxComboboxRoot).floatingContext)
809
1200
  ], exportAs: ["rdxComboboxRoot"], hostDirectives: [{ directive: i1.RdxPopper }], ngImport: i0 }); }
810
1201
  }
811
1202
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxRoot, decorators: [{
@@ -815,14 +1206,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
815
1206
  exportAs: 'rdxComboboxRoot',
816
1207
  providers: [
817
1208
  provideComboboxRootContext(context),
818
- { provide: NG_VALUE_ACCESSOR, useExisting: RdxComboboxRoot, multi: true }
1209
+ { provide: NG_VALUE_ACCESSOR, useExisting: RdxComboboxRoot, multi: true },
1210
+ // New floating foundation (ADR 0015/0017) — the dismissal capability reads this shared context.
1211
+ provideFloatingTree(),
1212
+ provideFloatingRootContext(() => inject(RdxComboboxRoot).floatingContext)
819
1213
  ],
820
1214
  hostDirectives: [RdxPopper],
821
1215
  host: {
822
1216
  '[attr.data-disabled]': 'disabledState() ? "" : undefined'
823
1217
  }
824
1218
  }]
825
- }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], defaultValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultValue", required: false }] }], inputValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputValue", required: false }] }, { type: i0.Output, args: ["inputValueChange"] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], defaultOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultOpen", required: false }] }], multipleInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], selectionMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionMode", required: false }] }], fillInputOnItemPress: [{ type: i0.Input, args: [{ isSignal: true, alias: "fillInputOnItemPress", required: false }] }], dir: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], loopFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "loopFocus", required: false }] }], autoHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoHighlight", required: false }] }], highlightItemOnHover: [{ type: i0.Input, args: [{ isSignal: true, alias: "highlightItemOnHover", required: false }] }], keepHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepHighlight", required: false }] }], openOnInputClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "openOnInputClick", required: false }] }], modal: [{ type: i0.Input, args: [{ isSignal: true, alias: "modal", required: false }] }], submitOnItemClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitOnItemClick", required: false }] }], filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }], limit: [{ type: i0.Input, args: [{ isSignal: true, alias: "limit", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], virtualized: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtualized", required: false }] }], by: [{ type: i0.Input, args: [{ isSignal: true, alias: "by", required: false }] }], itemToStringLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToStringLabel", required: false }] }], onValueChange: [{ type: i0.Output, args: ["onValueChange"] }], onInputValueChange: [{ type: i0.Output, args: ["onInputValueChange"] }], onOpenChange: [{ type: i0.Output, args: ["onOpenChange"] }], onItemHighlighted: [{ type: i0.Output, args: ["onItemHighlighted"] }], onOpenChangeComplete: [{ type: i0.Output, args: ["onOpenChangeComplete"] }] } });
1219
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], defaultValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultValue", required: false }] }], inputValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputValue", required: false }] }, { type: i0.Output, args: ["inputValueChange"] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }, { type: i0.Output, args: ["openChange"] }], defaultOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultOpen", required: false }] }], multipleInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], selectionMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionMode", required: false }] }], fillInputOnItemPress: [{ type: i0.Input, args: [{ isSignal: true, alias: "fillInputOnItemPress", required: false }] }], dirInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "dir", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readOnly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], loopFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "loopFocus", required: false }] }], autoHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoHighlight", required: false }] }], highlightItemOnHover: [{ type: i0.Input, args: [{ isSignal: true, alias: "highlightItemOnHover", required: false }] }], keepHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepHighlight", required: false }] }], openOnInputClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "openOnInputClick", required: false }] }], modal: [{ type: i0.Input, args: [{ isSignal: true, alias: "modal", required: false }] }], submitOnItemClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitOnItemClick", required: false }] }], filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }], limit: [{ type: i0.Input, args: [{ isSignal: true, alias: "limit", required: false }] }], items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], virtualized: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtualized", required: false }] }], grid: [{ type: i0.Input, args: [{ isSignal: true, alias: "grid", required: false }] }], isItemEqualToValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "isItemEqualToValue", required: false }] }], itemToStringLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemToStringLabel", required: false }] }], onValueChange: [{ type: i0.Output, args: ["onValueChange"] }], onInputValueChange: [{ type: i0.Output, args: ["onInputValueChange"] }], onOpenChange: [{ type: i0.Output, args: ["onOpenChange"] }], onItemHighlighted: [{ type: i0.Output, args: ["onItemHighlighted"] }], onOpenChangeComplete: [{ type: i0.Output, args: ["onOpenChangeComplete"] }] } });
826
1220
 
827
1221
  /**
828
1222
  * An overlay rendered beneath the popup in `modal` mode. Place it inside the portal/presence; style
@@ -836,7 +1230,7 @@ class RdxComboboxBackdrop {
836
1230
  this.rootContext = injectComboboxRootContext();
837
1231
  }
838
1232
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxBackdrop, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
839
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxBackdrop, isStandalone: true, selector: "[rdxComboboxBackdrop]", host: { attributes: { "aria-hidden": "true" }, properties: { "attr.data-state": "rootContext.open() ? \"open\" : \"closed\"", "attr.data-open": "rootContext.open() ? \"\" : undefined", "attr.data-closed": "rootContext.open() ? undefined : \"\"" } }, exportAs: ["rdxComboboxBackdrop"], ngImport: i0 }); }
1233
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxBackdrop, isStandalone: true, selector: "[rdxComboboxBackdrop]", host: { attributes: { "role": "presentation" }, properties: { "attr.data-state": "rootContext.open() ? \"open\" : \"closed\"", "attr.data-open": "rootContext.open() ? \"\" : undefined", "attr.data-closed": "rootContext.open() ? undefined : \"\"" } }, exportAs: ["rdxComboboxBackdrop"], ngImport: i0 }); }
840
1234
  }
841
1235
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxBackdrop, decorators: [{
842
1236
  type: Directive,
@@ -844,7 +1238,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
844
1238
  selector: '[rdxComboboxBackdrop]',
845
1239
  exportAs: 'rdxComboboxBackdrop',
846
1240
  host: {
847
- 'aria-hidden': 'true',
1241
+ // A decorative overlay — Base UI marks it `role="presentation"` (excluded from the a11y tree).
1242
+ role: 'presentation',
848
1243
  '[attr.data-state]': 'rootContext.open() ? "open" : "closed"',
849
1244
  '[attr.data-open]': 'rootContext.open() ? "" : undefined',
850
1245
  '[attr.data-closed]': 'rootContext.open() ? undefined : ""'
@@ -854,7 +1249,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
854
1249
 
855
1250
  /**
856
1251
  * Container for the selected-value chips in `multiple` mode. Sits before the input and coordinates
857
- * arrow-key navigation across the chips (the chips themselves handle the key events).
1252
+ * arrow-key navigation across the chips (the chips themselves handle the key events). Uses
1253
+ * `role="toolbar"` (Base UI): it keeps NVDA in focus mode while arrow-navigating the chips, where a
1254
+ * plain `list` would drop into browse mode.
858
1255
  *
859
1256
  * @group Components
860
1257
  */
@@ -879,16 +1276,16 @@ class RdxComboboxChips {
879
1276
  return true;
880
1277
  }
881
1278
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxChips, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
882
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxChips, isStandalone: true, selector: "[rdxComboboxChips]", host: { attributes: { "role": "list" } }, exportAs: ["rdxComboboxChips"], hostDirectives: [{ directive: i1$1.RdxDismissableLayerBranch }], ngImport: i0 }); }
1279
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxChips, isStandalone: true, selector: "[rdxComboboxChips]", host: { attributes: { "role": "toolbar" } }, exportAs: ["rdxComboboxChips"], hostDirectives: [{ directive: i1$1.RdxFloatingInsideElement }], ngImport: i0 }); }
883
1280
  }
884
1281
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxChips, decorators: [{
885
1282
  type: Directive,
886
1283
  args: [{
887
1284
  selector: '[rdxComboboxChips]',
888
1285
  exportAs: 'rdxComboboxChips',
889
- hostDirectives: [RdxDismissableLayerBranch],
1286
+ hostDirectives: [RdxFloatingInsideElement],
890
1287
  host: {
891
- role: 'list'
1288
+ role: 'toolbar'
892
1289
  }
893
1290
  }]
894
1291
  }], ctorParameters: () => [] });
@@ -916,21 +1313,35 @@ class RdxComboboxChip {
916
1313
  const list = this.chips?.getChips() ?? [];
917
1314
  const index = list.indexOf(this.element);
918
1315
  switch (event.key) {
1316
+ case 'ArrowDown':
1317
+ case 'ArrowUp':
1318
+ // Leave the chips and engage the list: focus must return to the input so
1319
+ // `aria-activedescendant` navigation works, then run the same nav as the input.
1320
+ event.preventDefault();
1321
+ this.rootContext.focusInput();
1322
+ this.rootContext.navigateByKeyboard(event.key === 'ArrowDown' ? 1 : -1);
1323
+ break;
919
1324
  case 'ArrowLeft':
920
- if (index > 0) {
1325
+ case 'ArrowRight': {
1326
+ // Direction-aware: in RTL the visual arrows flip. "Forward" steps toward the input
1327
+ // (the next chip, then the input); "backward" steps toward the first chip.
1328
+ const rtl = this.rootContext.dir() === 'rtl';
1329
+ const forward = (event.key === 'ArrowRight') !== rtl;
1330
+ if (forward) {
921
1331
  event.preventDefault();
922
- list[index - 1].focus();
923
- }
924
- break;
925
- case 'ArrowRight':
926
- event.preventDefault();
927
- if (index < list.length - 1) {
928
- list[index + 1].focus();
1332
+ if (index < list.length - 1) {
1333
+ list[index + 1].focus();
1334
+ }
1335
+ else {
1336
+ this.rootContext.focusInput();
1337
+ }
929
1338
  }
930
- else {
931
- this.rootContext.focusInput();
1339
+ else if (index > 0) {
1340
+ event.preventDefault();
1341
+ list[index - 1].focus();
932
1342
  }
933
1343
  break;
1344
+ }
934
1345
  case 'Home':
935
1346
  if (list.length) {
936
1347
  event.preventDefault();
@@ -965,7 +1376,7 @@ class RdxComboboxChip {
965
1376
  }
966
1377
  }
967
1378
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxChip, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
968
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxChip, isStandalone: true, selector: "[rdxComboboxChip]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "role": "listitem", "tabindex": "-1" }, listeners: { "keydown": "onKeydown($event)" } }, providers: [provideComboboxChipContext(chipContext)], exportAs: ["rdxComboboxChip"], ngImport: i0 }); }
1379
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxChip, isStandalone: true, selector: "[rdxComboboxChip]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null } }, host: { attributes: { "tabindex": "-1" }, listeners: { "keydown": "onKeydown($event)" } }, providers: [provideComboboxChipContext(chipContext)], exportAs: ["rdxComboboxChip"], ngImport: i0 }); }
969
1380
  }
970
1381
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxChip, decorators: [{
971
1382
  type: Directive,
@@ -974,7 +1385,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
974
1385
  exportAs: 'rdxComboboxChip',
975
1386
  providers: [provideComboboxChipContext(chipContext)],
976
1387
  host: {
977
- role: 'listitem',
1388
+ // No explicit role (Base UI): a focusable child of the `toolbar` chips container.
978
1389
  tabindex: '-1',
979
1390
  '(keydown)': 'onKeydown($event)'
980
1391
  }
@@ -1020,7 +1431,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1020
1431
  class RdxComboboxClear {
1021
1432
  constructor() {
1022
1433
  this.rootContext = injectComboboxRootContext();
1434
+ /** Disables just this clear button (in addition to the combobox's own disabled / read-only state). */
1435
+ this.disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1436
+ this.isDisabled = computed(() => this.disabled() || this.rootContext.disabledState() || this.rootContext.readonly(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : /* istanbul ignore next */ []));
1437
+ /**
1438
+ * Whether there is nothing to clear, mirroring Base UI's visibility rule: in `none` mode the
1439
+ * button tracks the input text (a pure search field has no selection), otherwise it tracks the
1440
+ * selected value(s).
1441
+ */
1023
1442
  this.isEmpty = computed(() => {
1443
+ if (this.rootContext.selectionMode() === 'none') {
1444
+ return (this.rootContext.inputValue() ?? '') === '';
1445
+ }
1024
1446
  const value = this.rootContext.value();
1025
1447
  if (Array.isArray(value)) {
1026
1448
  return value.length === 0;
@@ -1028,49 +1450,76 @@ class RdxComboboxClear {
1028
1450
  return value === null || value === undefined;
1029
1451
  }, ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
1030
1452
  }
1453
+ // Keep focus on the input — don't let the button take it on pointer/mouse down.
1454
+ onPointerDown(event) {
1455
+ event.preventDefault();
1456
+ }
1031
1457
  onClick() {
1032
1458
  this.rootContext.clearSelection();
1033
1459
  }
1034
1460
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxClear, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1035
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxClear, isStandalone: true, selector: "button[rdxComboboxClear]", host: { attributes: { "type": "button", "tabindex": "-1", "aria-label": "Clear" }, listeners: { "click": "onClick()" }, properties: { "hidden": "isEmpty()", "attr.disabled": "rootContext.disabledState() ? \"\" : undefined" } }, exportAs: ["rdxComboboxClear"], hostDirectives: [{ directive: i1$1.RdxDismissableLayerBranch }], ngImport: i0 }); }
1461
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxClear, isStandalone: true, selector: "button[rdxComboboxClear]", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "type": "button", "tabindex": "-1", "aria-label": "Clear" }, listeners: { "pointerdown": "onPointerDown($event)", "mousedown": "onPointerDown($event)", "click": "onClick()" }, properties: { "hidden": "isEmpty()", "attr.disabled": "isDisabled() ? \"\" : undefined" } }, exportAs: ["rdxComboboxClear"], hostDirectives: [{ directive: i1$1.RdxFloatingInsideElement }], ngImport: i0 }); }
1036
1462
  }
1037
1463
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxClear, decorators: [{
1038
1464
  type: Directive,
1039
1465
  args: [{
1040
1466
  selector: 'button[rdxComboboxClear]',
1041
1467
  exportAs: 'rdxComboboxClear',
1042
- hostDirectives: [RdxDismissableLayerBranch],
1468
+ hostDirectives: [RdxFloatingInsideElement],
1043
1469
  host: {
1044
1470
  type: 'button',
1045
1471
  tabindex: '-1',
1046
1472
  'aria-label': 'Clear',
1047
1473
  '[hidden]': 'isEmpty()',
1048
- '[attr.disabled]': 'rootContext.disabledState() ? "" : undefined',
1474
+ '[attr.disabled]': 'isDisabled() ? "" : undefined',
1475
+ '(pointerdown)': 'onPointerDown($event)',
1476
+ '(mousedown)': 'onPointerDown($event)',
1049
1477
  '(click)': 'onClick()'
1050
1478
  }
1051
1479
  }]
1052
- }] });
1480
+ }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }] } });
1053
1481
 
1054
1482
  /**
1055
- * Shown only when no items match the current query.
1483
+ * A polite, atomic live region announcing the "no results" message. Mirrors Base UI: the element
1484
+ * stays **mounted and visible at all times** so screen readers reliably announce the transition to
1485
+ * empty — only its *content* is rendered conditionally (projected when nothing matches, removed
1486
+ * otherwise). It must never be hidden/unmounted (`hidden`, `display:none`, `aria-hidden`, `@if`):
1487
+ * pulling the region out of the accessibility tree at the same instant its text appears is exactly
1488
+ * what suppresses the announcement.
1056
1489
  *
1057
1490
  * @group Components
1058
1491
  */
1059
1492
  class RdxComboboxEmpty {
1060
1493
  constructor() {
1061
1494
  this.rootContext = injectComboboxRootContext();
1495
+ /** Whether no items match the current query (drives projection of the message). */
1496
+ this.isEmpty = computed(() => this.rootContext.visibleCount() === 0, ...(ngDevMode ? [{ debugName: "isEmpty" }] : /* istanbul ignore next */ []));
1062
1497
  }
1063
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxEmpty, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1064
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxEmpty, isStandalone: true, selector: "[rdxComboboxEmpty]", host: { attributes: { "role": "presentation" }, properties: { "hidden": "rootContext.visibleCount() > 0" } }, exportAs: ["rdxComboboxEmpty"], ngImport: i0 }); }
1498
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxEmpty, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1499
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.9", type: RdxComboboxEmpty, isStandalone: true, selector: "[rdxComboboxEmpty]", host: { attributes: { "role": "status", "aria-live": "polite", "aria-atomic": "true" }, properties: { "attr.data-empty": "isEmpty() ? \"\" : undefined" } }, exportAs: ["rdxComboboxEmpty"], ngImport: i0, template: `
1500
+ @if (isEmpty()) {
1501
+ <ng-content />
1502
+ }
1503
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1065
1504
  }
1066
1505
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxEmpty, decorators: [{
1067
- type: Directive,
1506
+ type: Component,
1068
1507
  args: [{
1069
1508
  selector: '[rdxComboboxEmpty]',
1070
1509
  exportAs: 'rdxComboboxEmpty',
1510
+ changeDetection: ChangeDetectionStrategy.OnPush,
1511
+ template: `
1512
+ @if (isEmpty()) {
1513
+ <ng-content />
1514
+ }
1515
+ `,
1071
1516
  host: {
1072
- role: 'presentation',
1073
- '[hidden]': 'rootContext.visibleCount() > 0'
1517
+ role: 'status',
1518
+ 'aria-live': 'polite',
1519
+ 'aria-atomic': 'true',
1520
+ // Present only while the message is shown. Lets consumers collapse the always-mounted region
1521
+ // (e.g. `data-[empty]:py-6`) without `display:none`/`hidden`, which would break the announcement.
1522
+ '[attr.data-empty]': 'isEmpty() ? "" : undefined'
1074
1523
  }
1075
1524
  }]
1076
1525
  }] });
@@ -1131,6 +1580,12 @@ class RdxComboboxGroupLabel {
1131
1580
  this.groupContext = injectComboboxGroupContext();
1132
1581
  this.id = injectId('rdx-combobox-group-label-');
1133
1582
  this.groupContext.labelId.set(this.id);
1583
+ // Clear the registration on unmount so the group doesn't reference a removed label's id.
1584
+ inject(DestroyRef).onDestroy(() => {
1585
+ if (this.groupContext.labelId() === this.id) {
1586
+ this.groupContext.labelId.set(undefined);
1587
+ }
1588
+ });
1134
1589
  }
1135
1590
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxGroupLabel, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1136
1591
  static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxGroupLabel, isStandalone: true, selector: "[rdxComboboxGroupLabel]", host: { properties: { "attr.id": "id" } }, exportAs: ["rdxComboboxGroupLabel"], ngImport: i0 }); }
@@ -1166,7 +1621,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1166
1621
  }]
1167
1622
  }] });
1168
1623
 
1169
- const attr = (value) => (value ? '' : undefined);
1624
+ /**
1625
+ * Positions the combobox popup relative to the input anchor using the popper engine.
1626
+ *
1627
+ * A "thin" positioner (ADR 0012): it inherits the full popper positioning surface — the inputs
1628
+ * (`side`, `sideOffset`, `align`, …), the `placed` output, and the host bindings — from
1629
+ * {@link RdxPopperContentWrapper}, and only declares combobox's Base UI-aligned defaults through the
1630
+ * config provider. `provideRdxPopperContentWrapper` re-wires the `useExisting` alias + context that
1631
+ * the popup and arrow resolve (Angular does not inherit a base directive's `providers`).
1632
+ *
1633
+ * @group Components
1634
+ */
1635
+ class RdxComboboxPositioner extends RdxPopperContentWrapper {
1636
+ constructor() {
1637
+ super();
1638
+ const rootContext = injectComboboxRootContext();
1639
+ const injector = inject(Injector);
1640
+ const host = inject(ElementRef).nativeElement;
1641
+ // A modal combobox isolates the background with an internal backdrop (Base UI); the input stays
1642
+ // clickable through a cutout. (Combobox is non-modal by default — usually no backdrop.)
1643
+ afterNextRender(() => setupInternalBackdrop(host, injector, {
1644
+ isOpen: () => rootContext.open(),
1645
+ shouldRender: () => rootContext.modal(),
1646
+ cutout: () => rootContext.inputElement() ?? null
1647
+ }));
1648
+ }
1649
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPositioner, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1650
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxPositioner, isStandalone: true, selector: "[rdxComboboxPositioner]", providers: [
1651
+ ...provideRdxPopperContentWrapper(RdxComboboxPositioner),
1652
+ provideRdxPopperContentConfig({ sideOffset: 4, align: 'start' })
1653
+ ], exportAs: ["rdxComboboxPositioner"], usesInheritance: true, ngImport: i0 }); }
1654
+ }
1655
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPositioner, decorators: [{
1656
+ type: Directive,
1657
+ args: [{
1658
+ selector: '[rdxComboboxPositioner]',
1659
+ exportAs: 'rdxComboboxPositioner',
1660
+ providers: [
1661
+ ...provideRdxPopperContentWrapper(RdxComboboxPositioner),
1662
+ provideRdxPopperContentConfig({ sideOffset: 4, align: 'start' })
1663
+ ]
1664
+ }]
1665
+ }], ctorParameters: () => [] });
1666
+
1667
+ const attr$1 = (value) => (value ? '' : undefined);
1170
1668
  /**
1171
1669
  * The combobox text input. Holds DOM focus at all times; the highlighted option is referenced via
1172
1670
  * `aria-activedescendant`. Integrates with Field for labeling, description, and validation state.
@@ -1206,8 +1704,12 @@ class RdxComboboxInput {
1206
1704
  }, ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
1207
1705
  /** Whether an IME composition is in progress (CJK). While composing, don't filter or select. */
1208
1706
  this.composing = false;
1209
- this.dataAttr = attr;
1707
+ this.dataAttr = attr$1;
1210
1708
  this.rootContext.setInputElement(this.element);
1709
+ // Report the layout (Base UI's `inputInsidePopup`): a positioner ancestor means the input lives
1710
+ // inside the popup (e.g. a command palette), so the Trigger becomes the focusable
1711
+ // `role="combobox"`; otherwise the input is the tab stop and the Trigger is a `tabindex="-1"` toggle.
1712
+ this.rootContext.setInputLayout(inject(RdxComboboxPositioner, { optional: true }) ? 'inside' : 'outside');
1211
1713
  afterNextRender(() => {
1212
1714
  this.fieldRootContext?.setControlId(this.id());
1213
1715
  });
@@ -1222,22 +1724,31 @@ class RdxComboboxInput {
1222
1724
  if (this.composing || event.isComposing) {
1223
1725
  return;
1224
1726
  }
1225
- this.commitInput(event.target.value);
1727
+ this.commitInput(event.target.value, event);
1226
1728
  }
1227
1729
  onCompositionEnd(event) {
1228
1730
  this.composing = false;
1229
- this.commitInput(event.target.value);
1230
- }
1231
- commitInput(value) {
1232
- if (!this.rootContext.open()) {
1233
- this.rootContext.openPopup();
1731
+ this.commitInput(event.target.value, event);
1732
+ }
1733
+ commitInput(value, event) {
1734
+ // Base UI: clearing the field closes the popup only when the input is OUTSIDE it (and doesn't
1735
+ // open on click). When the input lives inside the popup, emptying the search must keep the popup
1736
+ // open (closing it would dismiss the field the user is typing in); otherwise typing (including
1737
+ // down to empty in browse mode) opens it.
1738
+ if (value === '' && !this.rootContext.openOnInputClick() && this.rootContext.inputLayout() !== 'inside') {
1739
+ this.rootContext.closePopup(false, 'input-clear', event);
1740
+ }
1741
+ else if (!this.rootContext.open() && value.trim() !== '') {
1742
+ // Base UI opens on input only for a non-empty trimmed value — whitespace alone won't open it.
1743
+ this.rootContext.openPopup('input-change', event);
1234
1744
  }
1235
- // setInputValue applies any autoHighlight (deferred until items mount).
1745
+ // setInputValue applies any autoHighlight (deferred until items mount) and, in single mode,
1746
+ // deselects when the field is emptied.
1236
1747
  this.rootContext.setInputValue(value);
1237
1748
  }
1238
- onClick() {
1749
+ onClick(event) {
1239
1750
  if (this.rootContext.openOnInputClick()) {
1240
- this.rootContext.openForBrowse();
1751
+ this.rootContext.openForBrowse('input-press', event);
1241
1752
  }
1242
1753
  }
1243
1754
  onFocus() {
@@ -1248,8 +1759,9 @@ class RdxComboboxInput {
1248
1759
  this.fieldRootContext?.setTouched(true);
1249
1760
  }
1250
1761
  onKeydown(event) {
1251
- // Don't interfere with IME composition or text-editing shortcuts / range selection. Home/End
1252
- // and Shift+Arrows must keep moving the caret, Ctrl/Meta combos stay browser shortcuts.
1762
+ // Don't interfere with IME composition or text-editing shortcuts / range selection. Shift+Arrows
1763
+ // and modified Home/End keep moving/extending the caret; Ctrl/Meta combos stay browser shortcuts.
1764
+ // (Plain Home/End navigate the grid below, but only in `grid` mode.)
1253
1765
  if (event.isComposing || this.composing) {
1254
1766
  return;
1255
1767
  }
@@ -1260,23 +1772,11 @@ class RdxComboboxInput {
1260
1772
  switch (event.key) {
1261
1773
  case 'ArrowDown':
1262
1774
  event.preventDefault();
1263
- this.rootContext.setKeyboardActive(true);
1264
- if (!open) {
1265
- this.rootContext.openAndHighlight('first');
1266
- }
1267
- else {
1268
- this.rootContext.highlightNext();
1269
- }
1775
+ this.rootContext.navigateByKeyboard(1, event);
1270
1776
  break;
1271
1777
  case 'ArrowUp':
1272
1778
  event.preventDefault();
1273
- this.rootContext.setKeyboardActive(true);
1274
- if (!open) {
1275
- this.rootContext.openAndHighlight('last');
1276
- }
1277
- else {
1278
- this.rootContext.highlightPrevious();
1279
- }
1779
+ this.rootContext.navigateByKeyboard(-1, event);
1280
1780
  break;
1281
1781
  case 'Enter':
1282
1782
  if (open) {
@@ -1286,33 +1786,69 @@ class RdxComboboxInput {
1286
1786
  if (hasHighlight) {
1287
1787
  // Select the highlighted item (and prevent an accidental form submit).
1288
1788
  event.preventDefault();
1289
- this.rootContext.selectHighlighted();
1789
+ this.rootContext.selectHighlighted(event);
1290
1790
  }
1291
1791
  else {
1292
1792
  // Nothing highlighted: just close, and let the form submit.
1293
- this.rootContext.closePopup(true);
1793
+ this.rootContext.closePopup(true, 'none', event);
1294
1794
  }
1295
1795
  }
1296
1796
  break;
1297
1797
  case 'Escape':
1298
- // Just close the popup (reverting the in-progress query); never clear the selection.
1299
1798
  if (open) {
1799
+ // Close the popup, reverting the in-progress query; keep the selection.
1300
1800
  event.preventDefault();
1301
- this.rootContext.closePopup(true);
1801
+ this.rootContext.closePopup(true, 'escape-key', event);
1802
+ }
1803
+ else if (!this.rootContext.popupMounted()) {
1804
+ // Base UI: Escape on a closed combobox clears the input text and the selection
1805
+ // (`clearSelection` resets both, a no-op while read-only / disabled). Guard on
1806
+ // `popupMounted`: the input's own Escape handler (the `open` branch above) already set
1807
+ // `open()` false when this same Escape just closed an open popup — in that case the
1808
+ // popup is still mounted (exiting) and we must not also clear.
1809
+ this.rootContext.clearSelection();
1302
1810
  }
1303
1811
  break;
1304
1812
  case 'Tab':
1305
- if (open) {
1306
- this.rootContext.closePopup(true);
1813
+ // Tab dismisses a real popup and lets focus move on. With no popup mounted (an always-open
1814
+ // inline layout) Tab must NOT close — it just moves focus on. Guard on `popupMounted`.
1815
+ if (open && this.rootContext.popupMounted()) {
1816
+ this.rootContext.closePopup(true, 'none', event);
1307
1817
  }
1308
1818
  break;
1819
+ case 'ArrowRight':
1820
+ // In a grid, the horizontal arrows move within a row.
1821
+ if (open && this.rootContext.grid()) {
1822
+ event.preventDefault();
1823
+ this.rootContext.setKeyboardActive(true);
1824
+ this.rootContext.highlightNextColumn();
1825
+ break;
1826
+ }
1827
+ this.maybeStepIntoChips('ArrowRight', event);
1828
+ break;
1309
1829
  case 'ArrowLeft':
1310
- // From the very start of the input in multiple mode, step into the chips.
1311
- if (this.rootContext.multiple() &&
1312
- this.element.selectionStart === 0 &&
1313
- this.element.selectionEnd === 0 &&
1314
- this.rootContext.focusLastChip()) {
1830
+ if (open && this.rootContext.grid()) {
1831
+ event.preventDefault();
1832
+ this.rootContext.setKeyboardActive(true);
1833
+ this.rootContext.highlightPreviousColumn();
1834
+ break;
1835
+ }
1836
+ this.maybeStepIntoChips('ArrowLeft', event);
1837
+ break;
1838
+ case 'Home':
1839
+ // In a grid the search box is a filter, so Home/End jump to the first/last cell rather
1840
+ // than moving the caret (outside a grid they keep their native text-editing behavior).
1841
+ if (open && this.rootContext.grid()) {
1842
+ event.preventDefault();
1843
+ this.rootContext.setKeyboardActive(true);
1844
+ this.rootContext.highlightFirst();
1845
+ }
1846
+ break;
1847
+ case 'End':
1848
+ if (open && this.rootContext.grid()) {
1315
1849
  event.preventDefault();
1850
+ this.rootContext.setKeyboardActive(true);
1851
+ this.rootContext.highlightLast();
1316
1852
  }
1317
1853
  break;
1318
1854
  case 'Backspace':
@@ -1322,20 +1858,35 @@ class RdxComboboxInput {
1322
1858
  break;
1323
1859
  }
1324
1860
  }
1861
+ /**
1862
+ * From the very start of the input in `multiple` mode, step into the chips. The key that points
1863
+ * toward the chips is direction-aware: `ArrowLeft` in LTR, `ArrowRight` in RTL.
1864
+ */
1865
+ maybeStepIntoChips(key, event) {
1866
+ const towardChips = this.rootContext.dir() === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1867
+ if (key === towardChips &&
1868
+ this.rootContext.multiple() &&
1869
+ this.element.selectionStart === 0 &&
1870
+ this.element.selectionEnd === 0 &&
1871
+ this.rootContext.focusLastChip()) {
1872
+ event.preventDefault();
1873
+ }
1874
+ }
1325
1875
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxInput, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1326
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxInput, isStandalone: true, selector: "input[rdxComboboxInput]", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "combobox", "autocomplete": "off", "aria-autocomplete": "list" }, listeners: { "input": "onInput($event)", "click": "onClick()", "focus": "onFocus()", "blur": "onBlur()", "keydown": "onKeydown($event)", "compositionstart": "composing = true", "compositionend": "onCompositionEnd($event)" }, properties: { "attr.id": "id()", "attr.aria-expanded": "rootContext.open()", "attr.aria-controls": "rootContext.listId", "attr.aria-labelledby": "rootContext.labelId()", "attr.aria-activedescendant": "rootContext.activeId()", "attr.aria-describedby": "describedBy()", "attr.aria-invalid": "invalidState() ? \"true\" : undefined", "attr.aria-required": "requiredState() ? \"true\" : undefined", "attr.aria-disabled": "disabledState() ? \"true\" : undefined", "attr.disabled": "disabledState() ? \"\" : undefined", "attr.readonly": "rootContext.readonly() ? \"\" : undefined", "attr.required": "requiredState() ? \"\" : undefined", "value": "rootContext.inputValue()", "attr.data-popup-open": "dataAttr(rootContext.open())", "attr.data-list-empty": "dataAttr(rootContext.visibleCount() === 0)", "attr.data-placeholder": "dataAttr(isEmptyValue())", "attr.data-invalid": "dataAttr(invalidState())", "attr.data-valid": "dataAttr(!invalidState())", "attr.data-disabled": "dataAttr(disabledState())", "attr.data-required": "dataAttr(requiredState())", "attr.data-filled": "dataAttr(filledState())", "attr.data-focused": "dataAttr(focusedState())" } }, exportAs: ["rdxComboboxInput"], hostDirectives: [{ directive: i1.RdxPopperAnchor }, { directive: i1$1.RdxDismissableLayerBranch }], ngImport: i0 }); }
1876
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxInput, isStandalone: true, selector: "input[rdxComboboxInput]", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "combobox", "autocomplete": "off", "aria-autocomplete": "list" }, listeners: { "input": "onInput($event)", "click": "onClick($event)", "focus": "onFocus()", "blur": "onBlur()", "keydown": "onKeydown($event)", "compositionstart": "composing = true", "compositionend": "onCompositionEnd($event)" }, properties: { "attr.id": "id()", "attr.aria-haspopup": "rootContext.grid() ? \"grid\" : \"listbox\"", "attr.aria-expanded": "rootContext.open()", "attr.aria-controls": "rootContext.listId", "attr.aria-labelledby": "rootContext.labelId()", "attr.aria-activedescendant": "rootContext.activeId()", "attr.aria-describedby": "describedBy()", "attr.aria-invalid": "invalidState() ? \"true\" : undefined", "attr.aria-required": "requiredState() ? \"true\" : undefined", "attr.aria-disabled": "disabledState() ? \"true\" : undefined", "attr.disabled": "disabledState() ? \"\" : undefined", "attr.readonly": "rootContext.readonly() ? \"\" : undefined", "attr.required": "requiredState() ? \"\" : undefined", "value": "rootContext.inputValue()", "attr.data-popup-open": "dataAttr(rootContext.open())", "attr.data-list-empty": "dataAttr(rootContext.visibleCount() === 0)", "attr.data-placeholder": "dataAttr(isEmptyValue())", "attr.data-invalid": "dataAttr(invalidState())", "attr.data-valid": "dataAttr(!invalidState())", "attr.data-disabled": "dataAttr(disabledState())", "attr.data-required": "dataAttr(requiredState())", "attr.data-filled": "dataAttr(filledState())", "attr.data-focused": "dataAttr(focusedState())" } }, exportAs: ["rdxComboboxInput"], hostDirectives: [{ directive: i1.RdxPopperAnchor }, { directive: i1$1.RdxFloatingInsideElement }], ngImport: i0 }); }
1327
1877
  }
1328
1878
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxInput, decorators: [{
1329
1879
  type: Directive,
1330
1880
  args: [{
1331
1881
  selector: 'input[rdxComboboxInput]',
1332
1882
  exportAs: 'rdxComboboxInput',
1333
- hostDirectives: [RdxPopperAnchor, RdxDismissableLayerBranch],
1883
+ hostDirectives: [RdxPopperAnchor, RdxFloatingInsideElement],
1334
1884
  host: {
1335
1885
  role: 'combobox',
1336
1886
  autocomplete: 'off',
1337
1887
  'aria-autocomplete': 'list',
1338
1888
  '[attr.id]': 'id()',
1889
+ '[attr.aria-haspopup]': 'rootContext.grid() ? "grid" : "listbox"',
1339
1890
  '[attr.aria-expanded]': 'rootContext.open()',
1340
1891
  '[attr.aria-controls]': 'rootContext.listId',
1341
1892
  '[attr.aria-labelledby]': 'rootContext.labelId()',
@@ -1358,7 +1909,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1358
1909
  '[attr.data-filled]': 'dataAttr(filledState())',
1359
1910
  '[attr.data-focused]': 'dataAttr(focusedState())',
1360
1911
  '(input)': 'onInput($event)',
1361
- '(click)': 'onClick()',
1912
+ '(click)': 'onClick($event)',
1362
1913
  '(focus)': 'onFocus()',
1363
1914
  '(blur)': 'onBlur()',
1364
1915
  '(keydown)': 'onKeydown($event)',
@@ -1368,12 +1919,72 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1368
1919
  }]
1369
1920
  }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }] } });
1370
1921
 
1922
+ const attr = (value) => (value ? '' : undefined);
1923
+ /**
1924
+ * Optional wrapper around the input and its adornments (icon, clear, trigger). Mirrors the combobox
1925
+ * state via `data-*` so the whole group can be styled together (focus ring, disabled, etc.).
1926
+ *
1927
+ * @group Components
1928
+ */
1929
+ class RdxComboboxInputGroup {
1930
+ constructor() {
1931
+ this.rootContext = injectComboboxRootContext();
1932
+ /** Whether a value is selected (a non-empty array in multiple mode, or a non-nullish single value). */
1933
+ this.filled = computed(() => {
1934
+ const value = this.rootContext.value();
1935
+ if (Array.isArray(value)) {
1936
+ return value.length > 0;
1937
+ }
1938
+ return value !== null && value !== undefined;
1939
+ }, ...(ngDevMode ? [{ debugName: "filled" }] : /* istanbul ignore next */ []));
1940
+ this.dataAttr = attr;
1941
+ }
1942
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxInputGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1943
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxInputGroup, isStandalone: true, selector: "[rdxComboboxInputGroup]", host: { properties: { "attr.data-popup-open": "dataAttr(rootContext.open())", "attr.data-disabled": "dataAttr(rootContext.disabledState())", "attr.data-required": "dataAttr(rootContext.requiredState())", "attr.data-filled": "dataAttr(filled())" } }, exportAs: ["rdxComboboxInputGroup"], ngImport: i0 }); }
1944
+ }
1945
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxInputGroup, decorators: [{
1946
+ type: Directive,
1947
+ args: [{
1948
+ selector: '[rdxComboboxInputGroup]',
1949
+ exportAs: 'rdxComboboxInputGroup',
1950
+ host: {
1951
+ '[attr.data-popup-open]': 'dataAttr(rootContext.open())',
1952
+ '[attr.data-disabled]': 'dataAttr(rootContext.disabledState())',
1953
+ '[attr.data-required]': 'dataAttr(rootContext.requiredState())',
1954
+ '[attr.data-filled]': 'dataAttr(filled())'
1955
+ }
1956
+ }]
1957
+ }] });
1958
+
1959
+ /**
1960
+ * A row in a grid-layout combobox list. Groups the items in one row so the root can navigate by row
1961
+ * (`ArrowUp` / `ArrowDown`) and within a row (`ArrowLeft` / `ArrowRight`). Only meaningful when the
1962
+ * root has `grid` enabled; the root resolves an item's row from its nearest `[rdxComboboxRow]` ancestor.
1963
+ *
1964
+ * @group Components
1965
+ */
1966
+ class RdxComboboxRow {
1967
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxRow, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1968
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxRow, isStandalone: true, selector: "[rdxComboboxRow]", host: { attributes: { "role": "row" } }, exportAs: ["rdxComboboxRow"], ngImport: i0 }); }
1969
+ }
1970
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxRow, decorators: [{
1971
+ type: Directive,
1972
+ args: [{
1973
+ selector: '[rdxComboboxRow]',
1974
+ exportAs: 'rdxComboboxRow',
1975
+ host: {
1976
+ role: 'row'
1977
+ }
1978
+ }]
1979
+ }] });
1980
+
1371
1981
  const itemContext = () => {
1372
1982
  const item = inject(RdxComboboxItem);
1373
1983
  return {
1374
1984
  isSelected: item.isSelected,
1375
1985
  isHighlighted: item.isHighlighted,
1376
1986
  disabled: item.disabled,
1987
+ // Read-only `Signal` (not `InputSignal`) so autocomplete's computed `value` is assignable too.
1377
1988
  value: item.value
1378
1989
  };
1379
1990
  };
@@ -1407,6 +2018,16 @@ class RdxComboboxItem {
1407
2018
  this.elementId = computed(() => this.virtualized() ? this.rootContext.itemId(this.index() ?? -1) : this.id, ...(ngDevMode ? [{ debugName: "elementId" }] : /* istanbul ignore next */ []));
1408
2019
  this.ariaSetSize = computed(() => this.virtualized() ? this.rootContext.filteredItems().length : undefined, ...(ngDevMode ? [{ debugName: "ariaSetSize" }] : /* istanbul ignore next */ []));
1409
2020
  this.ariaPosInSet = computed(() => (this.virtualized() ? (this.index() ?? -1) + 1 : undefined), ...(ngDevMode ? [{ debugName: "ariaPosInSet" }] : /* istanbul ignore next */ []));
2021
+ /** The nearest enclosing grid row, if any (drives the `gridcell` role). */
2022
+ this.row = inject(RdxComboboxRow, { optional: true });
2023
+ /** `gridcell` only when actually inside a `RdxComboboxRow` of a grid list; otherwise `option`. */
2024
+ this.role = computed(() => (this.rootContext.grid() && this.row ? 'gridcell' : 'option'), ...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
2025
+ /**
2026
+ * Whether selection is a meaningful concept here (Base UI's `selectable`). In `selectionMode="none"`
2027
+ * (every autocomplete option, and a pure-search combobox) options carry no selection state, so
2028
+ * `aria-selected` / `data-selected` are omitted entirely rather than rendered as `false`.
2029
+ */
2030
+ this.selectable = computed(() => this.rootContext.selectionMode() !== 'none', ...(ngDevMode ? [{ debugName: "selectable" }] : /* istanbul ignore next */ []));
1410
2031
  // Virtualized items are always rendered (the consumer only mounts the filtered window).
1411
2032
  this.isVisible = computed(() => (this.virtualized() ? true : this.rootContext.isVisible(this)), ...(ngDevMode ? [{ debugName: "isVisible" }] : /* istanbul ignore next */ []));
1412
2033
  this.isSelected = computed(() => this.rootContext.isSelected(this.value()), ...(ngDevMode ? [{ debugName: "isSelected" }] : /* istanbul ignore next */ []));
@@ -1414,6 +2035,9 @@ class RdxComboboxItem {
1414
2035
  ? this.rootContext.highlightedIndex() === this.index()
1415
2036
  : this.rootContext.highlightedItem() === this, ...(ngDevMode ? [{ debugName: "isHighlighted" }] : /* istanbul ignore next */ []));
1416
2037
  this.group = injectComboboxGroupContext(true);
2038
+ // Whether a primary-button pointerdown started on **this** item. A normal press+release here is
2039
+ // committed by `click`; `mouseup` is only the drag-end fallback for a press that began *elsewhere*.
2040
+ this.pointerDownStarted = false;
1417
2041
  const destroyRef = inject(DestroyRef);
1418
2042
  afterNextRender(() => {
1419
2043
  // Virtualized items are not registered: the root navigates over `items` data by index, and
@@ -1445,18 +2069,51 @@ class RdxComboboxItem {
1445
2069
  this.element.scrollIntoView({ block: 'nearest' });
1446
2070
  }
1447
2071
  });
2072
+ // Reset the press flag whenever the popup closes (matches Base UI), so a later drag-end onto
2073
+ // this item isn't blocked by a stale press from an earlier interaction.
2074
+ effect(() => {
2075
+ if (!this.rootContext.open()) {
2076
+ this.pointerDownStarted = false;
2077
+ }
2078
+ });
1448
2079
  }
1449
2080
  onPointerDown(event) {
1450
- // Keep focus on the input; prevent the item from stealing focus on pointer/mouse down.
2081
+ if (event.button !== 0) {
2082
+ return;
2083
+ }
2084
+ // Keep focus on the input; prevent the item from stealing focus.
1451
2085
  event.preventDefault();
1452
2086
  this.rootContext.setKeyboardActive(false);
2087
+ this.pointerDownStarted = true;
1453
2088
  }
1454
- onPointerUp() {
2089
+ onMouseDown(event) {
2090
+ // Belt-and-suspenders for keeping focus on the input (and iOS Safari blur on tap).
2091
+ if (event.button === 0) {
2092
+ event.preventDefault();
2093
+ }
2094
+ }
2095
+ onMouseUp(event) {
2096
+ // Read-and-reset the press flag first (matches Base UI), so a press+release here doesn't leave
2097
+ // it set and block a later drag-end onto this same item. Drag-end: commit when the primary
2098
+ // button is released over the highlighted item while the press began on a *different* element
2099
+ // (so `click` won't fire here). A press that began on this item is committed by `click` instead.
2100
+ const startedHere = this.pointerDownStarted;
2101
+ this.pointerDownStarted = false;
2102
+ if (event.button !== 0 || startedHere || !this.isHighlighted()) {
2103
+ return;
2104
+ }
2105
+ this.commitSelection(event);
2106
+ }
2107
+ onClick(event) {
2108
+ // Primary selection trigger; also fires for programmatic `.click()`.
2109
+ this.commitSelection(event);
2110
+ }
2111
+ commitSelection(event) {
1455
2112
  if (this.virtualized()) {
1456
- this.rootContext.selectIndex(this.index() ?? -1);
2113
+ this.rootContext.selectIndex(this.index() ?? -1, event);
1457
2114
  }
1458
2115
  else {
1459
- this.rootContext.select(this);
2116
+ this.rootContext.select(this, event);
1460
2117
  }
1461
2118
  }
1462
2119
  onPointerMove() {
@@ -1497,7 +2154,7 @@ class RdxComboboxItem {
1497
2154
  this.rootContext.clearHighlight();
1498
2155
  }
1499
2156
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxItem, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1500
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxItem, isStandalone: true, selector: "[rdxComboboxItem]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, textValueInput: { classPropertyName: "textValueInput", publicName: "textValue", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "option" }, listeners: { "pointerdown": "onPointerDown($event)", "mousedown": "onPointerDown($event)", "pointerup": "onPointerUp()", "pointermove": "onPointerMove()", "pointerleave": "onPointerLeave($event)" }, properties: { "attr.id": "elementId()", "attr.aria-selected": "isSelected()", "attr.aria-disabled": "disabled() ? \"true\" : undefined", "attr.aria-setsize": "ariaSetSize()", "attr.aria-posinset": "ariaPosInSet()", "attr.data-selected": "isSelected() ? \"\" : undefined", "attr.data-highlighted": "isHighlighted() ? \"\" : undefined", "attr.data-disabled": "disabled() ? \"\" : undefined", "hidden": "!isVisible()", "attr.data-hidden": "isVisible() ? undefined : \"\"" } }, providers: [provideComboboxItemContext(itemContext)], exportAs: ["rdxComboboxItem"], ngImport: i0 }); }
2157
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxItem, isStandalone: true, selector: "[rdxComboboxItem]", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: true, transformFunction: null }, textValueInput: { classPropertyName: "textValueInput", publicName: "textValue", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "onPointerDown($event)", "mousedown": "onMouseDown($event)", "mouseup": "onMouseUp($event)", "click": "onClick($event)", "pointermove": "onPointerMove()", "pointerleave": "onPointerLeave($event)" }, properties: { "attr.role": "role()", "attr.id": "elementId()", "attr.aria-selected": "selectable() ? isSelected() : undefined", "attr.aria-disabled": "disabled() ? \"true\" : undefined", "attr.aria-setsize": "ariaSetSize()", "attr.aria-posinset": "ariaPosInSet()", "attr.data-selected": "selectable() && isSelected() ? \"\" : undefined", "attr.data-highlighted": "isHighlighted() ? \"\" : undefined", "attr.data-disabled": "disabled() ? \"\" : undefined", "hidden": "!isVisible()", "attr.data-hidden": "isVisible() ? undefined : \"\"" } }, providers: [provideComboboxItemContext(itemContext)], exportAs: ["rdxComboboxItem"], ngImport: i0 }); }
1501
2158
  }
1502
2159
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxItem, decorators: [{
1503
2160
  type: Directive,
@@ -1506,20 +2163,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1506
2163
  exportAs: 'rdxComboboxItem',
1507
2164
  providers: [provideComboboxItemContext(itemContext)],
1508
2165
  host: {
1509
- role: 'option',
2166
+ '[attr.role]': 'role()',
1510
2167
  '[attr.id]': 'elementId()',
1511
- '[attr.aria-selected]': 'isSelected()',
2168
+ '[attr.aria-selected]': 'selectable() ? isSelected() : undefined',
1512
2169
  '[attr.aria-disabled]': 'disabled() ? "true" : undefined',
1513
2170
  '[attr.aria-setsize]': 'ariaSetSize()',
1514
2171
  '[attr.aria-posinset]': 'ariaPosInSet()',
1515
- '[attr.data-selected]': 'isSelected() ? "" : undefined',
2172
+ '[attr.data-selected]': 'selectable() && isSelected() ? "" : undefined',
1516
2173
  '[attr.data-highlighted]': 'isHighlighted() ? "" : undefined',
1517
2174
  '[attr.data-disabled]': 'disabled() ? "" : undefined',
1518
2175
  '[hidden]': '!isVisible()',
1519
2176
  '[attr.data-hidden]': 'isVisible() ? undefined : ""',
1520
2177
  '(pointerdown)': 'onPointerDown($event)',
1521
- '(mousedown)': 'onPointerDown($event)',
1522
- '(pointerup)': 'onPointerUp()',
2178
+ '(mousedown)': 'onMouseDown($event)',
2179
+ '(mouseup)': 'onMouseUp($event)',
2180
+ '(click)': 'onClick($event)',
1523
2181
  '(pointermove)': 'onPointerMove()',
1524
2182
  '(pointerleave)': 'onPointerLeave($event)'
1525
2183
  }
@@ -1578,7 +2236,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1578
2236
  }], ctorParameters: () => [] });
1579
2237
 
1580
2238
  /**
1581
- * The listbox container for options. Carries the id referenced by the input's `aria-controls`.
2239
+ * The listbox container for options. Carries the id referenced by the input's `aria-controls`, exposes
2240
+ * `data-empty` while no options match the current query (Base UI's `ComboboxList` empty state), and
2241
+ * switches its `role` to `grid` when the root has `grid` enabled.
1582
2242
  *
1583
2243
  * @group Components
1584
2244
  */
@@ -1586,8 +2246,27 @@ class RdxComboboxList {
1586
2246
  constructor() {
1587
2247
  this.rootContext = injectComboboxRootContext();
1588
2248
  }
2249
+ onKeydown(event) {
2250
+ if (event.key !== 'Enter') {
2251
+ return;
2252
+ }
2253
+ // Base UI bails early when disabled / read-only — don't swallow Enter (e.g. a form submit).
2254
+ if (this.rootContext.disabledState() || this.rootContext.readonly()) {
2255
+ return;
2256
+ }
2257
+ const hasHighlight = this.rootContext.virtualized()
2258
+ ? this.rootContext.highlightedIndex() >= 0
2259
+ : this.rootContext.highlightedItem() !== null;
2260
+ if (hasHighlight) {
2261
+ // Base UI `stopEvent`: also stop propagation so a parent keydown handler doesn't re-handle
2262
+ // Enter after the selection.
2263
+ event.preventDefault();
2264
+ event.stopPropagation();
2265
+ this.rootContext.selectHighlighted();
2266
+ }
2267
+ }
1589
2268
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxList, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1590
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxList, isStandalone: true, selector: "[rdxComboboxList]", host: { attributes: { "role": "listbox" }, properties: { "attr.id": "rootContext.listId", "attr.aria-multiselectable": "rootContext.multiple() ? \"true\" : undefined" } }, exportAs: ["rdxComboboxList"], ngImport: i0 }); }
2269
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxList, isStandalone: true, selector: "[rdxComboboxList]", host: { attributes: { "tabindex": "-1" }, listeners: { "keydown": "onKeydown($event)" }, properties: { "attr.role": "rootContext.grid() ? \"grid\" : \"listbox\"", "attr.id": "rootContext.listId", "attr.aria-multiselectable": "rootContext.multiple() ? \"true\" : undefined", "attr.data-empty": "rootContext.visibleCount() === 0 ? \"\" : undefined" } }, exportAs: ["rdxComboboxList"], ngImport: i0 }); }
1591
2270
  }
1592
2271
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxList, decorators: [{
1593
2272
  type: Directive,
@@ -1595,9 +2274,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1595
2274
  selector: '[rdxComboboxList]',
1596
2275
  exportAs: 'rdxComboboxList',
1597
2276
  host: {
1598
- role: 'listbox',
2277
+ // Base UI: the list is a programmatic focus target (`tabindex="-1"`) and selects the highlighted
2278
+ // item on Enter, for custom layouts that move focus onto the list rather than the input.
2279
+ tabindex: '-1',
2280
+ '[attr.role]': 'rootContext.grid() ? "grid" : "listbox"',
1599
2281
  '[attr.id]': 'rootContext.listId',
1600
- '[attr.aria-multiselectable]': 'rootContext.multiple() ? "true" : undefined'
2282
+ '[attr.aria-multiselectable]': 'rootContext.multiple() ? "true" : undefined',
2283
+ '[attr.data-empty]': 'rootContext.visibleCount() === 0 ? "" : undefined',
2284
+ '(keydown)': 'onKeydown($event)'
1601
2285
  }
1602
2286
  }]
1603
2287
  }] });
@@ -1611,19 +2295,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1611
2295
  class RdxComboboxPopup {
1612
2296
  constructor() {
1613
2297
  this.rootContext = injectComboboxRootContext();
1614
- this.dismissableLayer = inject(RdxDismissableLayer);
2298
+ this.floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT);
2299
+ this.registration = inject(RDX_FLOATING_REGISTRATION, { optional: true });
1615
2300
  this.popper = injectPopperContentWrapperContext();
1616
2301
  this.element = inject(ElementRef).nativeElement;
1617
- // The popup mounts only while open, so locking on `modal` locks scroll for as long as a modal
1618
- // popup is open and releases it on close.
1619
- useScrollLock(this.rootContext.modal);
2302
+ // Activation policy (ADR 0016 §2 + §3): lock page scroll while a modal popup is OPEN. The gate
2303
+ // keys on `open` (not mounted), so the lock releases at close-start — before the exit animation
2304
+ // finishes — even though the popup stays mounted through it. For a **touch** open the anchored
2305
+ // helper only locks when the popup is effectively viewport-width, so a small dropdown stays
2306
+ // swipe-to-dismissable on mobile (§3).
2307
+ useAnchoredScrollLock(computed(() => this.rootContext.open() && this.rootContext.modal()), {
2308
+ touchOpen: () => this.rootContext.openedByTouch(),
2309
+ element: () => this.element
2310
+ });
1620
2311
  // The popup's animation determines when the open/close transition (onOpenChangeComplete) is done.
1621
2312
  const unregister = this.rootContext.registerTransitionElement(this.element);
1622
- inject(DestroyRef).onDestroy(unregister);
1623
- // The input keeps focus while the popup is open; it is registered as a layer branch, so
1624
- // focus/pointer interactions on it don't count as "outside" and won't self-dismiss. Escape
1625
- // is handled by the input (which calls preventDefault), so the layer won't dismiss for it.
1626
- this.dismissableLayer.dismiss.subscribe(() => this.rootContext.closePopup(true));
2313
+ // Track mounted state so Escape can tell "closing this open popup" from "already closed".
2314
+ this.rootContext.setPopupMounted(true);
2315
+ inject(DestroyRef).onDestroy(() => {
2316
+ unregister();
2317
+ this.rootContext.setPopupMounted(false);
2318
+ });
2319
+ // The popup is this layer's floating element (the inside surface for containment checks).
2320
+ this.floatingContext.setFloatingElement(this.element);
2321
+ // Dismissal (ADR 0015): an outside press, or focus leaving everything, closes the combobox. The
2322
+ // input / trigger / chips / clear are registered as "inside" (RdxFloatingInsideElement), so the
2323
+ // input keeping focus — or a press on those parts — never self-dismisses. Escape is owned by the
2324
+ // input (it preventDefaults + closes), so the capability does not handle it (`escapeKey: false`).
2325
+ new RdxDismiss(this.floatingContext, () => this.registration?.node() ?? null, {
2326
+ escapeKey: () => false,
2327
+ outsidePress: () => true,
2328
+ focusOutside: () => true,
2329
+ onDismiss: (reason, event) => this.rootContext.closePopup(true, reason === 'focus-outside' ? 'focus-out' : 'outside-press', event)
2330
+ });
1627
2331
  // For the "input inside the popup" pattern, move focus to the input once the popup is
1628
2332
  // positioned. Use `afterRenderEffect` (not `effect`): when `isPositioned` flips true the
1629
2333
  // popup's final position/visibility is applied in the *following* render, so a synchronous
@@ -1634,33 +2338,57 @@ class RdxComboboxPopup {
1634
2338
  }
1635
2339
  const input = this.rootContext.inputElement();
1636
2340
  if (input && input.closest('[rdxComboboxPopup]')) {
1637
- input.focus();
1638
- input.select();
2341
+ // Base UI: a touch-open focuses the popup itself so Android keeps the virtual keyboard
2342
+ // closed; mouse/keyboard opens focus (and select) the search input as usual.
2343
+ if (this.rootContext.openedByTouch()) {
2344
+ this.element.focus();
2345
+ }
2346
+ else {
2347
+ input.focus();
2348
+ input.select();
2349
+ }
1639
2350
  }
1640
2351
  });
1641
2352
  }
2353
+ /**
2354
+ * Base UI focus handoff: if focus lands on the popup or the list (the `tabindex="-1"` programmatic
2355
+ * focus targets), hand it back to the input so arrow-key navigation (`aria-activedescendant`) keeps
2356
+ * working. Skipped for a touch interaction, where focus is intentionally parked on the popup to keep
2357
+ * the Android virtual keyboard closed.
2358
+ */
2359
+ onFocusIn(event) {
2360
+ if (this.rootContext.openedByTouch()) {
2361
+ return;
2362
+ }
2363
+ const input = this.rootContext.inputElement();
2364
+ const target = event.target;
2365
+ if (!input || !target || target === input) {
2366
+ return;
2367
+ }
2368
+ if (target === this.element || target.matches('[rdxComboboxList]')) {
2369
+ input.focus();
2370
+ }
2371
+ }
1642
2372
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPopup, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1643
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxPopup, isStandalone: true, selector: "[rdxComboboxPopup]", host: { properties: { "attr.data-state": "rootContext.open() ? \"open\" : \"closed\"", "attr.data-open": "rootContext.open() ? \"\" : undefined", "attr.data-closed": "rootContext.open() ? undefined : \"\"", "attr.data-starting-style": "rootContext.transitionStatus() === \"starting\" ? \"\" : undefined", "attr.data-ending-style": "rootContext.transitionStatus() === \"ending\" ? \"\" : undefined" } }, providers: [
1644
- // In modal mode, make content outside the popup inert (Base UI's `modal`).
1645
- provideRdxDismissableLayerConfig(() => ({ disableOutsidePointerEvents: injectComboboxRootContext().modal }))
1646
- ], exportAs: ["rdxComboboxPopup"], hostDirectives: [{ directive: i1.RdxPopperContent }, { directive: i1$1.RdxDismissableLayer }], ngImport: i0 }); }
2373
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxPopup, isStandalone: true, selector: "[rdxComboboxPopup]", host: { attributes: { "tabindex": "-1" }, listeners: { "focusin": "onFocusIn($event)" }, properties: { "attr.role": "rootContext.inputLayout() === \"inside\" ? \"dialog\" : \"presentation\"", "attr.data-state": "rootContext.open() ? \"open\" : \"closed\"", "attr.data-open": "rootContext.open() ? \"\" : undefined", "attr.data-closed": "rootContext.open() ? undefined : \"\"", "attr.data-starting-style": "rootContext.transitionStatus() === \"starting\" ? \"\" : undefined", "attr.data-ending-style": "rootContext.transitionStatus() === \"ending\" ? \"\" : undefined" } }, exportAs: ["rdxComboboxPopup"], hostDirectives: [{ directive: i1.RdxPopperContent }, { directive: i2.RdxFloatingNodeRegistration }], ngImport: i0 }); }
1647
2374
  }
1648
2375
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPopup, decorators: [{
1649
2376
  type: Directive,
1650
2377
  args: [{
1651
2378
  selector: '[rdxComboboxPopup]',
1652
2379
  exportAs: 'rdxComboboxPopup',
1653
- hostDirectives: [RdxPopperContent, RdxDismissableLayer],
1654
- providers: [
1655
- // In modal mode, make content outside the popup inert (Base UI's `modal`).
1656
- provideRdxDismissableLayerConfig(() => ({ disableOutsidePointerEvents: injectComboboxRootContext().modal }))
1657
- ],
2380
+ hostDirectives: [RdxPopperContent, RdxFloatingNodeRegistration],
1658
2381
  host: {
2382
+ // Base UI: a `dialog` (focusable, tabindex -1) when the input lives inside the popup, otherwise
2383
+ // a presentational wrapper around the `listbox` (the List part owns the listbox role).
2384
+ tabindex: '-1',
2385
+ '[attr.role]': 'rootContext.inputLayout() === "inside" ? "dialog" : "presentation"',
1659
2386
  '[attr.data-state]': 'rootContext.open() ? "open" : "closed"',
1660
2387
  '[attr.data-open]': 'rootContext.open() ? "" : undefined',
1661
2388
  '[attr.data-closed]': 'rootContext.open() ? undefined : ""',
1662
2389
  '[attr.data-starting-style]': 'rootContext.transitionStatus() === "starting" ? "" : undefined',
1663
- '[attr.data-ending-style]': 'rootContext.transitionStatus() === "ending" ? "" : undefined'
2390
+ '[attr.data-ending-style]': 'rootContext.transitionStatus() === "ending" ? "" : undefined',
2391
+ '(focusin)': 'onFocusIn($event)'
1664
2392
  }
1665
2393
  }]
1666
2394
  }], ctorParameters: () => [] });
@@ -1685,7 +2413,7 @@ class RdxComboboxPortal {
1685
2413
  this.container = input(...(ngDevMode ? [undefined, { debugName: "container" }] : /* istanbul ignore next */ []));
1686
2414
  }
1687
2415
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPortal, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1688
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxPortal, isStandalone: true, selector: "ng-template[rdxComboboxPortal]", inputs: { container: { classPropertyName: "container", publicName: "container", isSignal: true, isRequired: false, transformFunction: null } }, providers: [provideRdxPresenceContext(() => ({ present: injectComboboxRootContext().open }))], exportAs: ["rdxComboboxPortal"], hostDirectives: [{ directive: i1$2.RdxPortalPresence, inputs: ["container", "container"] }], ngImport: i0 }); }
2416
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxPortal, isStandalone: true, selector: "ng-template[rdxComboboxPortal]", inputs: { container: { classPropertyName: "container", publicName: "container", isSignal: true, isRequired: false, transformFunction: null } }, providers: [provideRdxPresenceContext(() => ({ present: injectComboboxRootContext().present }))], exportAs: ["rdxComboboxPortal"], hostDirectives: [{ directive: i1$2.RdxPortalPresence, inputs: ["container", "container"] }], ngImport: i0 }); }
1689
2417
  }
1690
2418
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPortal, decorators: [{
1691
2419
  type: Directive,
@@ -1693,7 +2421,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1693
2421
  selector: 'ng-template[rdxComboboxPortal]',
1694
2422
  exportAs: 'rdxComboboxPortal',
1695
2423
  hostDirectives: [{ directive: RdxPortalPresence, inputs: ['container'] }],
1696
- providers: [provideRdxPresenceContext(() => ({ present: injectComboboxRootContext().open }))]
2424
+ providers: [provideRdxPresenceContext(() => ({ present: injectComboboxRootContext().present }))]
1697
2425
  }]
1698
2426
  }], propDecorators: { container: [{ type: i0.Input, args: [{ isSignal: true, alias: "container", required: false }] }] } });
1699
2427
  /**
@@ -1706,9 +2434,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1706
2434
  class RdxComboboxPortalMisuseGuard {
1707
2435
  constructor() {
1708
2436
  if (isDevMode()) {
1709
- throw new Error('[rdxComboboxPortal] is now a structural directive. ' +
2437
+ rdxDevError('combobox/portal-on-element', '`rdxComboboxPortal` is now a structural directive. ' +
1710
2438
  'Use `*rdxComboboxPortal` on the positioner element or `<ng-template rdxComboboxPortal>`. ' +
1711
- 'rdxComboboxPortalPresence has been removed. See https://radix-ng.com/components/combobox.md');
2439
+ 'rdxComboboxPortalPresence has been removed.', 'components/combobox');
1712
2440
  }
1713
2441
  }
1714
2442
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPortalMisuseGuard, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -1722,63 +2450,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1722
2450
  }], ctorParameters: () => [] });
1723
2451
 
1724
2452
  /**
1725
- * Positions the popup relative to the input anchor using the popper engine. Re-exposes the popper
1726
- * positioning inputs.
2453
+ * A visual separator between groups of options. Carries `role="separator"` with a horizontal
2454
+ * orientation (it divides rows in a vertical list), so assistive tech announces the grouping break.
1727
2455
  *
1728
2456
  * @group Components
1729
2457
  */
1730
- class RdxComboboxPositioner {
1731
- constructor() {
1732
- this.side = input('bottom', ...(ngDevMode ? [{ debugName: "side" }] : /* istanbul ignore next */ []));
1733
- this.sideOffset = input(4, { ...(ngDevMode ? { debugName: "sideOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
1734
- this.align = input('start', ...(ngDevMode ? [{ debugName: "align" }] : /* istanbul ignore next */ []));
1735
- this.alignOffset = input(0, { ...(ngDevMode ? { debugName: "alignOffset" } : /* istanbul ignore next */ {}), transform: numberAttribute });
1736
- this.arrowPadding = input(0, { ...(ngDevMode ? { debugName: "arrowPadding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
1737
- this.avoidCollisions = input(true, { ...(ngDevMode ? { debugName: "avoidCollisions" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1738
- this.collisionBoundary = input(...(ngDevMode ? [undefined, { debugName: "collisionBoundary" }] : /* istanbul ignore next */ []));
1739
- this.collisionPadding = input(0, ...(ngDevMode ? [{ debugName: "collisionPadding" }] : /* istanbul ignore next */ []));
1740
- this.sticky = input('partial', ...(ngDevMode ? [{ debugName: "sticky" }] : /* istanbul ignore next */ []));
1741
- this.hideWhenDetached = input(false, { ...(ngDevMode ? { debugName: "hideWhenDetached" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1742
- this.updatePositionStrategy = input('optimized', ...(ngDevMode ? [{ debugName: "updatePositionStrategy" }] : /* istanbul ignore next */ []));
1743
- }
1744
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPositioner, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1745
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxComboboxPositioner, isStandalone: true, selector: "[rdxComboboxPositioner]", inputs: { side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, sideOffset: { classPropertyName: "sideOffset", publicName: "sideOffset", isSignal: true, isRequired: false, transformFunction: null }, align: { classPropertyName: "align", publicName: "align", isSignal: true, isRequired: false, transformFunction: null }, alignOffset: { classPropertyName: "alignOffset", publicName: "alignOffset", isSignal: true, isRequired: false, transformFunction: null }, arrowPadding: { classPropertyName: "arrowPadding", publicName: "arrowPadding", isSignal: true, isRequired: false, transformFunction: null }, avoidCollisions: { classPropertyName: "avoidCollisions", publicName: "avoidCollisions", isSignal: true, isRequired: false, transformFunction: null }, collisionBoundary: { classPropertyName: "collisionBoundary", publicName: "collisionBoundary", isSignal: true, isRequired: false, transformFunction: null }, collisionPadding: { classPropertyName: "collisionPadding", publicName: "collisionPadding", 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 }, updatePositionStrategy: { classPropertyName: "updatePositionStrategy", publicName: "updatePositionStrategy", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "style": "{\n 'boxSizing': 'border-box',\n '--radix-combobox-content-transform-origin': 'var(--radix-popper-transform-origin)',\n '--radix-combobox-content-available-width': 'var(--radix-popper-available-width)',\n '--radix-combobox-content-available-height': 'var(--radix-popper-available-height)',\n '--radix-combobox-trigger-width': 'var(--radix-popper-anchor-width)',\n '--radix-combobox-trigger-height': 'var(--radix-popper-anchor-height)'\n }" } }, exportAs: ["rdxComboboxPositioner"], hostDirectives: [{ directive: i1.RdxPopperContentWrapper, inputs: ["side", "side", "sideOffset", "sideOffset", "align", "align", "alignOffset", "alignOffset", "arrowPadding", "arrowPadding", "avoidCollisions", "avoidCollisions", "collisionBoundary", "collisionBoundary", "collisionPadding", "collisionPadding", "sticky", "sticky", "hideWhenDetached", "hideWhenDetached", "updatePositionStrategy", "updatePositionStrategy"] }], ngImport: i0 }); }
2458
+ class RdxComboboxSeparator {
2459
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxSeparator, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2460
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxSeparator, isStandalone: true, selector: "[rdxComboboxSeparator]", host: { attributes: { "role": "separator", "aria-orientation": "horizontal" } }, exportAs: ["rdxComboboxSeparator"], ngImport: i0 }); }
1746
2461
  }
1747
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxPositioner, decorators: [{
2462
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxSeparator, decorators: [{
1748
2463
  type: Directive,
1749
2464
  args: [{
1750
- selector: '[rdxComboboxPositioner]',
1751
- exportAs: 'rdxComboboxPositioner',
1752
- hostDirectives: [
1753
- {
1754
- directive: RdxPopperContentWrapper,
1755
- inputs: [
1756
- 'side',
1757
- 'sideOffset',
1758
- 'align',
1759
- 'alignOffset',
1760
- 'arrowPadding',
1761
- 'avoidCollisions',
1762
- 'collisionBoundary',
1763
- 'collisionPadding',
1764
- 'sticky',
1765
- 'hideWhenDetached',
1766
- 'updatePositionStrategy'
1767
- ]
1768
- }
1769
- ],
2465
+ selector: '[rdxComboboxSeparator]',
2466
+ exportAs: 'rdxComboboxSeparator',
1770
2467
  host: {
1771
- '[style]': `{
1772
- 'boxSizing': 'border-box',
1773
- '--radix-combobox-content-transform-origin': 'var(--radix-popper-transform-origin)',
1774
- '--radix-combobox-content-available-width': 'var(--radix-popper-available-width)',
1775
- '--radix-combobox-content-available-height': 'var(--radix-popper-available-height)',
1776
- '--radix-combobox-trigger-width': 'var(--radix-popper-anchor-width)',
1777
- '--radix-combobox-trigger-height': 'var(--radix-popper-anchor-height)'
1778
- }`
2468
+ role: 'separator',
2469
+ 'aria-orientation': 'horizontal'
1779
2470
  }
1780
2471
  }]
1781
- }], propDecorators: { side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], sideOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "sideOffset", required: false }] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], alignOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignOffset", required: false }] }], arrowPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "arrowPadding", required: false }] }], avoidCollisions: [{ type: i0.Input, args: [{ isSignal: true, alias: "avoidCollisions", required: false }] }], collisionBoundary: [{ type: i0.Input, args: [{ isSignal: true, alias: "collisionBoundary", required: false }] }], collisionPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "collisionPadding", required: false }] }], sticky: [{ type: i0.Input, args: [{ isSignal: true, alias: "sticky", required: false }] }], hideWhenDetached: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideWhenDetached", required: false }] }], updatePositionStrategy: [{ type: i0.Input, args: [{ isSignal: true, alias: "updatePositionStrategy", required: false }] }] } });
2472
+ }] });
1782
2473
 
1783
2474
  /**
1784
2475
  * A polite live region for async status (loading, result counts) announced without moving focus.
@@ -1787,7 +2478,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1787
2478
  */
1788
2479
  class RdxComboboxStatus {
1789
2480
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxStatus, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1790
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxStatus, isStandalone: true, selector: "[rdxComboboxStatus]", host: { attributes: { "role": "status", "aria-live": "polite" } }, exportAs: ["rdxComboboxStatus"], ngImport: i0 }); }
2481
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxStatus, isStandalone: true, selector: "[rdxComboboxStatus]", host: { attributes: { "role": "status", "aria-live": "polite", "aria-atomic": "true" } }, exportAs: ["rdxComboboxStatus"], ngImport: i0 }); }
1791
2482
  }
1792
2483
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxStatus, decorators: [{
1793
2484
  type: Directive,
@@ -1796,13 +2487,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1796
2487
  exportAs: 'rdxComboboxStatus',
1797
2488
  host: {
1798
2489
  role: 'status',
1799
- 'aria-live': 'polite'
2490
+ 'aria-live': 'polite',
2491
+ 'aria-atomic': 'true'
1800
2492
  }
1801
2493
  }]
1802
2494
  }] });
1803
2495
 
1804
2496
  /**
1805
- * Toggles the combobox popup. Carries `tabindex="-1"` so it never steals focus from the input.
2497
+ * Toggles the combobox popup. Its semantics depend on the layout (Base UI's `inputInsidePopup`):
2498
+ *
2499
+ * - **Input outside the popup** (default): a `tabindex="-1"` toggle button (`aria-haspopup="listbox"`)
2500
+ * that never steals focus from the input — `Tab` lands directly on the input.
2501
+ * - **Input inside the popup** (e.g. a command palette / emoji picker): the trigger becomes the primary
2502
+ * `role="combobox"` control — `tabindex="0"` (reachable via `Tab`), `aria-haspopup="dialog"`, and
2503
+ * `ArrowDown`/`ArrowUp` open the popup (which moves focus to the inner input and highlights an item).
2504
+ *
2505
+ * The trigger stays `Tab`-reachable by default and is demoted to `tabindex="-1"` only once an input is
2506
+ * detected *outside* the popup — so a trigger whose input lives in a not-yet-opened popup is focusable
2507
+ * from the first render (`inputLayout` is `unknown` until that input mounts).
1806
2508
  *
1807
2509
  * @group Components
1808
2510
  */
@@ -1813,35 +2515,57 @@ class RdxComboboxTrigger {
1813
2515
  this.rootContext.registerTrigger(this.element);
1814
2516
  inject(DestroyRef).onDestroy(() => this.rootContext.registerTrigger(null));
1815
2517
  }
1816
- onClick() {
2518
+ // Record whether the opening interaction is touch, so the popup can keep focus off the inner input
2519
+ // (and Android's virtual keyboard closed) when the input lives inside the popup.
2520
+ onPointerDown(event) {
2521
+ this.rootContext.setOpenedByTouch(event.pointerType === 'touch');
2522
+ }
2523
+ onClick(event) {
1817
2524
  if (this.rootContext.open()) {
1818
- this.rootContext.closePopup(true);
2525
+ this.rootContext.closePopup(true, 'trigger-press', event);
1819
2526
  }
1820
2527
  else {
1821
2528
  this.rootContext.focusInput();
1822
- this.rootContext.openForBrowse();
2529
+ this.rootContext.openForBrowse('trigger-press', event);
2530
+ }
2531
+ }
2532
+ onKeydown(event) {
2533
+ if (this.rootContext.disabledState() || this.rootContext.readonly()) {
2534
+ return;
2535
+ }
2536
+ // ArrowDown/ArrowUp open the popup and seed the first/last highlight; the popup's own
2537
+ // auto-focus then moves focus to the input (whether it lives inside or outside the popup).
2538
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
2539
+ event.preventDefault();
2540
+ // A keyboard open must focus the input, not the popup — clear any prior touch flag.
2541
+ this.rootContext.setOpenedByTouch(false);
2542
+ this.rootContext.openAndHighlight(event.key === 'ArrowUp' ? 'last' : 'first', 'list-navigation', event);
1823
2543
  }
1824
2544
  }
1825
2545
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1826
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxTrigger, isStandalone: true, selector: "button[rdxComboboxTrigger]", host: { attributes: { "type": "button", "tabindex": "-1", "aria-label": "Open" }, listeners: { "click": "onClick()" }, properties: { "attr.aria-expanded": "rootContext.open()", "attr.aria-controls": "rootContext.listId", "attr.aria-labelledby": "rootContext.labelId()", "attr.disabled": "rootContext.disabledState() ? \"\" : undefined", "attr.data-popup-open": "rootContext.open() ? \"\" : undefined", "attr.data-disabled": "rootContext.disabledState() ? \"\" : undefined" } }, exportAs: ["rdxComboboxTrigger"], hostDirectives: [{ directive: i1$1.RdxDismissableLayerBranch }], ngImport: i0 }); }
2546
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxComboboxTrigger, isStandalone: true, selector: "button[rdxComboboxTrigger]", host: { attributes: { "type": "button" }, listeners: { "pointerdown": "onPointerDown($event)", "click": "onClick($event)", "keydown": "onKeydown($event)" }, properties: { "attr.tabindex": "rootContext.inputLayout() === \"outside\" ? \"-1\" : \"0\"", "attr.role": "rootContext.inputLayout() === \"inside\" ? \"combobox\" : undefined", "attr.aria-haspopup": "rootContext.inputLayout() === \"inside\" ? \"dialog\" : \"listbox\"", "attr.aria-expanded": "rootContext.open()", "attr.aria-controls": "rootContext.listId", "attr.aria-labelledby": "rootContext.labelId()", "attr.aria-required": "rootContext.inputLayout() === \"inside\" && rootContext.requiredState() ? \"true\" : undefined", "attr.disabled": "rootContext.disabledState() ? \"\" : undefined", "attr.data-popup-open": "rootContext.open() ? \"\" : undefined", "attr.data-disabled": "rootContext.disabledState() ? \"\" : undefined" } }, exportAs: ["rdxComboboxTrigger"], hostDirectives: [{ directive: i1$1.RdxFloatingInsideElement }], ngImport: i0 }); }
1827
2547
  }
1828
2548
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxComboboxTrigger, decorators: [{
1829
2549
  type: Directive,
1830
2550
  args: [{
1831
2551
  selector: 'button[rdxComboboxTrigger]',
1832
2552
  exportAs: 'rdxComboboxTrigger',
1833
- hostDirectives: [RdxDismissableLayerBranch],
2553
+ hostDirectives: [RdxFloatingInsideElement],
1834
2554
  host: {
1835
2555
  type: 'button',
1836
- tabindex: '-1',
1837
- 'aria-label': 'Open',
2556
+ '[attr.tabindex]': 'rootContext.inputLayout() === "outside" ? "-1" : "0"',
2557
+ '[attr.role]': 'rootContext.inputLayout() === "inside" ? "combobox" : undefined',
2558
+ '[attr.aria-haspopup]': 'rootContext.inputLayout() === "inside" ? "dialog" : "listbox"',
1838
2559
  '[attr.aria-expanded]': 'rootContext.open()',
1839
2560
  '[attr.aria-controls]': 'rootContext.listId',
1840
2561
  '[attr.aria-labelledby]': 'rootContext.labelId()',
2562
+ '[attr.aria-required]': 'rootContext.inputLayout() === "inside" && rootContext.requiredState() ? "true" : undefined',
1841
2563
  '[attr.disabled]': 'rootContext.disabledState() ? "" : undefined',
1842
2564
  '[attr.data-popup-open]': 'rootContext.open() ? "" : undefined',
1843
2565
  '[attr.data-disabled]': 'rootContext.disabledState() ? "" : undefined',
1844
- '(click)': 'onClick()'
2566
+ '(pointerdown)': 'onPointerDown($event)',
2567
+ '(click)': 'onClick($event)',
2568
+ '(keydown)': 'onKeydown($event)'
1845
2569
  }
1846
2570
  }]
1847
2571
  }], ctorParameters: () => [] });
@@ -1895,6 +2619,7 @@ const _importsCombobox = [
1895
2619
  RdxComboboxAnchor,
1896
2620
  RdxComboboxLabel,
1897
2621
  RdxComboboxInput,
2622
+ RdxComboboxInputGroup,
1898
2623
  RdxComboboxValue,
1899
2624
  RdxComboboxTrigger,
1900
2625
  RdxComboboxIcon,
@@ -1906,10 +2631,12 @@ const _importsCombobox = [
1906
2631
  RdxComboboxPopup,
1907
2632
  RdxComboboxArrow,
1908
2633
  RdxComboboxList,
2634
+ RdxComboboxRow,
1909
2635
  RdxComboboxItem,
1910
2636
  RdxComboboxItemIndicator,
1911
2637
  RdxComboboxGroup,
1912
2638
  RdxComboboxGroupLabel,
2639
+ RdxComboboxSeparator,
1913
2640
  RdxComboboxEmpty,
1914
2641
  RdxComboboxStatus,
1915
2642
  RdxComboboxChips,
@@ -1922,6 +2649,7 @@ class RdxComboboxModule {
1922
2649
  RdxComboboxAnchor,
1923
2650
  RdxComboboxLabel,
1924
2651
  RdxComboboxInput,
2652
+ RdxComboboxInputGroup,
1925
2653
  RdxComboboxValue,
1926
2654
  RdxComboboxTrigger,
1927
2655
  RdxComboboxIcon,
@@ -1933,10 +2661,12 @@ class RdxComboboxModule {
1933
2661
  RdxComboboxPopup,
1934
2662
  RdxComboboxArrow,
1935
2663
  RdxComboboxList,
2664
+ RdxComboboxRow,
1936
2665
  RdxComboboxItem,
1937
2666
  RdxComboboxItemIndicator,
1938
2667
  RdxComboboxGroup,
1939
2668
  RdxComboboxGroupLabel,
2669
+ RdxComboboxSeparator,
1940
2670
  RdxComboboxEmpty,
1941
2671
  RdxComboboxStatus,
1942
2672
  RdxComboboxChips,
@@ -1945,6 +2675,7 @@ class RdxComboboxModule {
1945
2675
  RdxComboboxAnchor,
1946
2676
  RdxComboboxLabel,
1947
2677
  RdxComboboxInput,
2678
+ RdxComboboxInputGroup,
1948
2679
  RdxComboboxValue,
1949
2680
  RdxComboboxTrigger,
1950
2681
  RdxComboboxIcon,
@@ -1956,10 +2687,12 @@ class RdxComboboxModule {
1956
2687
  RdxComboboxPopup,
1957
2688
  RdxComboboxArrow,
1958
2689
  RdxComboboxList,
2690
+ RdxComboboxRow,
1959
2691
  RdxComboboxItem,
1960
2692
  RdxComboboxItemIndicator,
1961
2693
  RdxComboboxGroup,
1962
2694
  RdxComboboxGroupLabel,
2695
+ RdxComboboxSeparator,
1963
2696
  RdxComboboxEmpty,
1964
2697
  RdxComboboxStatus,
1965
2698
  RdxComboboxChips,
@@ -1979,5 +2712,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1979
2712
  * Generated bundle index. Do not edit.
1980
2713
  */
1981
2714
 
1982
- export { RdxComboboxAnchor, RdxComboboxArrow, RdxComboboxBackdrop, RdxComboboxChip, RdxComboboxChipRemove, RdxComboboxChips, RdxComboboxClear, RdxComboboxEmpty, RdxComboboxGroup, RdxComboboxGroupLabel, RdxComboboxIcon, RdxComboboxInput, RdxComboboxItem, RdxComboboxItemIndicator, RdxComboboxLabel, RdxComboboxList, RdxComboboxModule, RdxComboboxPopup, RdxComboboxPortal, RdxComboboxPortalMisuseGuard, RdxComboboxPositioner, RdxComboboxRoot, RdxComboboxStatus, RdxComboboxTrigger, RdxComboboxValue, _importsCombobox, injectComboboxChipContext, injectComboboxGroupContext, injectComboboxItemContext, injectComboboxRootContext, provideComboboxChipContext, provideComboboxGroupContext, provideComboboxItemContext, provideComboboxRootContext };
2715
+ export { RdxComboboxAnchor, RdxComboboxArrow, RdxComboboxBackdrop, RdxComboboxChip, RdxComboboxChipRemove, RdxComboboxChips, RdxComboboxClear, RdxComboboxEmpty, RdxComboboxGroup, RdxComboboxGroupLabel, RdxComboboxIcon, RdxComboboxInput, RdxComboboxInputGroup, RdxComboboxItem, RdxComboboxItemIndicator, RdxComboboxLabel, RdxComboboxList, RdxComboboxModule, RdxComboboxPopup, RdxComboboxPortal, RdxComboboxPortalMisuseGuard, RdxComboboxPositioner, RdxComboboxRoot, RdxComboboxRow, RdxComboboxSeparator, RdxComboboxStatus, RdxComboboxTrigger, RdxComboboxValue, _importsCombobox, injectComboboxChipContext, injectComboboxGroupContext, injectComboboxItemContext, injectComboboxRootContext, provideComboboxChipContext, provideComboboxGroupContext, provideComboboxItemContext, provideComboboxRootContext, useComboboxEngine };
1983
2716
  //# sourceMappingURL=radix-ng-primitives-combobox.mjs.map