bits-ui 1.0.0-next.90 → 1.0.0-next.92

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 (50) hide show
  1. package/dist/bits/alert-dialog/components/alert-dialog-content.svelte +8 -5
  2. package/dist/bits/command/command.svelte.d.ts +0 -1
  3. package/dist/bits/command/command.svelte.js +50 -61
  4. package/dist/bits/command/components/command.svelte +3 -2
  5. package/dist/bits/command/compute-command-score.d.ts +26 -0
  6. package/dist/bits/command/{command-score.js → compute-command-score.js} +47 -15
  7. package/dist/bits/command/index.d.ts +1 -0
  8. package/dist/bits/command/index.js +1 -0
  9. package/dist/bits/command/types.d.ts +2 -1
  10. package/dist/bits/dialog/components/dialog-content.svelte +8 -5
  11. package/dist/bits/index.d.ts +1 -1
  12. package/dist/bits/index.js +1 -1
  13. package/dist/bits/menubar/components/menubar-content-static.svelte +9 -0
  14. package/dist/bits/menubar/components/menubar-content.svelte +9 -0
  15. package/dist/bits/menubar/menubar.svelte.d.ts +7 -3
  16. package/dist/bits/menubar/menubar.svelte.js +10 -2
  17. package/dist/bits/navigation-menu/components/navigation-menu-content-impl.svelte +90 -0
  18. package/dist/bits/navigation-menu/components/navigation-menu-content-impl.svelte.d.ts +13 -0
  19. package/dist/bits/navigation-menu/components/navigation-menu-content.svelte +18 -57
  20. package/dist/bits/navigation-menu/components/navigation-menu-content.svelte.d.ts +1 -1
  21. package/dist/bits/navigation-menu/components/navigation-menu-indicator-impl.svelte +32 -0
  22. package/dist/bits/navigation-menu/components/navigation-menu-indicator-impl.svelte.d.ts +4 -0
  23. package/dist/bits/navigation-menu/components/navigation-menu-indicator.svelte +7 -19
  24. package/dist/bits/navigation-menu/components/navigation-menu-list.svelte +10 -8
  25. package/dist/bits/navigation-menu/components/navigation-menu-sub.svelte +44 -0
  26. package/dist/bits/navigation-menu/components/navigation-menu-sub.svelte.d.ts +4 -0
  27. package/dist/bits/navigation-menu/components/navigation-menu-trigger.svelte +3 -3
  28. package/dist/bits/navigation-menu/components/navigation-menu-viewport.svelte +3 -3
  29. package/dist/bits/navigation-menu/exports.d.ts +2 -1
  30. package/dist/bits/navigation-menu/exports.js +1 -0
  31. package/dist/bits/navigation-menu/navigation-menu.svelte.d.ts +209 -201
  32. package/dist/bits/navigation-menu/navigation-menu.svelte.js +572 -621
  33. package/dist/bits/navigation-menu/types.d.ts +18 -2
  34. package/dist/bits/select/select.svelte.d.ts +2 -2
  35. package/dist/bits/select/select.svelte.js +27 -8
  36. package/dist/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.js +1 -1
  37. package/dist/bits/utilities/focus-scope/use-focus-scope.svelte.js +2 -2
  38. package/dist/bits/utilities/popper-layer/popper-layer.svelte +2 -2
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/internal/events.d.ts +1 -1
  42. package/dist/internal/events.js +2 -2
  43. package/dist/internal/previous-with-init.svelte.d.ts +11 -0
  44. package/dist/internal/previous-with-init.svelte.js +21 -0
  45. package/dist/internal/tabbable.d.ts +1 -0
  46. package/dist/internal/tabbable.js +1 -1
  47. package/dist/internal/use-arrow-navigation.d.ts +0 -1
  48. package/dist/internal/use-arrow-navigation.js +2 -2
  49. package/package.json +1 -1
  50. package/dist/bits/command/command-score.d.ts +0 -1
@@ -1,20 +1,24 @@
1
+ /**
2
+ * Based on Radix UI's Navigation Menu
3
+ * https://www.radix-ui.com/docs/primitives/components/navigation-menu
4
+ */
5
+ import { afterSleep, afterTick, box, useRefById, } from "svelte-toolbelt";
6
+ import { Context, useDebounce, watch } from "runed";
1
7
  import { untrack } from "svelte";
2
- import { afterTick, box, useRefById } from "svelte-toolbelt";
3
- import { Context, Previous } from "runed";
8
+ import { SvelteMap } from "svelte/reactivity";
9
+ import { useId } from "../../shared/index.js";
10
+ import { getAriaExpanded, getDataDisabled, getDataOpenClosed, getDataOrientation, } from "../../internal/attrs.js";
11
+ import { noop } from "../../internal/noop.js";
4
12
  import { getTabbableCandidates } from "../../internal/focus.js";
5
- import { getAriaExpanded, getAriaHidden, getDataDisabled, getDataOpenClosed, getDataOrientation, getDisabled, } from "../../internal/attrs.js";
6
- import { useId } from "../../internal/use-id.js";
7
13
  import { kbd } from "../../internal/kbd.js";
14
+ import { CustomEventDispatcher } from "../../internal/events.js";
15
+ import { useRovingFocus } from "../../internal/use-roving-focus.svelte.js";
8
16
  import { useArrowNavigation } from "../../internal/use-arrow-navigation.js";
9
17
  import { boxAutoReset } from "../../internal/box-auto-reset.svelte.js";
10
- import { noop } from "../../internal/noop.js";
11
- import { useRovingFocus } from "../../internal/use-roving-focus.svelte.js";
12
- const NavigationMenuRootContext = new Context("NavigationMenu.Root");
13
- const NavigationMenuMenuContext = new Context("NavigationMenu.Root | NavigationMenu.Sub");
14
- const NavigationMenuListContext = new Context("NavigationMenu.List");
15
- const NavigationMenuItemContext = new Context("NavigationMenu.Item");
16
- const NavigationMenuContentContext = new Context("NavigationMenu.Content");
18
+ import { useResizeObserver } from "../../internal/use-resize-observer.svelte.js";
19
+ import { isElement } from "../../internal/is.js";
17
20
  const NAVIGATION_MENU_ROOT_ATTR = "data-navigation-menu-root";
21
+ const NAVIGATION_MENU_ATTR = "data-navigation-menu";
18
22
  const NAVIGATION_MENU_SUB_ATTR = "data-navigation-menu-sub";
19
23
  const NAVIGATION_MENU_ITEM_ATTR = "data-navigation-menu-item";
20
24
  const NAVIGATION_MENU_INDICATOR_ATTR = "data-navigation-menu-indicator";
@@ -22,517 +26,407 @@ const NAVIGATION_MENU_LIST_ATTR = "data-navigation-menu-list";
22
26
  const NAVIGATION_MENU_TRIGGER_ATTR = "data-navigation-menu-trigger";
23
27
  const NAVIGATION_MENU_CONTENT_ATTR = "data-navigation-menu-content";
24
28
  const NAVIGATION_MENU_LINK_ATTR = "data-navigation-menu-link";
25
- class NavigationMenuRootState {
26
- id;
27
- rootRef;
28
- delayDuration;
29
- skipDelayDuration;
30
- orientation;
31
- dir;
32
- value;
33
- previousValue = new Previous(() => this.value.current);
34
- openTimer = 0;
35
- closeTimer = 0;
36
- skipDelayTimer = 0;
37
- isOpenDelayed = $state(false);
38
- #setValue(v) {
39
- this.value.current = v;
40
- }
41
- constructor(props) {
42
- this.id = props.id;
43
- this.delayDuration = props.delayDuration;
44
- this.skipDelayDuration = props.skipDelayDuration;
45
- this.orientation = props.orientation;
46
- this.dir = props.dir;
47
- this.value = props.value;
48
- this.rootRef = props.ref;
49
- this.onTriggerEnter = this.onTriggerEnter.bind(this);
50
- this.onTriggerLeave = this.onTriggerLeave.bind(this);
51
- this.onContentEnter = this.onContentEnter.bind(this);
52
- this.onContentLeave = this.onContentLeave.bind(this);
53
- this.onItemSelect = this.onItemSelect.bind(this);
54
- this.onItemDismiss = this.onItemDismiss.bind(this);
55
- useRefById({
56
- id: this.id,
57
- ref: this.rootRef,
58
- });
59
- $effect(() => {
60
- this.value.current;
61
- untrack(() => {
62
- const curr = this.value.current;
63
- const isOpen = curr !== "";
64
- const hasSkipDelayDuration = this.skipDelayDuration.current > 0;
65
- if (isOpen) {
66
- window.clearTimeout(this.skipDelayTimer);
67
- if (hasSkipDelayDuration)
68
- this.isOpenDelayed = false;
69
- }
70
- else {
71
- window.clearTimeout(this.skipDelayTimer);
72
- this.skipDelayTimer = window.setTimeout(() => (this.isOpenDelayed = true), this.skipDelayDuration.current);
73
- }
74
- });
75
- });
76
- $effect(() => {
77
- return () => {
78
- window.clearTimeout(this.openTimer);
79
- window.clearTimeout(this.closeTimer);
80
- window.clearTimeout(this.skipDelayTimer);
81
- };
82
- });
83
- }
84
- #startCloseTimer() {
85
- window.clearTimeout(this.closeTimer);
86
- this.closeTimer = window.setTimeout(() => this.#setValue(""), 150);
87
- }
88
- #handleOpen(itemValue) {
89
- window.clearTimeout(this.closeTimer);
90
- this.#setValue(itemValue);
91
- }
92
- handleClose() {
93
- this.onItemDismiss();
94
- this.onContentLeave();
29
+ class NavigationMenuProviderState {
30
+ opts;
31
+ indicatorTrackRef = box(null);
32
+ viewportRef = box(null);
33
+ viewportContent = new SvelteMap();
34
+ onTriggerEnter;
35
+ onTriggerLeave = noop;
36
+ onContentEnter = noop;
37
+ onContentLeave = noop;
38
+ onItemSelect;
39
+ onItemDismiss;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ this.onTriggerEnter = opts.onTriggerEnter;
43
+ this.onTriggerLeave = opts.onTriggerLeave ?? noop;
44
+ this.onContentEnter = opts.onContentEnter ?? noop;
45
+ this.onContentLeave = opts.onContentLeave ?? noop;
46
+ this.onItemDismiss = opts.onItemDismiss;
47
+ this.onItemSelect = opts.onItemSelect;
95
48
  }
96
- #handleDelayedOpen(itemValue) {
97
- const isOpenItem = this.value.current === itemValue;
98
- if (isOpenItem) {
99
- // If the item is already open (e.g. we're transitioning from the content to the trigger)
100
- // then we want to clear the close timer immediately.
101
- window.clearTimeout(this.closeTimer);
49
+ }
50
+ class NavigationMenuRootState {
51
+ opts;
52
+ provider;
53
+ previousValue = box("");
54
+ isDelaySkipped;
55
+ #derivedDelay = $derived.by(() => {
56
+ const isOpen = this.opts?.value?.current !== "";
57
+ if (isOpen || this.isDelaySkipped.current) {
58
+ // 150 for user to switch trigger or move into content view
59
+ return 100;
102
60
  }
103
61
  else {
104
- this.openTimer = window.setTimeout(() => {
105
- window.clearTimeout(this.closeTimer);
106
- this.#setValue(itemValue);
107
- }, this.delayDuration.current);
62
+ return this.opts.delayDuration.current;
108
63
  }
64
+ });
65
+ constructor(opts) {
66
+ this.opts = opts;
67
+ this.isDelaySkipped = boxAutoReset(false, this.opts.skipDelayDuration.current);
68
+ useRefById(opts);
69
+ this.provider = useNavigationMenuProvider({
70
+ value: this.opts.value,
71
+ previousValue: this.previousValue,
72
+ dir: this.opts.dir,
73
+ orientation: this.opts.orientation,
74
+ rootNavigationMenuRef: this.opts.ref,
75
+ isRootMenu: true,
76
+ onTriggerEnter: (itemValue) => {
77
+ this.#onTriggerEnter(itemValue);
78
+ },
79
+ onTriggerLeave: this.#onTriggerLeave,
80
+ onContentEnter: this.#onContentEnter,
81
+ onContentLeave: this.#onContentLeave,
82
+ onItemSelect: this.#onItemSelect,
83
+ onItemDismiss: this.#onItemDismiss,
84
+ });
109
85
  }
110
- onTriggerEnter(itemValue) {
111
- window.clearTimeout(this.openTimer);
112
- if (this.isOpenDelayed) {
113
- this.#handleDelayedOpen(itemValue);
114
- }
115
- else {
116
- this.#handleOpen(itemValue);
86
+ #debouncedFn = useDebounce((val) => {
87
+ // passing `undefined` meant to reset the debounce timer
88
+ if (typeof val === "string") {
89
+ this.setValue(val);
117
90
  }
118
- }
119
- onTriggerLeave() {
120
- window.clearTimeout(this.openTimer);
121
- this.#startCloseTimer();
122
- }
123
- onContentEnter() {
124
- window.clearTimeout(this.closeTimer);
125
- }
126
- onContentLeave() {
127
- this.#startCloseTimer();
128
- }
129
- onItemSelect(itemValue) {
130
- const prevValue = this.value.current;
131
- this.#setValue(prevValue === itemValue ? "" : itemValue);
132
- }
133
- onItemDismiss() {
134
- this.#setValue("");
135
- }
91
+ }, () => this.#derivedDelay);
92
+ #onTriggerEnter = (itemValue) => {
93
+ this.#debouncedFn(itemValue);
94
+ };
95
+ #onTriggerLeave = () => {
96
+ this.isDelaySkipped.current = false;
97
+ this.#debouncedFn("");
98
+ };
99
+ #onContentEnter = () => {
100
+ this.#debouncedFn();
101
+ };
102
+ #onContentLeave = () => {
103
+ this.#debouncedFn("");
104
+ };
105
+ #onItemSelect = (itemValue) => {
106
+ this.setValue(itemValue);
107
+ };
108
+ #onItemDismiss = () => {
109
+ this.setValue("");
110
+ };
111
+ setValue = (newValue) => {
112
+ this.previousValue.current = this.opts.value.current;
113
+ this.opts.value.current = newValue;
114
+ };
136
115
  props = $derived.by(() => ({
137
- id: this.id.current,
138
- "aria-label": "Main",
139
- "data-orientation": getDataOrientation(this.orientation.current),
140
- dir: this.dir.current,
116
+ id: this.opts.id.current,
117
+ "data-orientation": getDataOrientation(this.opts.orientation.current),
118
+ dir: this.opts.dir.current,
141
119
  [NAVIGATION_MENU_ROOT_ATTR]: "",
120
+ [NAVIGATION_MENU_ATTR]: "",
142
121
  }));
143
122
  }
144
- class NavigationMenuMenuState {
145
- isRoot = $state(false);
146
- rootNavigationId;
147
- dir;
148
- orientation;
149
- value;
150
- previousValue;
151
- onTriggerEnter;
152
- onTriggerLeave;
153
- onContentEnter;
154
- onContentLeave;
155
- onItemSelect;
156
- onItemDismiss;
157
- viewportNode = $state(null);
158
- indicatorTrackNode = $state(null);
159
- viewportContentId = box.with(() => undefined);
160
- root;
161
- triggerRefs = new Set();
162
- constructor(props, root) {
163
- this.isRoot = props.isRoot;
164
- this.rootNavigationId = props.rootNavigationId;
165
- this.dir = props.dir;
166
- this.orientation = props.orientation;
167
- this.value = props.value;
168
- this.onTriggerEnter = props.onTriggerEnter;
169
- this.onTriggerLeave = props.onTriggerLeave;
170
- this.onContentEnter = props.onContentEnter;
171
- this.onContentLeave = props.onContentLeave;
172
- this.onItemSelect = props.onItemSelect;
173
- this.onItemDismiss = props.onItemDismiss;
174
- this.root = root;
175
- this.previousValue = props.previousValue;
176
- }
177
- registerTrigger(ref) {
178
- this.triggerRefs.add(ref);
179
- }
180
- deRegisterTrigger(ref) {
181
- this.triggerRefs.delete(ref);
182
- }
183
- getTriggerNodes() {
184
- return Array.from(this.triggerRefs)
185
- .map((ref) => ref.current)
186
- .filter((node) => Boolean(node));
187
- }
188
- }
189
123
  class NavigationMenuSubState {
190
- id;
191
- isRoot = false;
192
- rootNavigationId;
193
- dir;
194
- orientation;
195
- value;
196
- previousValue = new Previous(() => this.value.current);
197
- onTriggerLeave;
198
- onContentEnter;
199
- onContentLeave;
200
- viewportNode = $state(null);
201
- indicatorTrackNode = $state(null);
202
- viewportContentId = box.with(() => undefined);
203
- root;
204
- triggerRefs = new Set();
205
- ref;
206
- constructor(props, root) {
207
- this.id = props.id;
208
- this.rootNavigationId = root.id;
209
- this.dir = root.dir;
210
- this.orientation = props.orientation;
211
- this.value = props.value;
212
- this.root = root;
213
- this.ref = props.ref;
214
- useRefById({
215
- id: this.id,
216
- ref: this.ref,
124
+ opts;
125
+ context;
126
+ previousValue = box("");
127
+ constructor(opts, context) {
128
+ this.opts = opts;
129
+ this.context = context;
130
+ useRefById(opts);
131
+ useNavigationMenuProvider({
132
+ isRootMenu: false,
133
+ value: this.opts.value,
134
+ dir: this.context.opts.dir,
135
+ orientation: this.opts.orientation,
136
+ rootNavigationMenuRef: this.opts.ref,
137
+ onTriggerEnter: this.setValue,
138
+ onItemSelect: this.setValue,
139
+ onItemDismiss: () => this.setValue(""),
140
+ previousValue: this.previousValue,
217
141
  });
218
142
  }
219
- onTriggerEnter(itemValue) {
220
- this.value.current = itemValue;
221
- }
222
- onItemSelect(itemValue) {
223
- this.value.current = itemValue;
224
- }
225
- registerTrigger(ref) {
226
- this.triggerRefs.add(ref);
227
- }
228
- deRegisterTrigger(ref) {
229
- this.triggerRefs.delete(ref);
230
- }
231
- getTriggerNodes() {
232
- return Array.from(this.triggerRefs)
233
- .map((ref) => ref.current)
234
- .filter((node) => Boolean(node));
235
- }
143
+ setValue = (newValue) => {
144
+ this.opts.value.current = newValue;
145
+ };
236
146
  props = $derived.by(() => ({
237
- id: this.id.current,
238
- "data-orientation": getDataOrientation(this.orientation.current),
147
+ id: this.opts.id.current,
148
+ "data-orientation": getDataOrientation(this.opts.orientation.current),
239
149
  [NAVIGATION_MENU_SUB_ATTR]: "",
150
+ [NAVIGATION_MENU_ATTR]: "",
240
151
  }));
241
152
  }
242
153
  class NavigationMenuListState {
243
- menu;
244
- #id;
245
- #ref;
246
- indicatorTrackRef;
247
- indicatorTrackId = box(useId());
154
+ opts;
155
+ context;
156
+ wrapperId = box(useId());
157
+ wrapperRef = box(null);
158
+ listTriggers = $state.raw([]);
248
159
  rovingFocusGroup;
249
- constructor(props, menu) {
250
- this.menu = menu;
251
- this.#id = props.id;
252
- this.#ref = props.ref;
253
- this.indicatorTrackRef = props.indicatorTrackRef;
254
- this.rovingFocusGroup = useRovingFocus({
255
- rootNodeId: this.#id,
256
- candidateAttr: NAVIGATION_MENU_TRIGGER_ATTR,
257
- candidateSelector: `:is([${NAVIGATION_MENU_TRIGGER_ATTR}], [data-list-link]):not([data-disabled])`,
258
- loop: box.with(() => false),
259
- orientation: this.menu.orientation,
260
- });
160
+ wrapperMounted = $state(false);
161
+ constructor(opts, context) {
162
+ this.opts = opts;
163
+ this.context = context;
164
+ useRefById(opts);
261
165
  useRefById({
262
- id: this.#id,
263
- ref: this.#ref,
264
- });
265
- useRefById({
266
- id: this.indicatorTrackId,
267
- ref: this.indicatorTrackRef,
166
+ id: this.wrapperId,
167
+ ref: this.wrapperRef,
268
168
  onRefChange: (node) => {
269
- this.menu.indicatorTrackNode = node;
169
+ this.context.indicatorTrackRef.current = node;
270
170
  },
271
- deps: () => Boolean(this.menu.root.value.current),
171
+ deps: () => this.wrapperMounted,
172
+ });
173
+ this.rovingFocusGroup = useRovingFocus({
174
+ rootNodeId: opts.id,
175
+ candidateAttr: NAVIGATION_MENU_ITEM_ATTR,
176
+ candidateSelector: `:is([${NAVIGATION_MENU_TRIGGER_ATTR}], [data-list-link]):not([data-disabled])`,
177
+ loop: box.with(() => false),
178
+ orientation: this.context.opts.orientation,
272
179
  });
273
180
  }
274
- indicatorTrackProps = $derived.by(() => ({
275
- id: this.indicatorTrackId.current,
276
- style: {
277
- position: "relative",
278
- },
181
+ registerTrigger(trigger) {
182
+ if (trigger)
183
+ this.listTriggers.push(trigger);
184
+ return () => {
185
+ this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger.id);
186
+ };
187
+ }
188
+ wrapperProps = $derived.by(() => ({
189
+ id: this.wrapperId.current,
279
190
  }));
280
191
  props = $derived.by(() => ({
281
- id: this.#id.current,
282
- "data-orientation": getDataOrientation(this.menu.orientation.current),
192
+ id: this.opts.id.current,
193
+ "data-orientation": getDataOrientation(this.context.opts.orientation.current),
283
194
  [NAVIGATION_MENU_LIST_ATTR]: "",
284
195
  }));
285
196
  }
286
- class NavigationMenuItemState {
287
- id;
288
- #ref;
289
- value;
197
+ export class NavigationMenuItemState {
198
+ opts;
199
+ listContext;
290
200
  contentNode = $state(null);
291
201
  triggerNode = $state(null);
292
- focusProxyRef = box(null);
293
202
  focusProxyNode = $state(null);
294
- focusProxyId = box(useId());
295
203
  restoreContentTabOrder = noop;
296
- wasEscapeClose = $state(false);
297
- menu;
298
- list;
299
- constructor(props, list, menu) {
300
- this.id = props.id;
301
- this.#ref = props.ref;
302
- this.value = props.value;
303
- this.menu = menu;
304
- this.list = list;
305
- this.handleContentEntry = this.handleContentEntry.bind(this);
306
- this.handleContentExit = this.handleContentExit.bind(this);
307
- useRefById({
308
- id: this.id,
309
- ref: this.#ref,
310
- });
311
- }
312
- handleContentEntry(side = "start") {
204
+ wasEscapeClose = false;
205
+ contentId = $derived.by(() => this.contentNode?.id);
206
+ triggerId = $derived.by(() => this.triggerNode?.id);
207
+ contentChildren = box(undefined);
208
+ contentChild = box(undefined);
209
+ contentProps = box({});
210
+ constructor(opts, listContext) {
211
+ this.opts = opts;
212
+ this.listContext = listContext;
213
+ }
214
+ #handleContentEntry = (side = "start") => {
313
215
  if (!this.contentNode)
314
216
  return;
315
217
  this.restoreContentTabOrder();
316
218
  const candidates = getTabbableCandidates(this.contentNode);
317
- if (candidates.length) {
318
- if (side === "start") {
319
- candidates[0]?.focus();
320
- }
321
- else {
322
- candidates[candidates.length - 1]?.focus();
323
- }
324
- }
325
- }
326
- handleContentExit() {
219
+ if (candidates.length)
220
+ focusFirst(side === "start" ? candidates : candidates.reverse());
221
+ };
222
+ #handleContentExit = () => {
327
223
  if (!this.contentNode)
328
224
  return;
329
225
  const candidates = getTabbableCandidates(this.contentNode);
330
- if (candidates.length) {
226
+ if (candidates.length)
331
227
  this.restoreContentTabOrder = removeFromTabOrder(candidates);
332
- }
333
- }
334
- onEntryKeydown = this.handleContentEntry;
335
- onFocusProxyEnter = this.handleContentEntry;
336
- onContentFocusOutside = this.handleContentExit;
337
- onRootContentClose = this.handleContentExit;
228
+ };
229
+ onEntryKeydown = this.#handleContentEntry;
230
+ onFocusProxyEnter = this.#handleContentEntry;
231
+ onRootContentClose = this.#handleContentExit;
232
+ onContentFocusOutside = this.#handleContentExit;
338
233
  props = $derived.by(() => ({
339
- id: this.id.current,
234
+ id: this.opts.id.current,
340
235
  [NAVIGATION_MENU_ITEM_ATTR]: "",
341
236
  }));
342
237
  }
343
238
  class NavigationMenuTriggerState {
344
- #id;
345
- #ref;
239
+ opts;
240
+ focusProxyId = box(useId());
241
+ focusProxyRef = box(null);
242
+ context;
243
+ itemContext;
244
+ listContext;
245
+ hasPointerMoveOpened = box(false);
246
+ wasClickClose = false;
247
+ open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current);
346
248
  focusProxyMounted = $state(false);
347
- menu;
348
- item;
349
- disabled;
350
- hasPointerMoveOpened = boxAutoReset(false, 150);
351
- wasClickClose = $state(false);
352
- open = $derived.by(() => this.item.value.current === this.menu.value.current);
353
- constructor(props, item) {
354
- this.#id = props.id;
355
- this.#ref = props.ref;
356
- this.item = item;
357
- this.menu = item.menu;
358
- this.disabled = props.disabled;
249
+ constructor(opts, context) {
250
+ this.opts = opts;
251
+ this.hasPointerMoveOpened = boxAutoReset(false, 300);
252
+ this.context = context.provider;
253
+ this.itemContext = context.item;
254
+ this.listContext = context.list;
359
255
  useRefById({
360
- id: this.#id,
361
- ref: this.#ref,
256
+ ...opts,
362
257
  onRefChange: (node) => {
363
- this.item.triggerNode = node;
258
+ this.itemContext.triggerNode = node;
364
259
  },
365
260
  });
366
261
  useRefById({
367
- id: this.item.focusProxyId,
368
- ref: this.item.focusProxyRef,
262
+ id: this.focusProxyId,
263
+ ref: this.focusProxyRef,
369
264
  onRefChange: (node) => {
370
- this.item.focusProxyNode = node;
265
+ this.itemContext.focusProxyNode = node;
371
266
  },
372
267
  deps: () => this.focusProxyMounted,
373
268
  });
374
- $effect(() => {
375
- this.menu.registerTrigger(this.#ref);
376
- return () => {
377
- this.menu.deRegisterTrigger(this.#ref);
378
- };
269
+ watch(() => this.opts.ref.current, () => {
270
+ const node = this.opts.ref.current;
271
+ if (!node)
272
+ return;
273
+ return this.listContext.registerTrigger(node);
379
274
  });
380
- this.onpointerenter = this.onpointerenter.bind(this);
381
- this.onpointermove = this.onpointermove.bind(this);
382
- this.onpointerleave = this.onpointerleave.bind(this);
383
- this.onclick = this.onclick.bind(this);
384
- this.onkeydown = this.onkeydown.bind(this);
385
275
  }
386
- onpointerenter(_) {
276
+ onpointerenter = (_) => {
387
277
  this.wasClickClose = false;
388
- this.item.wasEscapeClose = false;
389
- }
390
- onpointermove(e) {
391
- if (e.pointerType !== "mouse")
392
- return;
393
- if (this.disabled.current ||
278
+ this.itemContext.wasEscapeClose = false;
279
+ };
280
+ onpointermove = whenMouse(() => {
281
+ if (this.opts.disabled.current ||
394
282
  this.wasClickClose ||
395
- this.item.wasEscapeClose ||
396
- this.hasPointerMoveOpened.current)
283
+ this.itemContext.wasEscapeClose ||
284
+ this.hasPointerMoveOpened.current) {
397
285
  return;
398
- this.menu.onTriggerEnter(this.item.value.current);
286
+ }
287
+ this.context.onTriggerEnter(this.itemContext.opts.value.current);
399
288
  this.hasPointerMoveOpened.current = true;
400
- }
401
- onpointerleave(e) {
402
- if (e.pointerType !== "mouse" || this.disabled.current)
289
+ });
290
+ onpointerleave = whenMouse(() => {
291
+ if (this.opts.disabled.current)
403
292
  return;
404
- this.menu.onTriggerLeave?.();
293
+ this.context.onTriggerLeave();
405
294
  this.hasPointerMoveOpened.current = false;
406
- }
407
- onclick(_) {
408
- // if opened via pointer move, we prevent clicked event
295
+ });
296
+ onclick = (_) => {
297
+ // if opened via pointer move, we prevent the click event
409
298
  if (this.hasPointerMoveOpened.current)
410
299
  return;
411
300
  if (this.open) {
412
- this.menu.onItemSelect("");
301
+ this.context.onItemSelect("");
413
302
  }
414
303
  else {
415
- this.menu.onItemSelect(this.item.value.current);
304
+ this.context.onItemSelect(this.itemContext.opts.value.current);
416
305
  }
417
306
  this.wasClickClose = this.open;
418
- }
419
- onkeydown(e) {
420
- const verticalEntryKey = this.menu.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT;
421
- const entryKey = {
422
- horizontal: kbd.ARROW_DOWN,
423
- vertical: verticalEntryKey,
424
- }[this.menu.orientation.current];
307
+ };
308
+ onkeydown = (e) => {
309
+ const verticalEntryKey = this.context.opts.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT;
310
+ const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[this.context.opts.orientation.current];
425
311
  if (this.open && e.key === entryKey) {
426
- this.item.onEntryKeydown();
312
+ this.itemContext.onEntryKeydown();
313
+ // prevent focus group from handling the event
427
314
  e.preventDefault();
428
315
  return;
429
316
  }
430
- this.item.list.rovingFocusGroup.handleKeydown(this.#ref.current, e);
431
- }
317
+ this.itemContext.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
318
+ };
319
+ focusProxyOnFocus = (e) => {
320
+ const content = this.itemContext.contentNode;
321
+ const prevFocusedElement = e.relatedTarget;
322
+ const wasTriggerFocused = this.opts.ref.current && prevFocusedElement === this.opts.ref.current;
323
+ const wasFocusFromContent = content?.contains(prevFocusedElement);
324
+ if (wasTriggerFocused || !wasFocusFromContent) {
325
+ this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end");
326
+ }
327
+ };
432
328
  props = $derived.by(() => ({
433
- id: this.#id.current,
434
- disabled: getDisabled(this.disabled.current),
435
- "data-disabled": getDataDisabled(this.disabled.current),
329
+ id: this.opts.id.current,
330
+ disabled: this.opts.disabled.current,
331
+ "data-disabled": getDataDisabled(Boolean(this.opts.disabled.current)),
436
332
  "data-state": getDataOpenClosed(this.open),
333
+ "data-value": this.itemContext.opts.value.current,
437
334
  "aria-expanded": getAriaExpanded(this.open),
438
- "aria-controls": this.item.contentNode ? this.item.contentNode.id : undefined,
439
- "data-value": this.item.value.current,
440
- onpointerenter: this.onpointerenter,
335
+ "aria-controls": this.itemContext.contentId,
336
+ [NAVIGATION_MENU_TRIGGER_ATTR]: "",
441
337
  onpointermove: this.onpointermove,
442
338
  onpointerleave: this.onpointerleave,
339
+ onpointerenter: this.onpointerenter,
443
340
  onclick: this.onclick,
444
341
  onkeydown: this.onkeydown,
445
- [NAVIGATION_MENU_TRIGGER_ATTR]: "",
446
342
  }));
447
- visuallyHiddenProps = $derived.by(() => ({
448
- id: this.item.focusProxyId.current,
449
- "aria-hidden": "true",
450
- tabIndex: 0,
451
- onfocus: (e) => {
452
- const prevFocusedElement = e.relatedTarget;
453
- const wasTriggerFocused = prevFocusedElement === this.item.triggerNode;
454
- const wasFocusFromContent = this.item.contentNode?.contains(prevFocusedElement);
455
- if (wasTriggerFocused || !wasFocusFromContent) {
456
- e.preventDefault();
457
- this.item.onFocusProxyEnter(wasTriggerFocused ? "start" : "end");
458
- }
459
- },
343
+ focusProxyProps = $derived.by(() => ({
344
+ id: this.focusProxyId.current,
345
+ tabindex: 0,
346
+ onfocus: this.focusProxyOnFocus,
347
+ }));
348
+ restructureSpanProps = $derived.by(() => ({
349
+ "aria-owns": this.itemContext.contentId,
460
350
  }));
461
351
  }
352
+ const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", {
353
+ bubbles: true,
354
+ cancelable: true,
355
+ });
356
+ const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", {
357
+ cancelable: true,
358
+ bubbles: true,
359
+ });
462
360
  class NavigationMenuLinkState {
463
- #id;
464
- #ref;
465
- active;
466
- onSelect;
467
- content;
468
- item;
469
- constructor(props, item, content) {
470
- this.#id = props.id;
471
- this.#ref = props.ref;
472
- this.active = props.active;
473
- this.onSelect = props.onSelect;
474
- this.content = content;
475
- this.item = item;
476
- useRefById({
477
- id: this.#id,
478
- ref: this.#ref,
479
- });
480
- this.onclick = this.onclick.bind(this);
481
- this.onkeydown = this.onkeydown.bind(this);
482
- }
483
- onclick(e) {
484
- const linkSelectEvent = new CustomEvent("navigationMenu.linkSelect", {
485
- bubbles: true,
486
- cancelable: true,
487
- });
488
- this.onSelect.current(linkSelectEvent);
361
+ opts;
362
+ context;
363
+ isFocused = $state(false);
364
+ constructor(opts, context) {
365
+ this.opts = opts;
366
+ this.context = context;
367
+ useRefById(opts);
368
+ }
369
+ onclick = (e) => {
370
+ const currTarget = e.currentTarget;
371
+ LINK_SELECT_EVENT.listen(currTarget, (e) => this.opts.onSelect.current(e), { once: true });
372
+ const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget);
489
373
  if (!linkSelectEvent.defaultPrevented && !e.metaKey) {
490
- //
374
+ ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget);
491
375
  }
492
- }
493
- onkeydown(e) {
494
- this.item.list.rovingFocusGroup.handleKeydown(this.#ref.current, e);
495
- }
376
+ };
377
+ onkeydown = (e) => {
378
+ if (this.context.item.contentNode)
379
+ return;
380
+ this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
381
+ };
382
+ onfocus = (_) => {
383
+ this.isFocused = true;
384
+ };
385
+ onblur = (_) => {
386
+ this.isFocused = false;
387
+ };
496
388
  props = $derived.by(() => ({
497
- id: this.#id.current,
498
- "data-active": this.active.current ? "" : undefined,
499
- "aria-current": this.active.current ? "page" : undefined,
500
- "data-list-link": this.content ? undefined : "",
389
+ id: this.opts.id.current,
390
+ "data-active": this.opts.active.current ? "" : undefined,
391
+ "aria-current": this.opts.active.current ? "page" : undefined,
392
+ "data-focused": this.isFocused ? "" : undefined,
501
393
  onclick: this.onclick,
502
- onfocus: (_) => { },
503
- onkeydown: this.content ? undefined : this.onkeydown,
394
+ onkeydown: this.onkeydown,
395
+ onfocus: this.onfocus,
396
+ onblur: this.onblur,
397
+ [NAVIGATION_MENU_LINK_ATTR]: "",
504
398
  }));
505
399
  }
506
400
  class NavigationMenuIndicatorState {
507
- id;
508
- menu;
509
- activeTrigger = $state(null);
510
- position = $state(null);
511
- isHorizontal = $derived.by(() => this.menu.orientation.current === "horizontal");
512
- isVisible = $derived.by(() => Boolean(this.menu.value.current));
513
- indicatorRef;
514
- constructor(props, menu) {
515
- this.id = props.id;
516
- this.indicatorRef = props.ref;
517
- this.menu = menu;
401
+ context;
402
+ isVisible = $derived.by(() => Boolean(this.context.opts.value.current));
403
+ constructor(context) {
404
+ this.context = context;
405
+ }
406
+ }
407
+ class NavigationMenuIndicatorImplState {
408
+ opts;
409
+ context;
410
+ listContext;
411
+ position = $state.raw(null);
412
+ isHorizontal = $derived.by(() => this.context.opts.orientation.current === "horizontal");
413
+ isVisible = $derived.by(() => !!this.context.opts.value.current);
414
+ activeTrigger = $derived.by(() => {
415
+ const items = this.listContext.listTriggers;
416
+ const triggerNode = items.find((item) => item.getAttribute("data-value") === this.context.opts.value.current);
417
+ return triggerNode ?? null;
418
+ });
419
+ shouldRender = $derived.by(() => this.position !== null);
420
+ constructor(opts, context) {
421
+ this.opts = opts;
422
+ this.context = context.provider;
423
+ this.listContext = context.list;
424
+ useResizeObserver(() => this.activeTrigger, this.handlePositionChange);
425
+ useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange);
518
426
  useRefById({
519
- id: this.id,
520
- ref: this.indicatorRef,
521
- onRefChange: (node) => {
522
- this.menu.viewportNode = node;
523
- },
524
- });
525
- $effect(() => {
526
- const triggerNodes = this.menu.getTriggerNodes();
527
- const triggerNode = triggerNodes.find((node) => node.dataset.value === this.menu.value.current);
528
- if (triggerNode) {
529
- untrack(() => {
530
- this.activeTrigger = triggerNode;
531
- });
532
- }
427
+ ...opts,
428
+ deps: () => this.context.opts.value.current,
533
429
  });
534
- useResizeObserver(() => this.activeTrigger, this.handlePositionChange);
535
- useResizeObserver(() => this.menu.indicatorTrackNode, this.handlePositionChange);
536
430
  }
537
431
  handlePositionChange = () => {
538
432
  if (!this.activeTrigger)
@@ -547,127 +441,160 @@ class NavigationMenuIndicatorState {
547
441
  };
548
442
  };
549
443
  props = $derived.by(() => ({
550
- "aria-hidden": getAriaHidden(true),
444
+ id: this.opts.id.current,
551
445
  "data-state": this.isVisible ? "visible" : "hidden",
552
- "data-orientation": getDataOrientation(this.menu.orientation.current),
446
+ "data-orientation": getDataOrientation(this.context.opts.orientation.current),
553
447
  style: {
554
448
  position: "absolute",
555
449
  ...(this.isHorizontal
556
450
  ? {
557
451
  left: 0,
558
- width: this.position ? `${this.position.size}px` : undefined,
559
- transform: this.position
560
- ? `translateX(${this.position.offset}px)`
561
- : undefined,
452
+ width: `${this.position?.size}px`,
453
+ transform: `translateX(${this.position?.offset}px)`,
562
454
  }
563
455
  : {
564
456
  top: 0,
565
- height: this.position ? `${this.position.size}px` : undefined,
566
- transform: this.position
567
- ? `translateY(${this.position.offset}px)`
568
- : undefined,
457
+ height: `${this.position?.size}px`,
458
+ transform: `translateY(${this.position?.offset}px)`,
569
459
  }),
570
460
  },
571
461
  [NAVIGATION_MENU_INDICATOR_ATTR]: "",
572
462
  }));
573
463
  }
574
464
  class NavigationMenuContentState {
575
- id;
576
- forceMount;
577
- isMounted;
578
- contentRef;
579
- menu;
580
- item;
581
- prevMotionAttribute = $state(null);
582
- motionAttribute = $state(null);
583
- open = $derived.by(() => this.menu.value.current === this.item.value.current);
584
- isPresent = $derived.by(() => this.open || this.forceMount.current);
585
- constructor(props, item) {
586
- this.id = props.id;
587
- this.forceMount = props.forceMount;
588
- this.isMounted = props.isMounted;
589
- this.item = item;
590
- this.menu = item.menu;
591
- this.contentRef = props.ref;
465
+ opts;
466
+ context;
467
+ itemContext;
468
+ listContext;
469
+ open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current);
470
+ mounted = $state(false);
471
+ value = $derived.by(() => this.itemContext.opts.value.current);
472
+ // We persist the last active content value as the viewport may be animating out
473
+ // and we want the content to remain mounted for the lifecycle of the viewport.
474
+ isLastActiveValue = $derived.by(() => {
475
+ if (this.context.viewportRef.current) {
476
+ if (!this.context.opts.value.current && this.context.opts.previousValue.current) {
477
+ return (this.context.opts.previousValue.current === this.itemContext.opts.value.current);
478
+ }
479
+ }
480
+ return false;
481
+ });
482
+ constructor(opts, context) {
483
+ this.opts = opts;
484
+ this.context = context.provider;
485
+ this.itemContext = context.item;
486
+ this.listContext = context.list;
592
487
  useRefById({
593
- id: this.id,
594
- ref: this.contentRef,
488
+ ...opts,
595
489
  onRefChange: (node) => {
596
- this.item.contentNode = node;
490
+ this.itemContext.contentNode = node;
597
491
  },
598
- deps: () => this.isMounted.current,
492
+ deps: () => this.mounted,
599
493
  });
600
- $effect(() => {
601
- const items = this.menu.getTriggerNodes();
602
- const prev = this.menu.previousValue.current;
603
- const values = items
604
- .map((item) => item.dataset.value)
605
- .filter((v) => Boolean(v));
606
- if (this.menu.dir.current === "rtl")
607
- values.reverse();
608
- const index = values.indexOf(this.menu.value.current);
609
- const prevIndex = values.indexOf(prev ?? "");
610
- const isSelected = this.item.value.current === this.menu.value.current;
611
- const wasSelected = prevIndex === values.indexOf(this.item.value.current);
612
- // We only want to update selected and the last selected content
613
- // this avoids animations being interrupted outside of that range
614
- if (!isSelected && !wasSelected) {
615
- this.motionAttribute = this.prevMotionAttribute;
494
+ }
495
+ onpointerenter = (_) => {
496
+ this.context.onContentEnter();
497
+ };
498
+ onpointerleave = whenMouse(() => {
499
+ this.context.onContentLeave();
500
+ });
501
+ props = $derived.by(() => ({
502
+ id: this.opts.id.current,
503
+ onpointerenter: this.onpointerenter,
504
+ onpointerleave: this.onpointerleave,
505
+ }));
506
+ }
507
+ class NavigationMenuContentImplState {
508
+ opts;
509
+ itemContext;
510
+ context;
511
+ listContext;
512
+ prevMotionAttribute = $state(null);
513
+ motionAttribute = $derived.by(() => {
514
+ const items = this.listContext.listTriggers;
515
+ const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean);
516
+ if (this.context.opts.dir.current === "rtl")
517
+ values.reverse();
518
+ const index = values.indexOf(this.context.opts.value.current);
519
+ const prevIndex = values.indexOf(this.context.opts.previousValue.current);
520
+ const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current;
521
+ const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.value.current);
522
+ // We only want to update selected and the last selected content
523
+ // this avoids animations being interrupted outside of that range
524
+ if (!isSelected && !wasSelected)
525
+ return untrack(() => this.prevMotionAttribute);
526
+ const attribute = (() => {
527
+ // Don't provide a direction on the initial open
528
+ if (index !== prevIndex) {
529
+ // If we're moving to this item from another
530
+ if (isSelected && prevIndex !== -1)
531
+ return index > prevIndex ? "from-end" : "from-start";
532
+ // If we're leaving this item for another
533
+ if (wasSelected && index !== -1)
534
+ return index > prevIndex ? "to-start" : "to-end";
616
535
  }
617
- const attribute = (() => {
618
- // Don't provide a direction on the initial open
619
- if (index !== prevIndex) {
620
- // If we're moving to this item from another
621
- if (isSelected && prevIndex !== -1) {
622
- return index > prevIndex ? "from-end" : "from-start";
623
- }
624
- // If we're leaving this item for another
625
- if (wasSelected && index !== -1) {
626
- return index > prevIndex ? "to-start" : "to-end";
627
- }
536
+ // Otherwise we're entering from closed or leaving the list
537
+ // entirely and should not animate in any direction
538
+ return null;
539
+ })();
540
+ untrack(() => (this.prevMotionAttribute = attribute));
541
+ return attribute;
542
+ });
543
+ constructor(opts, itemContext) {
544
+ this.opts = opts;
545
+ this.itemContext = itemContext;
546
+ this.listContext = itemContext.listContext;
547
+ this.context = itemContext.listContext.context;
548
+ useRefById({
549
+ ...opts,
550
+ deps: () => this.context.opts.value.current,
551
+ });
552
+ watch([
553
+ () => this.itemContext.opts.value.current,
554
+ () => this.itemContext.triggerNode,
555
+ () => this.opts.ref.current,
556
+ ], () => {
557
+ const content = this.opts.ref.current;
558
+ if (!(content && this.context.opts.isRootMenu))
559
+ return;
560
+ const handleClose = () => {
561
+ this.context.onItemDismiss();
562
+ this.itemContext.onRootContentClose();
563
+ if (content.contains(document.activeElement)) {
564
+ this.itemContext.triggerNode?.focus();
628
565
  }
629
- // Otherwise we're entering from closed or leaving the list
630
- // entirely and should not animate in any direction
631
- return null;
632
- })();
633
- this.prevMotionAttribute = attribute;
634
- this.motionAttribute = attribute;
566
+ };
567
+ const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose);
568
+ return () => {
569
+ removeListener();
570
+ };
635
571
  });
636
- this.onFocusOutside = this.onFocusOutside.bind(this);
637
- this.onInteractOutside = this.onInteractOutside.bind(this);
638
- this.onEscapeKeydown = this.onEscapeKeydown.bind(this);
639
- this.onkeydown = this.onkeydown.bind(this);
640
572
  }
641
- onFocusOutside(e) {
642
- this.item.onContentFocusOutside();
573
+ onFocusOutside = (e) => {
574
+ this.itemContext.onContentFocusOutside();
643
575
  const target = e.target;
644
- // only dismiss content when focus moves outside the menu
645
- if (this.menu.root.rootRef.current?.contains(target)) {
576
+ // only dismiss content when focus moves outside of the menu
577
+ if (this.context.opts.rootNavigationMenuRef.current?.contains(target)) {
646
578
  e.preventDefault();
647
- }
648
- else {
649
- this.menu.root.handleClose();
650
- }
651
- }
652
- onInteractOutside(e) {
653
- if (e.defaultPrevented)
654
579
  return;
655
- const target = e.target;
656
- const isTrigger = this.menu.getTriggerNodes().some((node) => node.contains(target));
657
- const isRootViewport = this.menu.isRoot && this.menu.viewportNode?.contains(target);
658
- if (isTrigger || isRootViewport || !this.menu.isRoot) {
659
- e.preventDefault();
660
580
  }
661
- }
662
- onEscapeKeydown = (e) => {
663
- this.menu.root.handleClose();
581
+ this.context.onItemDismiss();
582
+ };
583
+ onInteractOutside = (e) => {
664
584
  const target = e.target;
665
- if (this.contentRef.current?.contains(target)) {
666
- this.item.triggerNode?.focus();
667
- }
668
- this.item.wasEscapeClose = true;
585
+ const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target));
586
+ const isRootViewport = this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target);
587
+ if (isTrigger || isRootViewport || !this.context.opts.isRootMenu)
588
+ e.preventDefault();
669
589
  };
670
- onkeydown(e) {
590
+ onkeydown = (e) => {
591
+ // prevent parent menus handling sub-menu keydown events
592
+ const target = e.target;
593
+ if (!isElement(target))
594
+ return;
595
+ if (target.closest(`[${NAVIGATION_MENU_ATTR}]`) !==
596
+ this.context.opts.rootNavigationMenuRef.current)
597
+ return;
671
598
  const isMetaKey = e.altKey || e.ctrlKey || e.metaKey;
672
599
  const isTabKey = e.key === kbd.TAB && !isMetaKey;
673
600
  const candidates = getTabbableCandidates(e.currentTarget);
@@ -687,63 +614,78 @@ class NavigationMenuContentState {
687
614
  // If we can't focus that means we're at the edges
688
615
  // so focus the proxy and let browser handle
689
616
  // tab/shift+tab keypress on the proxy instead
690
- this.item.focusProxyNode?.focus();
617
+ handleProxyFocus(this.itemContext.focusProxyNode);
691
618
  return;
692
619
  }
693
620
  }
694
- const newSelectedElement = useArrowNavigation(e, document.activeElement, undefined, {
621
+ let activeEl = document.activeElement;
622
+ if (this.itemContext.contentNode) {
623
+ const focusedNode = this.itemContext.contentNode.querySelector("[data-focused]");
624
+ if (focusedNode) {
625
+ activeEl = focusedNode;
626
+ }
627
+ }
628
+ if (activeEl === this.itemContext.triggerNode)
629
+ return;
630
+ const newSelectedElement = useArrowNavigation(e, activeEl, undefined, {
695
631
  itemsArray: candidates,
696
632
  attributeName: `[${NAVIGATION_MENU_LINK_ATTR}]`,
697
633
  loop: false,
698
634
  enableIgnoredElement: true,
699
635
  });
700
636
  newSelectedElement?.focus();
701
- }
637
+ };
638
+ onEscapeKeydown = (_) => {
639
+ this.context.onItemDismiss();
640
+ this.itemContext.triggerNode?.focus();
641
+ // prevent the dropdown from reopening after the escape key has been pressed
642
+ this.itemContext.wasEscapeClose = true;
643
+ };
702
644
  props = $derived.by(() => ({
703
- id: this.id.current,
704
- "aria-labelledby": this.item.triggerNode?.id ?? undefined,
705
- "data-motion": this.motionAttribute,
706
- "data-state": getDataOpenClosed(this.menu.value.current === this.item.value.current),
707
- "data-orientation": getDataOrientation(this.menu.orientation.current),
708
- [NAVIGATION_MENU_CONTENT_ATTR]: "",
709
- style: {
710
- pointerEvents: !this.open && this.menu.isRoot ? "none" : undefined,
711
- },
645
+ id: this.opts.id.current,
646
+ "aria-labelledby": this.itemContext.triggerId,
647
+ "data-motion": this.motionAttribute ?? undefined,
648
+ "data-orientation": getDataOrientation(this.context.opts.orientation.current),
649
+ "data-state": getDataOpenClosed(this.context.opts.value.current === this.itemContext.opts.value.current),
712
650
  onkeydown: this.onkeydown,
651
+ [NAVIGATION_MENU_CONTENT_ATTR]: "",
713
652
  }));
714
653
  }
715
654
  class NavigationMenuViewportState {
716
- id;
717
- menu;
655
+ opts;
656
+ context;
657
+ open = $derived.by(() => !!this.context.opts.value.current);
718
658
  size = $state(null);
719
- open = $derived.by(() => this.menu.value.current !== "");
720
- activeContentValue = $derived.by(() => this.menu.value.current);
721
- viewportRef;
722
- contentNode = $state();
723
- constructor(props, menu) {
724
- this.id = props.id;
725
- this.menu = menu;
726
- this.viewportRef = props.ref;
659
+ contentNode = $state(null);
660
+ viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined));
661
+ viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined));
662
+ activeContentValue = $derived.by(() => this.context.opts.value.current);
663
+ constructor(opts, context) {
664
+ this.opts = opts;
665
+ this.context = context;
727
666
  useRefById({
728
- id: this.id,
729
- ref: this.viewportRef,
667
+ ...opts,
730
668
  onRefChange: (node) => {
731
- this.menu.viewportNode = node;
669
+ this.context.viewportRef.current = node;
732
670
  },
733
671
  deps: () => this.open,
734
672
  });
735
- $effect(() => {
736
- this.open;
737
- this.activeContentValue;
738
- const currentNode = untrack(() => this.viewportRef.current);
739
- if (!currentNode)
740
- return;
673
+ watch([() => this.activeContentValue, () => this.open], () => {
741
674
  afterTick(() => {
742
- const contentNode = currentNode.querySelector("[data-state=open]")
743
- ?.children?.[0];
744
- this.contentNode = contentNode;
675
+ const currNode = this.context.viewportRef.current;
676
+ if (!currNode)
677
+ return;
678
+ const el = currNode.querySelector("[data-state=open]")
679
+ ?.children?.[0] ?? null;
680
+ this.contentNode = el;
745
681
  });
746
682
  });
683
+ /**
684
+ * Update viewport size to match the active content node.
685
+ * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform.
686
+ * For example, if content animates in from `scale(0.5)` the dimensions would be anything
687
+ * from `0.5` to `1` of the intended size.
688
+ */
747
689
  useResizeObserver(() => this.contentNode, () => {
748
690
  if (this.contentNode) {
749
691
  this.size = {
@@ -752,80 +694,75 @@ class NavigationMenuViewportState {
752
694
  };
753
695
  }
754
696
  });
755
- this.onpointerenter = this.onpointerenter.bind(this);
756
- this.onpointerleave = this.onpointerleave.bind(this);
757
- }
758
- onpointerenter(_) {
759
- this.menu.onContentEnter?.();
760
- }
761
- onpointerleave(e) {
762
- if (e.pointerType !== "mouse")
763
- return;
764
- this.menu.onContentLeave?.();
765
697
  }
766
698
  props = $derived.by(() => ({
767
- id: this.id.current,
699
+ id: this.opts.id.current,
768
700
  "data-state": getDataOpenClosed(this.open),
769
- "data-orientation": getDataOrientation(this.menu.orientation.current),
701
+ "data-orientation": getDataOrientation(this.context.opts.orientation.current),
770
702
  style: {
771
- pointerEvents: !this.open && this.menu.isRoot ? "none" : undefined,
772
- "--bits-navigation-menu-viewport-width": this.size
773
- ? `${this.size.width}px`
774
- : undefined,
775
- "--bits-navigation-menu-viewport-height": this.size
776
- ? `${this.size.height}px`
777
- : undefined,
703
+ pointerEvents: !this.open && this.context.opts.isRootMenu ? "none" : undefined,
704
+ "--bits-navigation-menu-viewport-width": this.viewportWidth,
705
+ "--bits-navigation-menu-viewport-height": this.viewportHeight,
778
706
  },
779
- onpointerenter: this.onpointerenter,
780
- onpointerleave: this.onpointerleave,
707
+ onpointerenter: this.context.onContentEnter,
708
+ onpointerleave: this.context.onContentLeave,
781
709
  }));
782
710
  }
711
+ const NavigationMenuProviderContext = new Context("NavigationMenu.Root");
712
+ export const NavigationMenuItemContext = new Context("NavigationMenu.Item");
713
+ const NavigationMenuListContext = new Context("NavigationMenu.List");
714
+ const NavigationMenuContentContext = new Context("NavigationMenu.Content");
783
715
  export function useNavigationMenuRoot(props) {
784
- const rootState = new NavigationMenuRootState(props);
785
- const menuState = new NavigationMenuMenuState({
786
- rootNavigationId: rootState.id,
787
- dir: rootState.dir,
788
- orientation: rootState.orientation,
789
- value: rootState.value,
790
- isRoot: true,
791
- onTriggerEnter: rootState.onTriggerEnter,
792
- onItemSelect: rootState.onItemSelect,
793
- onItemDismiss: rootState.onItemDismiss,
794
- onContentEnter: rootState.onContentEnter,
795
- onContentLeave: rootState.onContentLeave,
796
- onTriggerLeave: rootState.onTriggerLeave,
797
- previousValue: rootState.previousValue,
798
- }, rootState);
799
- NavigationMenuMenuContext.set(menuState);
800
- return NavigationMenuRootContext.set(rootState);
716
+ return new NavigationMenuRootState(props);
717
+ }
718
+ export function useNavigationMenuProvider(props) {
719
+ return NavigationMenuProviderContext.set(new NavigationMenuProviderState(props));
720
+ }
721
+ export function useNavigationMenuSub(props) {
722
+ return new NavigationMenuSubState(props, NavigationMenuProviderContext.get());
801
723
  }
802
724
  export function useNavigationMenuList(props) {
803
- return NavigationMenuListContext.set(new NavigationMenuListState(props, NavigationMenuMenuContext.get()));
725
+ return NavigationMenuListContext.set(new NavigationMenuListState(props, NavigationMenuProviderContext.get()));
804
726
  }
805
727
  export function useNavigationMenuItem(props) {
806
- const listState = NavigationMenuListContext.get();
807
- return NavigationMenuItemContext.set(new NavigationMenuItemState(props, listState, listState.menu));
728
+ return NavigationMenuItemContext.set(new NavigationMenuItemState(props, NavigationMenuListContext.get()));
729
+ }
730
+ export function useNavigationMenuIndicatorImpl(props) {
731
+ return new NavigationMenuIndicatorImplState(props, {
732
+ provider: NavigationMenuProviderContext.get(),
733
+ list: NavigationMenuListContext.get(),
734
+ });
808
735
  }
809
736
  export function useNavigationMenuTrigger(props) {
810
- return new NavigationMenuTriggerState(props, NavigationMenuItemContext.get());
737
+ return new NavigationMenuTriggerState(props, {
738
+ provider: NavigationMenuProviderContext.get(),
739
+ item: NavigationMenuItemContext.get(),
740
+ list: NavigationMenuListContext.get(),
741
+ });
811
742
  }
812
743
  export function useNavigationMenuContent(props) {
813
- return NavigationMenuContentContext.set(new NavigationMenuContentState(props, NavigationMenuItemContext.get()));
744
+ return NavigationMenuContentContext.set(new NavigationMenuContentState(props, {
745
+ provider: NavigationMenuProviderContext.get(),
746
+ item: NavigationMenuItemContext.get(),
747
+ list: NavigationMenuListContext.get(),
748
+ }));
814
749
  }
815
- export function useNavigationMenuViewport(props) {
816
- return new NavigationMenuViewportState(props, NavigationMenuMenuContext.get());
750
+ export function useNavigationMenuLink(props) {
751
+ return new NavigationMenuLinkState(props, {
752
+ provider: NavigationMenuProviderContext.get(),
753
+ item: NavigationMenuItemContext.get(),
754
+ });
817
755
  }
818
- export function useNavigationMenuIndicator(props) {
819
- return new NavigationMenuIndicatorState(props, NavigationMenuMenuContext.get());
756
+ export function useNavigationMenuContentImpl(props, itemState) {
757
+ return new NavigationMenuContentImplState(props, itemState ?? NavigationMenuItemContext.get());
820
758
  }
821
- export function useNavigationMenuLink(props) {
822
- const content = NavigationMenuContentContext.getOr(null);
823
- if (content) {
824
- return new NavigationMenuLinkState(props, content.item, content);
825
- }
826
- return new NavigationMenuLinkState(props, NavigationMenuItemContext.get());
759
+ export function useNavigationMenuViewport(props) {
760
+ return new NavigationMenuViewportState(props, NavigationMenuProviderContext.get());
761
+ }
762
+ export function useNavigationMenuIndicator() {
763
+ return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get());
827
764
  }
828
- /// Utils
765
+ //
829
766
  function focusFirst(candidates) {
830
767
  const previouslyFocusedElement = document.activeElement;
831
768
  return candidates.some((candidate) => {
@@ -848,20 +785,34 @@ function removeFromTabOrder(candidates) {
848
785
  });
849
786
  };
850
787
  }
851
- function useResizeObserver(element, onResize) {
852
- $effect(() => {
853
- let rAF = 0;
854
- const node = element();
855
- if (node) {
856
- const resizeObserver = new ResizeObserver(() => {
857
- cancelAnimationFrame(rAF);
858
- rAF = window.requestAnimationFrame(onResize);
859
- });
860
- resizeObserver.observe(node);
861
- return () => {
862
- window.cancelAnimationFrame(rAF);
863
- resizeObserver.unobserve(node);
864
- };
788
+ function whenMouse(handler) {
789
+ return (e) => (e.pointerType === "mouse" ? handler(e) : undefined);
790
+ }
791
+ /**
792
+ *
793
+ * We apply the `aria-hidden` attribute to elements that should not be visible to screen readers
794
+ * under specific circumstances, mostly when in a "modal" context or when they are strictly for
795
+ * utility purposes, like the focus guards.
796
+ *
797
+ * When these elements receive focus before we can remove the aria-hidden attribute, we need to
798
+ * handle the focus in a way that does not cause an error to be logged.
799
+ *
800
+ * This function handles the focus of the guard element first by momentary removing the
801
+ * `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then
802
+ * restoring the attribute.
803
+ */
804
+ function handleProxyFocus(guard, focusOptions) {
805
+ if (!guard)
806
+ return;
807
+ const ariaHidden = guard.getAttribute("aria-hidden");
808
+ guard.removeAttribute("aria-hidden");
809
+ guard.focus(focusOptions);
810
+ afterSleep(0, () => {
811
+ if (ariaHidden === null) {
812
+ guard.setAttribute("aria-hidden", "");
813
+ }
814
+ else {
815
+ guard.setAttribute("aria-hidden", ariaHidden);
865
816
  }
866
817
  });
867
818
  }