@uniflowed/ui 0.0.0-alpha.9 → 0.2.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 (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
package/combobox.js CHANGED
@@ -49,6 +49,69 @@
49
49
  // the active option is cleared when the option it named is filtered away, the
50
50
  // count is remeasured, and `aria-activedescendant` never names an id that has
51
51
  // left the document.
52
+ //
53
+ // # Groups, and the two elements that had to change to have them
54
+ //
55
+ // A `listbox` may own `option` and `group` elements, and nothing else. This
56
+ // module rendered a `<ul>` of `<li>`s, which is the right shape for a flat list
57
+ // and the wrong one the moment a group appears: a group's options belong inside
58
+ // the group, a group inside a `<ul>` is an `<li>`, and an `<li>` inside an
59
+ // `<li>` is not something HTML has. The parser closes the outer one, so the
60
+ // markup a server sent and the tree a browser built would disagree — which
61
+ // React finds at hydration, in production, on the one page that had groups.
62
+ //
63
+ // The way out that keeps the list is a second `<ul role="presentation">` around
64
+ // each group's options, and it was rejected twice over. It works by an
65
+ // inheritance rule — a presentational role propagating to the elements its own
66
+ // role requires, except where a child carries an explicit role — which is
67
+ // correct in the specification and up to the software, and this package's whole
68
+ // premise is not building on that distinction. It would also leave the two
69
+ // halves of one pattern with two differently shaped listboxes, for a reason
70
+ // neither module could state.
71
+ //
72
+ // So `Combobox.List` and `Combobox.Option` are `div`s, exactly as `select.js`'s
73
+ // are and for the reason its header already gives at length. That is a change
74
+ // to what this component renders, and a caller whose stylesheet names `ul` or
75
+ // `li` will see it; nothing else moved, because the roles were always the part
76
+ // that carried the meaning.
77
+ //
78
+ // `Combobox.Group` and `Combobox.GroupLabel` are then `Select.Group` and
79
+ // `Select.GroupLabel`. The second name is deliberate rather than clumsy:
80
+ // `Combobox.Label` already means the *field's* label, so the heading over a
81
+ // group of options cannot also be `Combobox.Label`, and shadcn's single
82
+ // `SelectLabel` — which is the group's — has no name left for the field's.
83
+ //
84
+ // There is no `Combobox.Separator`, and that is the same decision `select.js`
85
+ // made about the tree rather than a different one about the part. A rule
86
+ // between two groups of options cannot be a `role="separator"`, because a
87
+ // listbox may not own one; it is `aria-hidden` decoration, and a
88
+ // `<div aria-hidden="true">` is something a caller writes without needing a
89
+ // part for it. `Select.Separator` exists because a select's options are a fixed
90
+ // list somebody wrote out and the rule between two of them is fixed too. A
91
+ // combobox's options are whatever survived the filter, so a rule that stays put
92
+ // while the groups either side of it disappear is decoration in the wrong
93
+ // place, and the caller who filtered is the one who knows where it goes.
94
+ //
95
+ // # A command palette is a composition, not a seventh module
96
+ //
97
+ // `crates/uf_lib/src/ui.rs` lists a `Command` with `Root`, `Input`, `List`,
98
+ // `Item`, `Group` and `Empty`, and with groups here every one of those parts
99
+ // now exists: a palette is a `Combobox` inside a `Dialog`, opened by
100
+ // `useKeyCombo("mod+k", …)` from `@uniflowed/hooks/keyboard`, with
101
+ // `Combobox.Group` for the sections, `Combobox.Empty` for the no-results state
102
+ // and `Combobox.Status` for the count. `ubugeeei-redundancy.md`'s objection to
103
+ // small lookalikes is an objection to shipping a module whose entire content is
104
+ // a composition the reader could have written, so the answer is the
105
+ // documentation page — `docs/app/reference/ui`, under "A command palette" —
106
+ // and not a seventh module.
107
+ //
108
+ // One behaviour a `Command` module would genuinely add is not in that page,
109
+ // because it is not implemented anywhere: a palette whose filter matched
110
+ // nothing still traps focus, so `Tab` cycles between a text field and a close
111
+ // button while the reader is told there are no results. That is `Dialog`'s
112
+ // question rather than this module's — a modal with nothing in it to reach is
113
+ // the general case — and it is left open on purpose rather than answered here
114
+ // by a component that would only look like it had.
52
115
 
53
116
  "use client";
54
117
 
@@ -64,12 +127,18 @@ import {
64
127
  } from "@uniflowed/react";
65
128
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
66
129
 
130
+ import { useInteractOutside } from "./interactions.js";
131
+
132
+ import type { Align, LogicalSide } from "./internal/anchor.js";
133
+ import { useAnchor } from "./internal/anchor.js";
67
134
  import type { Rest } from "./internal/merge-props.js";
68
135
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
69
136
  import { itemsOf, moveTo } from "./internal/roving-focus.js";
70
137
  import { useControlled } from "./internal/controlled-state.js";
71
138
  import { FormValue } from "./internal/form-value.js";
72
139
 
140
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
141
+
73
142
  const OPTION_SELECTOR = '[role="option"]';
74
143
  const LISTBOX_SELECTOR = '[role="listbox"]';
75
144
 
@@ -96,7 +165,7 @@ type ComboboxState = {|
96
165
  * and the list does not exist to be measured until the next commit. A ref
97
166
  * rather than state because nothing renders it.
98
167
  */
99
- readonly pendingActive: { current: "first" | "last" | null },
168
+ readonly pendingActiveRef: { current: "first" | "last" | null },
100
169
  readonly inputRef: { current: HTMLElement | null },
101
170
  readonly listRef: { current: HTMLElement | null },
102
171
  /** How many options are in the list, for the live region. */
@@ -116,6 +185,14 @@ hook useCombobox(part: string): ComboboxState {
116
185
  return state;
117
186
  }
118
187
 
188
+ /** The id of a group's label, so `Combobox.Group` only claims one that exists. */
189
+ type ComboboxGroupState = {|
190
+ readonly labelId: string,
191
+ readonly registerLabel: (present: boolean) => void,
192
+ |};
193
+
194
+ const ComboboxGroupContext: React.Context<ComboboxGroupState | null> = createContext(null);
195
+
119
196
  /**
120
197
  * The combobox.
121
198
  *
@@ -154,7 +231,7 @@ export component ComboboxRoot(
154
231
  const [activeId, setActiveId] = useState<string | null>(null);
155
232
  const [count, setCount] = useState(0);
156
233
  const [labelled, setLabelled] = useState(false);
157
- const pendingActive = useRef<"first" | "last" | null>(null);
234
+ const pendingActiveRef = useRef<"first" | "last" | null>(null);
158
235
  const inputRef = useRef<HTMLElement | null>(null);
159
236
  const listRef = useRef<HTMLElement | null>(null);
160
237
 
@@ -188,7 +265,7 @@ export component ComboboxRoot(
188
265
  clear,
189
266
  activeId,
190
267
  setActiveId,
191
- pendingActive,
268
+ pendingActiveRef,
192
269
  inputRef,
193
270
  listRef,
194
271
  count,
@@ -235,6 +312,8 @@ export component ComboboxLabel(children: React.Node, ...rest: Rest) {
235
312
  /** The text field, and every key the pattern defines. */
236
313
  export component ComboboxInput(...rest: Rest) {
237
314
  const combobox = useCombobox("Combobox.Input");
315
+ // `rest` filtering is render-time props work; ref objects are only passed through later.
316
+ // uf-lint-disable-next-line react-compiler/refs
238
317
  const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
239
318
 
240
319
  /** The options in the document right now, in document order. */
@@ -248,7 +327,8 @@ export component ComboboxInput(...rest: Rest) {
248
327
  if (items.length === 0) {
249
328
  // The list is not in the document yet, so leave an instruction for the
250
329
  // commit that puts it there.
251
- combobox.pendingActive.current = movement === "next" ? "first" : "last";
330
+ // uf-lint-disable-next-line react-compiler/immutability
331
+ combobox.pendingActiveRef.current = movement === "next" ? "first" : "last";
252
332
  return;
253
333
  }
254
334
  const at = items.findIndex((item) => item.id === combobox.activeId);
@@ -281,6 +361,8 @@ export component ComboboxInput(...rest: Rest) {
281
361
  // The browser's own dropdown would sit on top of this one.
282
362
  autoComplete="off"
283
363
  id={`${combobox.base}-input`}
364
+ // Input events read list refs and update the virtual active descendant.
365
+ // uf-lint-disable-next-line react-compiler/refs
284
366
  onChange={composeHandlers(rest.onChange, (event: $FlowFixMe) => {
285
367
  combobox.setText(event.target.value);
286
368
  combobox.setOpen(true);
@@ -289,6 +371,8 @@ export component ComboboxInput(...rest: Rest) {
289
371
  // Enter takes something the reader can no longer see.
290
372
  combobox.setActiveId(null);
291
373
  })}
374
+ // Key events read list refs and update the virtual active descendant.
375
+ // uf-lint-disable-next-line react-compiler/refs
292
376
  onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
293
377
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
294
378
  event.preventDefault();
@@ -334,7 +418,10 @@ export component ComboboxInput(...rest: Rest) {
334
418
  combobox.setActiveId(null);
335
419
  }
336
420
  })}
421
+ // React calls callback refs during commit; keyboard handlers read the input later.
422
+ // uf-lint-disable-next-line react-compiler/refs
337
423
  ref={composeRefs(rest.ref, (element) => {
424
+ // uf-lint-disable-next-line react-compiler/immutability
338
425
  combobox.inputRef.current = element;
339
426
  })}
340
427
  role="combobox"
@@ -347,23 +434,60 @@ export component ComboboxInput(...rest: Rest) {
347
434
  /**
348
435
  * The list of options, in the document only while it is open.
349
436
  *
437
+ * A `div` rather than the `ul` this was, because a listbox that owns groups
438
+ * cannot be a list without a second `list` role between a group and the options
439
+ * it holds. The module header has the argument and what it costs a caller.
440
+ *
350
441
  * It also keeps the two things that have to stay true as the caller filters:
351
442
  * the count the live region announces, and the invariant that
352
443
  * `aria-activedescendant` never names an option that has left the list.
353
444
  */
354
- export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest) {
445
+ export component ComboboxList(
446
+ children: renders* (ComboboxOption | ComboboxGroup),
447
+ align?: Align = "start",
448
+ alignOffset?: number = 0,
449
+ avoidCollisions?: boolean = true,
450
+ collisionPadding?: number = 0,
451
+ side?: LogicalSide = "bottom",
452
+ sideOffset?: number = 0,
453
+ ...rest: Rest
454
+ ) {
355
455
  const combobox = useCombobox("Combobox.List");
356
- const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
456
+ const { activeId, count, listRef, inputRef, pendingActiveRef, setActiveId, setCount } = combobox;
357
457
  const close = useStableCallback(() => {
358
458
  combobox.setOpen(false);
359
459
  combobox.setActiveId(null);
360
460
  });
361
461
 
462
+ // Anchored to the *field*, not to a wrapper the caller may not have written.
463
+ // `align="start"` because a list of options belongs under the edge the text
464
+ // starts at, and `--uf-anchor-trigger-width` is what a stylesheet reads to
465
+ // make it exactly as wide as the field.
466
+ // useAnchor accepts ref objects and reads them from layout/effects.
467
+ // uf-lint-disable-next-line react-compiler/refs
468
+ const anchored = useAnchor({
469
+ align,
470
+ alignOffset,
471
+ // uf-lint-disable-next-line react-compiler/refs
472
+ anchorRef: inputRef,
473
+ avoidCollisions,
474
+ collisionPadding,
475
+ // `open` is combobox metadata; no ref value is read during render.
476
+ // uf-lint-disable-next-line react-compiler/refs
477
+ open: combobox.open,
478
+ // uf-lint-disable-next-line react-compiler/refs
479
+ overlayRef: listRef,
480
+ side,
481
+ sideOffset,
482
+ });
483
+
362
484
  // No dependency list on purpose: what this reads is the *rendered* options,
363
485
  // and they change whenever the caller re-filters — which is a change to
364
486
  // `children` that no dependency list can describe. Every write below is
365
487
  // guarded by a comparison, so the effect settles after one extra pass rather
366
488
  // than looping.
489
+ // This effect measures caller-rendered options after commit.
490
+ // uf-lint-disable-next-line react-compiler/immutability
367
491
  useEffect(() => {
368
492
  const list = listRef.current;
369
493
  if (list == null) {
@@ -379,9 +503,10 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
379
503
  setCount(items.length);
380
504
  }
381
505
 
382
- const wanted = pendingActive.current;
506
+ const wanted = pendingActiveRef.current;
383
507
  if (wanted != null) {
384
- pendingActive.current = null;
508
+ // uf-lint-disable-next-line react-compiler/immutability
509
+ pendingActiveRef.current = null;
385
510
  setActiveId(moveTo(items, -1, wanted, false)?.id ?? null);
386
511
  return;
387
512
  }
@@ -392,33 +517,18 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
392
517
  }
393
518
  });
394
519
 
395
- // Keyed on `combobox.open`, and that is load-bearing. This component is
396
- // mounted the whole time and only *renders* while the list is open, so keyed
397
- // on the stable callbacks alone the effect ran once — on the first commit,
398
- // when `listRef.current` was still null — and never again. The listener was
399
- // never attached, and a press outside the combobox closed nothing.
400
- useEffect(() => {
401
- const list = listRef.current;
402
- if (list == null) {
403
- return;
404
- }
405
- const document = list.ownerDocument;
406
- const onOutsidePress = (event: Event) => {
407
- const target: $FlowFixMe = event.target;
408
- if (target == null || list.contains(target)) {
409
- return;
410
- }
411
- // The field is not "outside": pressing it is how a reader gets back to
412
- // typing, and closing on it would fight the input's own handlers.
413
- const input = inputRef.current;
414
- if (input != null && input.contains(target)) {
415
- return;
416
- }
417
- close();
418
- };
419
- document.addEventListener("pointerdown", onOutsidePress, true);
420
- return () => document.removeEventListener("pointerdown", onOutsidePress, true);
421
- }, [combobox.open, close, listRef, inputRef]);
520
+ // The field is not "outside": pressing it is how a reader gets back to
521
+ // typing, and closing on it would fight the input's own handlers.
522
+ //
523
+ // The refs are read when a press arrives rather than when the listener is
524
+ // attached. This component is mounted the whole time and only *renders*
525
+ // while the list is open, and the listener that was attached on the first
526
+ // commit — when `listRef.current` was still null — closed nothing at all.
527
+ useInteractOutside({
528
+ isDisabled: !combobox.open,
529
+ onInteractOutside: () => close(),
530
+ refs: [listRef, inputRef],
531
+ });
422
532
 
423
533
  if (!combobox.open) {
424
534
  return null;
@@ -427,9 +537,11 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
427
537
  const passed = withoutComposed(rest, ["ref"]);
428
538
 
429
539
  return (
430
- <ul
540
+ <div
431
541
  {...passed}
432
542
  aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
543
+ data-align={anchored.align}
544
+ data-side={anchored.side}
433
545
  id={`${combobox.base}-list`}
434
546
  ref={composeRefs(rest.ref, (element) => {
435
547
  listRef.current = element;
@@ -437,7 +549,7 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
437
549
  role="listbox"
438
550
  >
439
551
  {children}
440
- </ul>
552
+ </div>
441
553
  );
442
554
  }
443
555
 
@@ -463,7 +575,7 @@ export component ComboboxOption(
463
575
  const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
464
576
 
465
577
  return (
466
- <li
578
+ <div
467
579
  {...passed}
468
580
  aria-disabled={disabled ? "true" : undefined}
469
581
  aria-selected={combobox.value === value ? "true" : "false"}
@@ -495,7 +607,72 @@ export component ComboboxOption(
495
607
  role="option"
496
608
  >
497
609
  {children}
498
- </li>
610
+ </div>
611
+ );
612
+ }
613
+
614
+ /**
615
+ * A named group of options.
616
+ *
617
+ * The name reaches the group through `aria-labelledby`, and only while a
618
+ * `Combobox.GroupLabel` is rendered — the same rule, and the same reason, as
619
+ * `Select.Group` and `Menu.Group` before it.
620
+ *
621
+ * Nothing about `Combobox.Input` had to learn that groups exist. It asks for
622
+ * `[role="option"]` elements whose nearest `[role="listbox"]` is this list, and
623
+ * a group is not a listbox — so the arrow keys walk an option at a time across
624
+ * a boundary they cannot see, and the heading is never a place the cursor can
625
+ * land, because it is not an option.
626
+ *
627
+ * `children` is the true statement rather than a `React.Node` that would take
628
+ * anything: a `group` inside a `listbox` may own options and its own heading,
629
+ * and nothing else. `Select.Group` says the same since ubugeeei-prod/uf#562 —
630
+ * it is the same listbox, and it took a second breaking change to get there.
631
+ */
632
+ export component ComboboxGroup(
633
+ children: renders* (ComboboxOption | ComboboxGroupLabel),
634
+ ...rest: Rest
635
+ ) {
636
+ const base = useId();
637
+ const [labelled, setLabelled] = useState(false);
638
+
639
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
640
+
641
+ return (
642
+ <ComboboxGroupContext.Provider value={group}>
643
+ <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
644
+ {children}
645
+ </div>
646
+ </ComboboxGroupContext.Provider>
647
+ );
648
+ }
649
+
650
+ /**
651
+ * The heading of a `Combobox.Group`.
652
+ *
653
+ * `role="presentation"` because the group already carries the name: left as
654
+ * ordinary content a reader would hear the heading once as the group's name and
655
+ * again as a stray line of text among the options.
656
+ *
657
+ * This is not `Combobox.Label`. That one names the field; this one names a
658
+ * group of options, and a combobox with groups has both.
659
+ */
660
+ export component ComboboxGroupLabel(children: React.Node, ...rest: Rest) {
661
+ const group = useContext(ComboboxGroupContext);
662
+ const register = group?.registerLabel;
663
+
664
+ useEffect(() => {
665
+ if (register == null) {
666
+ return;
667
+ }
668
+ register(true);
669
+ return () => register(false);
670
+ }, [register]);
671
+
672
+ return (
673
+ <div {...rest} id={group?.labelId} role="presentation">
674
+ {children}
675
+ </div>
499
676
  );
500
677
  }
501
678
 
@@ -0,0 +1,215 @@
1
+ // @flow
2
+ //
3
+ // The same menu, opened by the right button.
4
+ //
5
+ // Everything below the trigger is `menu.js` — the arrow keys, typeahead,
6
+ // submenus, `Escape` stacking, the roving tab stop and the two checkable item
7
+ // kinds — because a context menu *is* a menu and a second implementation of one
8
+ // would be a second set of keyboard bugs. What is here is the two things that
9
+ // make it a component rather than an `oncontextmenu` handler, and both of them
10
+ // are the parts people leave out.
11
+ //
12
+ // # It has to be reachable from the keyboard
13
+ //
14
+ // `Shift+F10` and the `ContextMenu` key open a context menu, on every platform,
15
+ // and a component that only listens for `contextmenu` is a WCAG 2.1.1 failure:
16
+ // the commands in it are reachable by pointer and by nothing else. Long press
17
+ // is the touch equivalent of the same gesture, and `@uniflowed/hooks/dom`'s
18
+ // `useLongPress` already knows what a long press is — including that a press
19
+ // that moves is a drag and not a press.
20
+ //
21
+ // The trigger is therefore focusable. That is a real cost and it is stated
22
+ // rather than hidden: a list of two hundred rows with a context menu on each is
23
+ // two hundred tab stops. A caller whose trigger already *contains* something
24
+ // focusable should pass `tabIndex={-1}` and let the keys arrive from inside it,
25
+ // which they do — the handler is on the trigger and the event bubbles. What is
26
+ // not on offer is leaving the keys out, because the alternative to a tab stop
27
+ // is a command a keyboard cannot reach.
28
+ //
29
+ // # It opens at a point, and sometimes at an element
30
+ //
31
+ // A context menu opened by the pointer belongs at the pointer — the reader is
32
+ // looking at their cursor, and a menu that appeared against the top-left corner
33
+ // of a table row is a menu they have to go and find. Opened by the keyboard
34
+ // there is no pointer, and the menu belongs against the element that has focus.
35
+ //
36
+ // So the anchor is a rectangle rather than an element, and
37
+ // `internal/anchor.js`'s `anchorRect` is the seam: the trigger element is still
38
+ // what the writing direction is read from and what focus goes back to, and only
39
+ // the *measurement* is replaced. `null` — which is what the keyboard path
40
+ // leaves behind — measures the trigger, so both routes end in one code path
41
+ // rather than two placements that drift.
42
+ //
43
+ // # The body is not named after the trigger
44
+ //
45
+ // `Menu.Body` names itself with `aria-labelledby` pointing at its trigger,
46
+ // because a dropdown menu's trigger is a button with a short label — "File" —
47
+ // and that is the menu's name. A context menu's trigger is arbitrary content: a
48
+ // table row, a canvas, a paragraph. Naming the menu after it would announce the
49
+ // whole row as the menu's name. So `ContextMenu.Trigger` registers itself as
50
+ // the thing focus returns to and *not* as a name, and the caller gives
51
+ // `ContextMenu.Body` an `aria-label`. That is the one attribute this component
52
+ // cannot supply and the reference page says so.
53
+
54
+ "use client";
55
+
56
+ import * as React from "@uniflowed/react";
57
+ import { useCallback, useContext, useMemo, useRef, useState } from "@uniflowed/react";
58
+ import { useLongPress } from "@uniflowed/hooks/dom";
59
+
60
+ import type { Rect } from "./internal/anchor.js";
61
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
62
+ import {
63
+ composeHandlers,
64
+ composeRefs,
65
+ withProps,
66
+ withoutComposed,
67
+ } from "./internal/merge-props.js";
68
+ import { MenuAnchorContext, MenuContext, MenuLevel, useMenu } from "./internal/menu-tree.js";
69
+
70
+ /**
71
+ * Where the pointer was, or nothing when the keyboard opened the menu.
72
+ *
73
+ * Held by the root rather than by the trigger because the *body* is what reads
74
+ * it, and the body is a sibling of the trigger rather than a child of it.
75
+ */
76
+ type PointState = {|
77
+ readonly point: Rect | null,
78
+ readonly openAt: (point: Rect | null) => void,
79
+ |};
80
+
81
+ const PointContext: React.Context<PointState | null> = React.createContext(null);
82
+
83
+ /** A zero-sized box at a pointer's coordinates, which is what a point is. */
84
+ function pointAt(x: number, y: number): Rect {
85
+ return { height: 0, width: 0, x, y };
86
+ }
87
+
88
+ /**
89
+ * The trigger, the menu, and where the pointer was when it opened.
90
+ *
91
+ * Renders no element of its own, for the reason `Menu.Root` gives: the trigger
92
+ * and the body are siblings in whatever layout the caller wrote.
93
+ */
94
+ export component ContextMenuRoot(
95
+ children: React.Node,
96
+ defaultOpen?: boolean = false,
97
+ open?: boolean,
98
+ onOpenChange?: (open: boolean) => void,
99
+ ) {
100
+ // State rather than a ref, and that is load-bearing: the rectangle is one of
101
+ // the things the placement effect re-runs for, so a second right-click
102
+ // somewhere else has to be a new value React has committed rather than a
103
+ // mutation nothing heard about.
104
+ const [point, setPoint] = useState<Rect | null>(null);
105
+ const openAt = useCallback((next: Rect | null) => setPoint(next), []);
106
+ const state = useMemo(() => ({ point, openAt }), [point, openAt]);
107
+
108
+ return (
109
+ <PointContext.Provider value={state}>
110
+ <MenuAnchorContext.Provider value={point}>
111
+ <MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={null}>
112
+ {children}
113
+ </MenuLevel>
114
+ </MenuAnchorContext.Provider>
115
+ </PointContext.Provider>
116
+ );
117
+ }
118
+
119
+ hook usePoint(part: string): PointState {
120
+ const state = useContext(PointContext);
121
+ if (state == null) {
122
+ throw new Error(`${part} must be rendered inside a ContextMenu.Root`);
123
+ }
124
+ return state;
125
+ }
126
+
127
+ /**
128
+ * The content the menu belongs to.
129
+ *
130
+ * A `<div>` rather than a button, because what a context menu hangs off is a
131
+ * region of the page. The module header says why it is in the tab order and
132
+ * when a caller should take it out again.
133
+ */
134
+ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
135
+ const menu = useMenu("ContextMenu.Trigger");
136
+ const { openAt } = usePoint("ContextMenu.Trigger");
137
+ const triggerRef = useRef<HTMLElement | null>(null);
138
+ const passed = withoutComposed(rest, ["onContextMenu", "onKeyDown", "ref"]);
139
+
140
+ const openHere = useCallback(() => {
141
+ // No point: the menu goes against the element, which is where the reader's
142
+ // focus already is.
143
+ openAt(null);
144
+ // This is an instruction for the menu body after the opening commit.
145
+ // uf-lint-disable-next-line react-compiler/immutability
146
+ menu.pendingFocusRef.current = "first";
147
+ menu.setOpen(true);
148
+ }, [menu, openAt]);
149
+
150
+ // The touch equivalent of the right button. `useLongPress` cancels itself
151
+ // when the pointer moves, so a drag across a list is not two hundred menus.
152
+ useLongPress(triggerRef, (event: Event) => {
153
+ const pointer: $FlowFixMe = event;
154
+ openAt(pointAt(pointer.clientX ?? 0, pointer.clientY ?? 0));
155
+ // This is an instruction for the menu body after the opening commit.
156
+ // uf-lint-disable-next-line react-compiler/immutability
157
+ menu.pendingFocusRef.current = "first";
158
+ menu.setOpen(true);
159
+ });
160
+
161
+ const props = withProps(
162
+ // The `tabIndex` goes *underneath* the caller's props, alone, because it is
163
+ // the one attribute here a caller is invited to overrule: the module header
164
+ // promises `tabIndex={-1}` to a caller whose trigger already contains
165
+ // something focusable, and a value that won over the caller's would be a
166
+ // documented escape hatch that does nothing. Everything in the second
167
+ // argument is this component's own and stays on top.
168
+ withProps({ tabIndex: 0 }, passed),
169
+ {
170
+ "aria-haspopup": "menu",
171
+ children,
172
+ id: `${menu.base}-trigger`,
173
+ onContextMenu: composeHandlers(rest.onContextMenu, (event: PartEvent) => {
174
+ const press: $FlowFixMe = event;
175
+ // The browser's own menu would otherwise cover this one, and the reader
176
+ // would be looking at the platform's Back/Reload rather than at the
177
+ // commands the page has for what they pressed on.
178
+ press.preventDefault();
179
+ openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
180
+ // This is an instruction for the menu body after the opening commit.
181
+ // uf-lint-disable-next-line react-compiler/immutability
182
+ menu.pendingFocusRef.current = "first";
183
+ menu.setOpen(true);
184
+ }),
185
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
186
+ // Both spellings. `ContextMenu` is the dedicated key on a PC keyboard;
187
+ // `Shift+F10` is the one every platform has, and is what a laptop
188
+ // without that key leaves a reader with.
189
+ const asked =
190
+ event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey === true);
191
+ if (!asked) {
192
+ return;
193
+ }
194
+ event.preventDefault();
195
+ openHere();
196
+ }),
197
+ // React calls callback refs during commit; focus restoration reads these later.
198
+ // uf-lint-disable-next-line react-compiler/refs
199
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
200
+ triggerRef.current = element;
201
+ // What focus goes back to when the menu closes. It is deliberately not
202
+ // registered as the menu's *name*; see the module header.
203
+ // uf-lint-disable-next-line react-compiler/immutability
204
+ menu.triggerRef.current = element;
205
+ }),
206
+ },
207
+ );
208
+
209
+ if (render != null) {
210
+ return render(props);
211
+ }
212
+ return <div {...props} />;
213
+ }
214
+
215
+ export type { MenuSelect } from "./menu.js";
package/date-field.js ADDED
@@ -0,0 +1,9 @@
1
+ // @flow
2
+ "use client";
3
+ import * as React from "@uniflowed/react";
4
+ import { SegmentedField } from "./internal/segmented-field.js";
5
+ import type { DateFieldProps } from "./internal/segmented-field.js";
6
+ export component DateField(...props: DateFieldProps) {
7
+ return <SegmentedField options={props} time={false} />;
8
+ }
9
+ export type { DateFieldProps } from "./internal/segmented-field.js";