@spaced-out/ui-design-system 0.6.34 → 0.6.35-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.cspell/custom-words.txt +9 -0
  2. package/CHANGELOG.md +7 -0
  3. package/lib/components/Menu/Menu.d.ts +71 -0
  4. package/lib/components/Menu/Menu.d.ts.map +1 -1
  5. package/lib/components/Menu/Menu.js +1028 -185
  6. package/lib/components/Menu/Menu.module.css +148 -27
  7. package/lib/components/Menu/MenuOptionButton.d.ts +23 -2
  8. package/lib/components/Menu/MenuOptionButton.d.ts.map +1 -1
  9. package/lib/components/Menu/MenuOptionButton.js +87 -38
  10. package/lib/components/Menu/index.d.ts +2 -0
  11. package/lib/components/Menu/index.d.ts.map +1 -1
  12. package/lib/components/Menu/index.js +22 -0
  13. package/lib/components/Menu/menuOptionId.d.ts +12 -0
  14. package/lib/components/Menu/menuOptionId.d.ts.map +1 -0
  15. package/lib/components/Menu/menuOptionId.js +18 -0
  16. package/lib/components/Menu/useMenuKeyboardNavigation.d.ts +93 -0
  17. package/lib/components/Menu/useMenuKeyboardNavigation.d.ts.map +1 -0
  18. package/lib/components/Menu/useMenuKeyboardNavigation.js +471 -0
  19. package/lib/components/Menu/useMenuTrigger.d.ts +183 -0
  20. package/lib/components/Menu/useMenuTrigger.d.ts.map +1 -0
  21. package/lib/components/Menu/useMenuTrigger.js +347 -0
  22. package/lib/hooks/index.d.ts +1 -0
  23. package/lib/hooks/index.d.ts.map +1 -1
  24. package/lib/hooks/index.js +11 -0
  25. package/lib/hooks/useInteractionModality/index.d.ts +2 -0
  26. package/lib/hooks/useInteractionModality/index.d.ts.map +1 -0
  27. package/lib/hooks/useInteractionModality/index.js +16 -0
  28. package/lib/hooks/useInteractionModality/useInteractionModality.d.ts +15 -0
  29. package/lib/hooks/useInteractionModality/useInteractionModality.d.ts.map +1 -0
  30. package/lib/hooks/useInteractionModality/useInteractionModality.js +103 -0
  31. package/lib/utils/click-away/click-away.d.ts +3 -0
  32. package/lib/utils/click-away/click-away.d.ts.map +1 -1
  33. package/lib/utils/click-away/click-away.js +49 -0
  34. package/mcp/package.json +1 -1
  35. package/package.json +1 -1
@@ -0,0 +1,471 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useMenuKeyboardNavigation = exports.moveFocusToMenuChrome = exports.getMenuRegionFocusables = void 0;
7
+ var React = _interopRequireWildcard(require("react"));
8
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
9
+ /**
10
+ * Keyboard navigation for the Menu option list. Three interaction models,
11
+ * selected via `mode`:
12
+ *
13
+ * 1. `'roving'` — ROVING FOCUS (default; menu-button popups). Options are native
14
+ * <button>s, so ArrowUp/ArrowDown move real DOM focus between the enabled
15
+ * options and Space/Enter activate the focused option natively. Home/End jump
16
+ * to the first/last option, PageUp/PageDown move by a page, and typing a
17
+ * printable character jumps to the next matching label (type-ahead).
18
+ *
19
+ * 2. `'activedescendant'` — SELECT-ONLY COMBOBOX. DOM focus stays on the
20
+ * combobox input; the "active" option is a virtual highlight reported via
21
+ * `setActiveOptionKey` (surfaced as the input's aria-activedescendant) and
22
+ * NEVER takes real focus. Arrows/Home/End/PageUp/PageDown move the highlight,
23
+ * printable characters type-ahead, and Enter selects the active option.
24
+ *
25
+ * 3. `'search'` — EDITABLE COMBOBOX. Like `'activedescendant'`, but the input is
26
+ * a free-text field: printable keys, Left/Right and Home/End are left to the
27
+ * input for text editing (no type-ahead). Arrows/PageUp/PageDown move the
28
+ * highlight and Enter selects.
29
+ *
30
+ * Navigation wraps around at the ends. Options are discovered from the DOM
31
+ * (`[data-menu-option-key]`) so it always matches what is rendered after search
32
+ * filtering / grouping.
33
+ *
34
+ * Tab / Shift+Tab keep focus moving through the menu's own focus zones in DOM
35
+ * order — header → search → options → footer — and only close the menu (via
36
+ * `onTabOut`) when focus would leave the menu entirely (Tab past the last
37
+ * focusable, Shift+Tab before the first). This keeps focus from silently
38
+ * dropping to <body> when the popup renders in a portal with nothing after it.
39
+ */
40
+
41
+ /** One navigable option, for menus whose option DOM is windowed. */
42
+
43
+ const OPTION_SELECTOR = '[data-menu-option-key]';
44
+ // Elements that can hold a tab stop inside the menu (header/footer controls,
45
+ // the search input, and the single roving option whose tabIndex is 0).
46
+ const TABBABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]';
47
+ const PAGE_SIZE = 10;
48
+ const TYPEAHEAD_RESET_MS = 600;
49
+ // Frames to wait for a windowed list to mount the row we just moved to before
50
+ // giving up on placing real focus on it.
51
+ const MOUNT_RETRY_FRAMES = 3;
52
+ const getEnabledOptionEls = container => {
53
+ if (!container) {
54
+ return [];
55
+ }
56
+ return Array.from(container.querySelectorAll(OPTION_SELECTOR)).filter(el => el.getAttribute('aria-disabled') !== 'true' && !el.disabled);
57
+ };
58
+
59
+ // Enabled + visible. Used for elements we focus PROGRAMMATICALLY (footer/header
60
+ // controls via moveFocusToMenuChrome), so the natural tab order (tabIndex) is
61
+ // irrelevant — el.focus() works regardless. This matters because a combobox
62
+ // menu's footer/header controls are observed to sit at tabIndex -1 while the
63
+ // input holds focus, which a tabIndex-based filter (isTabbable) would wrongly
64
+ // skip.
65
+ const isFocusableControl = el => {
66
+ if (el.disabled) {
67
+ return false;
68
+ }
69
+ if (el.getAttribute('aria-disabled') === 'true') {
70
+ return false;
71
+ }
72
+ // Skip hidden elements (no layout box).
73
+ return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
74
+ };
75
+
76
+ // As above, but also in the natural tab order right now (tabIndex >= 0). Used
77
+ // for the roving boundary check, where focus actually rests on the element.
78
+ const isTabbable = el => el.tabIndex >= 0 && isFocusableControl(el);
79
+
80
+ /**
81
+ * Focusable elements inside the menu, in DOM order. With roving tabindex only
82
+ * the active option is tabbable (tabIndex 0); the others and the menu container
83
+ * are tabIndex -1, so they are excluded — leaving header/footer controls, the
84
+ * search input and the single active option.
85
+ */
86
+ const getTabbables = container => Array.from(container.querySelectorAll(TABBABLE_SELECTOR)).filter(isTabbable);
87
+
88
+ /**
89
+ * Focusable controls inside the menu's sticky header / footer region, in DOM
90
+ * order. Used by combobox triggers (Dropdown / Typeahead) so Tab / Shift+Tab can
91
+ * move focus into the footer / header instead of closing the menu when one
92
+ * holds focusable controls (e.g. Cancel / Save buttons).
93
+ */
94
+ const getMenuRegionFocusables = (menu, region) => {
95
+ const regionEl = menu?.querySelector(`[data-menu-region="${region}"]`);
96
+ if (!regionEl) {
97
+ return [];
98
+ }
99
+ return Array.from(regionEl.querySelectorAll(TABBABLE_SELECTOR)).filter(isFocusableControl);
100
+ };
101
+
102
+ /**
103
+ * Move focus into the menu's footer (Tab) or header (Shift+Tab) edge if that
104
+ * region has focusable controls. Returns true when it moved focus, so a combobox
105
+ * trigger can close the menu only when there was no chrome to land on.
106
+ */
107
+ exports.getMenuRegionFocusables = getMenuRegionFocusables;
108
+ const moveFocusToMenuChrome = (menu, shiftKey) => {
109
+ const focusables = getMenuRegionFocusables(menu, shiftKey ? 'header' : 'footer');
110
+ if (!focusables.length) {
111
+ return false;
112
+ }
113
+ // Tab → first control in the footer; Shift+Tab → last control in the header.
114
+ const target = shiftKey ? focusables[focusables.length - 1] : focusables[0];
115
+ target.focus();
116
+ return true;
117
+ };
118
+ exports.moveFocusToMenuChrome = moveFocusToMenuChrome;
119
+ const isPrintableCharacter = event => event.key.length === 1 && event.key !== ' ' && !event.altKey && !event.ctrlKey && !event.metaKey;
120
+ const useMenuKeyboardNavigation = _ref => {
121
+ let {
122
+ containerRef,
123
+ enabled = true,
124
+ mode = 'roving',
125
+ activeOptionKey,
126
+ onSelectKey,
127
+ setActiveOptionKey,
128
+ onTabOut,
129
+ onReturnFocusToInput,
130
+ options
131
+ } = _ref;
132
+ const typeahead = React.useRef({
133
+ query: '',
134
+ at: 0
135
+ });
136
+ const keyOf = el => el?.getAttribute('data-menu-option-key') ?? null;
137
+
138
+ // Move the highlight without moving focus (combobox / active-descendant model).
139
+ const highlightAt = (els, index) => {
140
+ const el = els[index];
141
+ if (!el) {
142
+ return;
143
+ }
144
+ el.scrollIntoView({
145
+ block: 'nearest'
146
+ });
147
+ setActiveOptionKey(keyOf(el));
148
+ };
149
+
150
+ // Move real DOM focus (roving model).
151
+ const focusAt = (els, index) => {
152
+ const el = els[index];
153
+ if (!el) {
154
+ return;
155
+ }
156
+ el.focus();
157
+ el.scrollIntoView({
158
+ block: 'nearest'
159
+ });
160
+ setActiveOptionKey(keyOf(el));
161
+ };
162
+
163
+ /** Type-ahead: jump to the next option whose label matches the typed text. */
164
+ const typeaheadTo = _ref2 => {
165
+ let {
166
+ count,
167
+ labelAt,
168
+ currentIndex,
169
+ char,
170
+ move
171
+ } = _ref2;
172
+ const now = Date.now();
173
+ const state = typeahead.current;
174
+ if (now - state.at > TYPEAHEAD_RESET_MS) {
175
+ state.query = '';
176
+ }
177
+ state.at = now;
178
+ state.query += char.toLowerCase();
179
+ const isRepeat = state.query.length > 1 && state.query.split('').every(c => c === state.query[0]);
180
+ const query = isRepeat ? state.query[0] : state.query;
181
+ // First keystroke or a repeated character advances past the current option
182
+ // so repeats cycle; accumulating distinct characters refines in place.
183
+ const offset = state.query.length === 1 || isRepeat ? 1 : 0;
184
+ const base = currentIndex < 0 ? -offset : currentIndex;
185
+ for (let i = 0; i < count; i++) {
186
+ const idx = (base + offset + i + count) % count;
187
+ if (labelAt(idx).startsWith(query)) {
188
+ move(idx);
189
+ return;
190
+ }
191
+ }
192
+ };
193
+
194
+ /**
195
+ * Search mode only: a printable key pressed while focus is somewhere in the
196
+ * menu OTHER than the search input (in practice the menu container — the
197
+ * fallback target when every option is disabled) redirects focus to the input
198
+ * and forwards the character, so typing still lands in the search box.
199
+ * Header/footer controls never reach here: onKeyDown's `[data-menu-region]`
200
+ * guard returns before the printable branch when focus is in that chrome.
201
+ */
202
+ const redirectPrintableToInput = event => {
203
+ const container = containerRef.current;
204
+ // Scope to the Menu's OWN search field via the marker it renders, not to a
205
+ // role — a consumer header/footer could contain an unrelated search input,
206
+ // and matching that would replay the user's keystrokes into the wrong field.
207
+ const searchInput = container?.querySelector('input[data-menu-search]');
208
+ if (!searchInput || typeof document === 'undefined' || document.activeElement === searchInput) {
209
+ return false;
210
+ }
211
+ event.preventDefault();
212
+ searchInput.focus();
213
+ searchInput.scrollIntoView({
214
+ block: 'nearest'
215
+ });
216
+ if (typeof window !== 'undefined') {
217
+ const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
218
+ if (nativeSetter) {
219
+ nativeSetter.call(searchInput, searchInput.value + event.key);
220
+ searchInput.dispatchEvent(new Event('input', {
221
+ bubbles: true
222
+ }));
223
+ }
224
+ }
225
+ return true;
226
+ };
227
+
228
+ /**
229
+ * Keep Tab / Shift+Tab inside the menu's focus zones (header → search →
230
+ * options → footer) and close the menu when focus would leave it entirely,
231
+ * instead of letting it fall to <body> below the portal. Internal moves
232
+ * (e.g. option → footer button) fall through to the browser default.
233
+ *
234
+ * Combobox menus (when `onReturnFocusToInput` is set) treat the trigger input
235
+ * — which lives OUTSIDE the menu DOM — as the logical centre of the tab order
236
+ * (header ← input → footer). So Tabbing toward the input (Shift+Tab off the
237
+ * footer's first control, or Tab off the header's last control) returns focus
238
+ * to the input with the menu open, and only the OUTER edges (Tab past the last
239
+ * footer control, Shift+Tab before the first header control) close the menu.
240
+ */
241
+ const handleTab = event => {
242
+ const container = containerRef.current;
243
+ if (!container || !onTabOut) {
244
+ return;
245
+ }
246
+ const focused = typeof document !== 'undefined' ? document.activeElement : null;
247
+ if (onReturnFocusToInput) {
248
+ const footers = getMenuRegionFocusables(container, 'footer');
249
+ const headers = getMenuRegionFocusables(container, 'header');
250
+ // Moving off a region back toward the input (Shift+Tab off the footer's
251
+ // first control, or Tab off the header's last control) returns focus to
252
+ // the input with the menu open — the input is the centre of the tab order.
253
+ const returningToInput = event.shiftKey ? footers.length > 0 && focused === footers[0] : headers.length > 0 && focused === headers[headers.length - 1];
254
+ if (returningToInput) {
255
+ event.preventDefault();
256
+ onReturnFocusToInput();
257
+ return;
258
+ }
259
+ // Anything else (footer/header outer edge, or focus not on a region edge)
260
+ // falls through to the boundary close logic below, so leaving the menu's
261
+ // outer edge closes it and never drops focus to <body>.
262
+ }
263
+ const tabbables = getTabbables(container);
264
+ const index = focused ? tabbables.indexOf(focused) : -1;
265
+ // Focus is inside the menu but not on one of its tab stops — in practice the
266
+ // menu container itself, which is the documented fallback target when every
267
+ // option is disabled or the option list is still empty. Treat it as sitting
268
+ // BEFORE the first tab stop: Tab moves forward into the menu's own stops
269
+ // (header / footer controls) instead of closing and leaving them unreachable,
270
+ // while Shift+Tab keeps moving backwards and falls through to the close
271
+ // below — jumping forward to the last control would reverse the user's
272
+ // direction of travel.
273
+ if (!event.shiftKey && index === -1 && tabbables.length) {
274
+ event.preventDefault();
275
+ tabbables[0].focus();
276
+ return;
277
+ }
278
+ const leavingMenu = event.shiftKey ? index <= 0 : index === tabbables.length - 1;
279
+ if (leavingMenu) {
280
+ // Stop the browser moving focus out (to <body>); close instead so the
281
+ // consumer's ClickAway returns focus to the trigger.
282
+ event.preventDefault();
283
+ onTabOut();
284
+ }
285
+ };
286
+ const onKeyDown = event => {
287
+ // Tab management runs even when option navigation is disabled (external
288
+ // combobox menus) so that, once focus is inside the footer / header, Tabbing
289
+ // off the edge still closes the menu instead of dropping focus to <body>.
290
+ if (event.key === 'Tab') {
291
+ handleTab(event);
292
+ return;
293
+ }
294
+ if (!enabled) {
295
+ return;
296
+ }
297
+ // While focus sits in the header/footer chrome (e.g. after Tabbing to a
298
+ // footer button), the menu's key handler must stay out of the way: arrows
299
+ // must not move or steal the option highlight, and printable keys must not
300
+ // be redirected into the search input. Let the focused control own its keys.
301
+ const focused = typeof document !== 'undefined' ? document.activeElement : null;
302
+ if (focused?.closest('[data-menu-region]')) {
303
+ return;
304
+ }
305
+ const els = getEnabledOptionEls(containerRef.current);
306
+ // A windowed list only has a slice of its rows in the DOM, so the caller
307
+ // supplies the real option list and it wins over what we can see.
308
+ const windowed = Boolean(options?.length);
309
+ const count = windowed ? options.length : els.length;
310
+ if (!count) {
311
+ return;
312
+ }
313
+ const movesFocus = mode === 'roving';
314
+ const hasSearchField = mode === 'search';
315
+ // Home/End (and Cmd+Arrow) belong to the caret only while the caret HAS
316
+ // focus. A searchable menu's virtualized listbox is itself a tab stop; once
317
+ // focus is on the list, those keys move the highlight like any listbox.
318
+ const caretOwnsKeys = hasSearchField && focused instanceof HTMLInputElement && focused.getAttribute('data-menu-search') !== null;
319
+ const labelAt = index => windowed ? (options[index].label ?? '').trim().toLowerCase() : (els[index].textContent ?? '').trim().toLowerCase();
320
+ const moveWindowed = index => {
321
+ const key = options[index].key;
322
+ // Publishing the key is the whole move: the consumer keys its virtual list
323
+ // off `activeOptionKey`, so this both scrolls the row into view and makes
324
+ // aria-activedescendant resolve once that row mounts. Deliberately no
325
+ // scrollIntoView here — that would fight the consumer's own scrolling.
326
+ setActiveOptionKey(key);
327
+ if (!movesFocus) {
328
+ return;
329
+ }
330
+ // Roving mode needs REAL focus, and the row may not be mounted yet when the
331
+ // list is windowed. Retry for a few frames until the consumer's scroll has
332
+ // rendered it, rather than assuming a single frame is enough.
333
+ let attempts = 0;
334
+ const focusRow = () => {
335
+ const el = containerRef.current?.querySelector(`[data-menu-option-key="${CSS.escape(key)}"]`);
336
+ if (el) {
337
+ el.focus({
338
+ preventScroll: true
339
+ });
340
+ return;
341
+ }
342
+ if (attempts < MOUNT_RETRY_FRAMES) {
343
+ attempts += 1;
344
+ requestAnimationFrame(focusRow);
345
+ }
346
+ };
347
+ focusRow();
348
+ };
349
+ const move = windowed ? moveWindowed : movesFocus ? index => focusAt(els, index) : index => highlightAt(els, index);
350
+ const currentIndex = windowed ? activeOptionKey ? options.findIndex(option => option.key === activeOptionKey) : -1 : movesFocus ? els.findIndex(el => typeof document !== 'undefined' && el === document.activeElement) : activeOptionKey ? els.map(el => keyOf(el)).indexOf(activeOptionKey) : -1;
351
+ const fromIndex = currentIndex < 0 ? 0 : currentIndex;
352
+
353
+ // Mac keyboards have no End / Home key — those are Fn+Right / Fn+Left — so
354
+ // Cmd+Down / Cmd+Up stand in for them, which is the "jump to the end"
355
+ // gesture macOS uses everywhere else. Subject to the same search-input
356
+ // exemption as End / Home below, because in a text field the platform binds
357
+ // them to the caret. Option and Control leave the key to the browser
358
+ // instead of being silently treated as an unmodified arrow.
359
+ const jumpsToEdge = event.metaKey && !event.altKey && !event.ctrlKey;
360
+ if ((event.altKey || event.ctrlKey) && !event.metaKey) {
361
+ return;
362
+ }
363
+ switch (event.key) {
364
+ case 'ArrowDown':
365
+ {
366
+ if (jumpsToEdge) {
367
+ if (!caretOwnsKeys) {
368
+ event.preventDefault();
369
+ move(count - 1);
370
+ }
371
+ break;
372
+ }
373
+ // Wraps from the last option back to the first.
374
+ event.preventDefault();
375
+ move(currentIndex < 0 ? 0 : (currentIndex + 1) % count);
376
+ break;
377
+ }
378
+ case 'ArrowUp':
379
+ {
380
+ if (jumpsToEdge) {
381
+ if (!caretOwnsKeys) {
382
+ event.preventDefault();
383
+ move(0);
384
+ }
385
+ break;
386
+ }
387
+ // Wraps from the first option back to the last.
388
+ event.preventDefault();
389
+ move(currentIndex < 0 ? count - 1 : (currentIndex - 1 + count) % count);
390
+ break;
391
+ }
392
+ case 'PageDown':
393
+ {
394
+ event.preventDefault();
395
+ move(Math.min(fromIndex + PAGE_SIZE, count - 1));
396
+ break;
397
+ }
398
+ case 'PageUp':
399
+ {
400
+ event.preventDefault();
401
+ move(Math.max(fromIndex - PAGE_SIZE, 0));
402
+ break;
403
+ }
404
+ case 'Home':
405
+ {
406
+ // Editable (search) input keeps Home/End for text-cursor movement.
407
+ if (!caretOwnsKeys) {
408
+ event.preventDefault();
409
+ move(0);
410
+ }
411
+ break;
412
+ }
413
+ case 'End':
414
+ {
415
+ if (!caretOwnsKeys) {
416
+ event.preventDefault();
417
+ move(count - 1);
418
+ }
419
+ break;
420
+ }
421
+ case ' ':
422
+ {
423
+ // In the text field Space types; on a roving option the <button> handles
424
+ // it natively. With focus on the list itself (the focusable listbox of a
425
+ // searchable menu) Space selects the active option, as it does for Enter.
426
+ if (!movesFocus && !caretOwnsKeys && activeOptionKey && onSelectKey) {
427
+ event.preventDefault();
428
+ onSelectKey(activeOptionKey);
429
+ }
430
+ break;
431
+ }
432
+ case 'Enter':
433
+ {
434
+ // Roving mode lets the focused <button> handle Enter natively. The
435
+ // combobox modes keep focus on the input, so select explicitly.
436
+ if (!movesFocus && activeOptionKey && onSelectKey) {
437
+ event.preventDefault();
438
+ onSelectKey(activeOptionKey);
439
+ }
440
+ break;
441
+ }
442
+ default:
443
+ {
444
+ // Space/Enter in roving mode are handled natively by the focused option.
445
+ if (!isPrintableCharacter(event)) {
446
+ break;
447
+ }
448
+ if (hasSearchField) {
449
+ // Editable input: let the character type into the field (it filters).
450
+ // Only step in when focus is somewhere else in the menu.
451
+ redirectPrintableToInput(event);
452
+ } else {
453
+ // Roving / select-only combobox: printable jumps to a matching label.
454
+ event.preventDefault();
455
+ typeaheadTo({
456
+ count,
457
+ labelAt,
458
+ currentIndex,
459
+ char: event.key,
460
+ move
461
+ });
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ };
467
+ return {
468
+ onKeyDown
469
+ };
470
+ };
471
+ exports.useMenuKeyboardNavigation = useMenuKeyboardNavigation;
@@ -0,0 +1,183 @@
1
+ import * as React from 'react';
2
+ import type { MenuOption, MenuProps } from '../../components/Menu/Menu';
3
+ /**
4
+ * Wires a trigger to the Menu it opens.
5
+ *
6
+ * Three things have to live on the trigger rather than in Menu, because they
7
+ * exist while the menu does NOT: `aria-haspopup`, `aria-expanded="false"`, and
8
+ * the ArrowDown/ArrowUp that opens the popup in the first place. That is the
9
+ * floor — no Menu prop can supply them — so these hooks hand them over as props
10
+ * to spread instead, and own everything else: the ids, the tie between the
11
+ * trigger's name and the listbox, and the ref Menu focuses into on open.
12
+ *
13
+ * `useMenuTrigger` is for triggers whose popup options own focus — a button, a
14
+ * tab overflow chevron, an inline label — so arrows move real DOM focus.
15
+ * `useComboboxMenuTrigger` is for triggers that are text inputs, where focus
16
+ * stays put and the highlight is virtual (`aria-activedescendant`).
17
+ *
18
+ * `getTriggerProps` is a getter rather than a static object because open state
19
+ * belongs to the consumer's `ClickAway`, which exposes it through a render
20
+ * callback — where a hook cannot be called.
21
+ *
22
+ * const menuTrigger = useMenuTrigger({id: props.id, ariaLabel: menu?.ariaLabel});
23
+ * <ClickAway>
24
+ * {({isOpen, onOpen, clickAway}) => (
25
+ * <>
26
+ * <Button {...props} {...menuTrigger.getTriggerProps({isOpen, onOpen})} />
27
+ * {isOpen && <Menu {...menu} {...menuTrigger.menuProps} onTabOut={clickAway} />}
28
+ * </>
29
+ * )}
30
+ * </ClickAway>
31
+ */
32
+ export interface UseMenuTriggerArgs {
33
+ /**
34
+ * The trigger's own `id`, when the consumer supplied one. It is kept — the
35
+ * hook only mints an id when there is none, since the listbox is named after
36
+ * the trigger through it.
37
+ */
38
+ id?: string;
39
+ /** The listbox id, when the consumer set `menu.menuId`. Minted otherwise. */
40
+ menuId?: string;
41
+ /** Literal name for the listbox. Wins over naming it after the trigger. */
42
+ ariaLabel?: string;
43
+ /** Id of the element naming the listbox. Defaults to the trigger itself. */
44
+ ariaLabelledBy?: string;
45
+ }
46
+ /**
47
+ * Generic in the trigger element so the returned handler matches whatever it is
48
+ * spread onto — inferred from the trigger's own `onKeyDown` where it has one.
49
+ */
50
+ export interface GetTriggerPropsArgs<T extends HTMLElement = HTMLElement> {
51
+ /** The consumer's popup state — what ClickAway reports. */
52
+ isOpen: boolean;
53
+ onOpen: () => void;
54
+ /** The trigger's own key handler, if it has one. Runs first. */
55
+ onKeyDown?: React.KeyboardEventHandler<T>;
56
+ }
57
+ export interface MenuTriggerProps<T extends HTMLElement = HTMLElement> {
58
+ id: string;
59
+ 'aria-haspopup': 'listbox';
60
+ 'aria-expanded': boolean;
61
+ 'aria-controls': string | undefined;
62
+ onKeyDown: React.KeyboardEventHandler<T>;
63
+ }
64
+ export interface MenuTriggerMenuProps {
65
+ menuId: string;
66
+ ariaLabelledBy?: string;
67
+ ariaLabel?: string;
68
+ initialFocusRef: React.RefObject<HTMLElement | null>;
69
+ }
70
+ export declare const useMenuTrigger: ({ id, menuId: consumerMenuId, ariaLabel, ariaLabelledBy, }?: UseMenuTriggerArgs) => {
71
+ getTriggerProps: <T extends HTMLElement = HTMLElement>(args: GetTriggerPropsArgs<T>) => MenuTriggerProps<T>;
72
+ menuProps: MenuTriggerMenuProps;
73
+ };
74
+ /**
75
+ * Which of ARIA's two combobox patterns the trigger implements.
76
+ *
77
+ * - `select` — a read-only field standing in for a `<select>`. An option is
78
+ * highlighted as soon as the list opens, Space selects it (the field has no
79
+ * typing role), and Enter or Space opens the list in the first place.
80
+ * - `autocomplete` — an editable field that filters the list as you type.
81
+ * Nothing is highlighted until the user arrows to it (APG calls this manual
82
+ * selection: auto-highlighting would let Enter select something they never
83
+ * chose), Space types a space, and only the arrow keys open the list.
84
+ */
85
+ export type ComboboxPattern = 'select' | 'autocomplete';
86
+ export interface UseComboboxMenuTriggerArgs {
87
+ /** Which pattern this trigger implements. Defaults to `select`. */
88
+ pattern?: ComboboxPattern;
89
+ /**
90
+ * The menu this trigger opens. Read for the option source (unless `options`
91
+ * is given), the selection, `virtualization`, `menuDisabled`, `resolveLabel`,
92
+ * `allowSearch` and the consumer's own `menuId` / `ariaLabel` /
93
+ * `ariaLabelledBy`, all of which win over what the hook would derive.
94
+ */
95
+ menu?: MenuProps;
96
+ /**
97
+ * Option source, when it is not `menu` — a consumer that owns filtering and
98
+ * hands the already-filtered options to Menu supplies them here instead. Every
99
+ * derivation (navigation list, highlight seed, selection lookup) uses it.
100
+ */
101
+ options?: Array<MenuOption>;
102
+ /**
103
+ * Refuse to open, e.g. until enough has been typed. Consulted only for the
104
+ * keys that would otherwise open the list.
105
+ */
106
+ canOpen?: () => boolean;
107
+ /** Ref on the rendered Menu, for reaching its header/footer controls. */
108
+ menuRef: React.RefObject<HTMLElement | null>;
109
+ /**
110
+ * The consumer's popup state — what ClickAway reports. Drives which keys the
111
+ * trigger handles (open-state navigation vs. closed-state opening) and the
112
+ * highlight's lifecycle. If the listbox can be WITHHELD while the popup is
113
+ * open (still loading, no matches), keep passing the open state here and hand
114
+ * `isExpanded` to `getTriggerProps` for the ARIA — routing keys off the
115
+ * rendered state instead would make an arrow key call `onOpen` on an
116
+ * already-open popup, and `onOpen` toggles.
117
+ */
118
+ isOpen: boolean;
119
+ /** Apply a selection. Called with the option the keyboard acted on. */
120
+ onSelectOption: (option: MenuOption) => void;
121
+ /** Close the popup — used when Tab leaves a menu with no chrome to land on. */
122
+ onClose: () => void;
123
+ /** Put focus back on the trigger input, keeping the menu open. */
124
+ focusTrigger: () => void;
125
+ /** What already labels the trigger, used to name the listbox after it. */
126
+ triggerAriaLabelledBy?: string;
127
+ triggerAriaLabel?: string;
128
+ /**
129
+ * Literal name for the listbox when nothing else supplies one. Defaults to
130
+ * `'Options'`; a suggestion list wants something truer to what it holds.
131
+ */
132
+ defaultAriaLabel?: string;
133
+ }
134
+ export interface GetComboboxTriggerPropsArgs<T extends HTMLElement = HTMLElement> extends GetTriggerPropsArgs<T> {
135
+ /**
136
+ * Whether the listbox is actually rendered right now, when that can differ
137
+ * from `isOpen` (a popup withheld while loading or with no matches). Drives
138
+ * `aria-expanded`, `aria-controls` and `aria-activedescendant`, so the trigger
139
+ * never claims an expanded listbox that is not in the DOM. Defaults to
140
+ * `isOpen`.
141
+ */
142
+ isExpanded?: boolean;
143
+ }
144
+ export interface ComboboxTriggerProps<T extends HTMLElement = HTMLElement> {
145
+ role: 'combobox';
146
+ 'aria-haspopup': 'listbox';
147
+ 'aria-expanded': boolean;
148
+ 'aria-controls': string | undefined;
149
+ 'aria-autocomplete': 'none' | 'list';
150
+ 'aria-activedescendant': string | undefined;
151
+ onKeyDown: React.KeyboardEventHandler<T>;
152
+ }
153
+ export interface ComboboxMenuProps {
154
+ menuId: string;
155
+ ariaLabelledBy?: string;
156
+ ariaLabel?: string;
157
+ externalKeyboardNav: boolean;
158
+ activeOptionKey?: string | null;
159
+ onActiveOptionKeyChange?: (key: string | null) => void;
160
+ onReturnFocusToInput?: () => void;
161
+ initialFocusRef?: React.RefObject<HTMLElement | null>;
162
+ }
163
+ /**
164
+ * Wires a COMBOBOX trigger — a text input — to the Menu it opens. Focus stays
165
+ * on the input and the highlight is virtual, reported through
166
+ * `aria-activedescendant`, because the input has to keep receiving the keys it
167
+ * filters with.
168
+ *
169
+ * Driving the highlight from outside Menu means owning the active option as
170
+ * state, and owning it means deriving the same option list, initial highlight
171
+ * and navigation list that Menu derives internally. Both combobox consumers had
172
+ * grown their own copies of all three; they come from Menu itself now.
173
+ *
174
+ * A menu that carries its own filter field (`menu.allowSearch`) inverts the
175
+ * model: navigation lives inside Menu, so the trigger stops driving it and
176
+ * hands over an `initialFocusRef` instead. Both shapes come out of this hook.
177
+ */
178
+ export declare const useComboboxMenuTrigger: ({ pattern, menu, options, canOpen, menuRef, isOpen, onSelectOption, onClose, focusTrigger, triggerAriaLabelledBy, triggerAriaLabel, defaultAriaLabel, }: UseComboboxMenuTriggerArgs) => {
179
+ getTriggerProps: <T extends HTMLElement = HTMLElement>(args: GetComboboxTriggerPropsArgs<T>) => ComboboxTriggerProps<T>;
180
+ menuProps: ComboboxMenuProps;
181
+ activeOptionKey: string | null;
182
+ };
183
+ //# sourceMappingURL=useMenuTrigger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMenuTrigger.d.ts","sourceRoot":"","sources":["../../../src/components/Menu/useMenuTrigger.ts"],"names":[],"mappings":"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"}