@uniflowed/ui 0.0.0-alpha.8 → 0.1.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 (64) 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 +560 -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 +235 -198
  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 +334 -0
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1254 -32
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +565 -0
  27. package/internal/collection.js +395 -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/focus.js +64 -0
  32. package/internal/hover-intent.js +259 -0
  33. package/internal/menu-tree.js +228 -0
  34. package/internal/merge-props.js +117 -1
  35. package/internal/roving-focus.js +15 -4
  36. package/internal/segmented-field.js +316 -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 -25
  42. package/pagination.js +34 -22
  43. package/popover.js +367 -0
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +78 -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 +112 -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 +404 -0
  64. package/tree.js +8 -0
package/scroll-area.js ADDED
@@ -0,0 +1,283 @@
1
+ // @flow
2
+ //
3
+ // A scroll area: a scrollbar you drew yourself, and the keyboard you took away
4
+ // when you did.
5
+ //
6
+ // # What it gives that `overflow: auto` does not
7
+ //
8
+ // A `<div style="overflow: auto">` scrolls with the wheel, with a trackpad, and
9
+ // with a finger. What it does not reliably do is scroll from the keyboard,
10
+ // because it may not be focusable: Firefox makes a scrollable region focusable,
11
+ // Chromium historically does not, and Safari's answer depends on the setting
12
+ // that controls whether `Tab` reaches anything but form controls. So a region
13
+ // that must be scrolled to be read is, in most browsers, a region a keyboard
14
+ // reader can see the top of and nothing else. That is WCAG 2.1.1, and it is the
15
+ // entire reason to have this component rather than the `div`:
16
+ //
17
+ // * **`tabindex="0"`, `role="region"` and a name.** The tab stop is what
18
+ // makes the arrow keys and `PageDown` work; the role and the name are what
19
+ // keep a tab stop from being a mystery — a focusable `div` with no name is
20
+ // announced as nothing at all, which is a worse place to land than the
21
+ // `div` was.
22
+ // * **Nothing is intercepted.** No `onKeyDown`, no `onWheel`, no
23
+ // `scroll-behavior` written from JavaScript. Every key that scrolls a
24
+ // native overflow container scrolls this one, because this one *is* a
25
+ // native overflow container and the component's whole contribution is not
26
+ // getting in its way. A scroll area that reimplemented `PageDown` would
27
+ // have to reimplement `Home`, `End`, the space bar, caret browsing and
28
+ // whatever the reader's own software sends, and would get one of them
29
+ // wrong.
30
+ // * **`scrollIntoView({ block: "nearest" })` still works.** `combobox.js` and
31
+ // `select.js` both call it to keep the active option visible, so a
32
+ // `Combobox.List` inside a `ScrollArea` is a case that has to work. It does
33
+ // because the viewport is a plain scroll container and nothing here
34
+ // overrides `scrollTop`; the one time this module writes it is described
35
+ // below, and it is exactly the case where the browser has already thrown
36
+ // the position away.
37
+ // * **The position survives a re-render.** Replacing the content of a scroll
38
+ // container — a filtered list, a new page of results — makes the browser
39
+ // clamp `scrollTop` to a shorter document and it does not put it back. The
40
+ // viewport remembers where the reader actually scrolled to, from the
41
+ // `scroll` event, and restores it after a commit that lost it. A reader who
42
+ // scrolls to the top themselves fires a `scroll` event, so the remembered
43
+ // position is theirs and this never fights them.
44
+ //
45
+ // # The scrollbar is a picture
46
+ //
47
+ // `ScrollArea.Scrollbar` is `aria-hidden` and holds no controls. That is the
48
+ // decision the component is made of: the *region* is the thing that scrolls and
49
+ // the keyboard is how it is operated, so a drawn scrollbar has nothing it must
50
+ // be able to do — which means it never has to answer WCAG 2.5.7's question
51
+ // about dragging, because nothing here is achievable only by dragging. It
52
+ // reports where the content is as two custom properties and stays out of the
53
+ // accessibility tree, where a second, mouse-only copy of the scroll position
54
+ // would be noise.
55
+
56
+ "use client";
57
+
58
+ import * as React from "@uniflowed/react";
59
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
60
+ import { useEventListener } from "@uniflowed/hooks/dom";
61
+
62
+ import type { Orientation } from "./internal/roving-focus.js";
63
+ import type { Rest } from "./internal/merge-props.js";
64
+ import { composeRefs, withoutComposed } from "./internal/merge-props.js";
65
+
66
+ export type { Orientation } from "./internal/roving-focus.js";
67
+
68
+ /** Where the reader last actually was, per axis. */
69
+ type Offset = {| x: number, y: number |};
70
+
71
+ type ScrollAreaState = {|
72
+ readonly base: string,
73
+ readonly label: string,
74
+ readonly viewportRef: { current: HTMLElement | null },
75
+ readonly rememberedRef: { current: Offset },
76
+ /** Written by the viewport, read by every scrollbar. */
77
+ readonly report: () => void,
78
+ readonly scrollbarsRef: { current: Array<HTMLElement> },
79
+ |};
80
+
81
+ const ScrollAreaContext: React.Context<ScrollAreaState | null> = createContext(null);
82
+
83
+ /**
84
+ * The scroll area a part belongs to.
85
+ *
86
+ * Raising rather than returning null, for the reason `useDialog` gives: a
87
+ * `ScrollArea.Scrollbar` outside a root would draw a thumb for a viewport it
88
+ * has never measured, and it would look correct until the content moved.
89
+ */
90
+ hook useScrollArea(part: string): ScrollAreaState {
91
+ const state = useContext(ScrollAreaContext);
92
+ if (state == null) {
93
+ throw new Error(`${part} must be rendered inside a ScrollArea.Root`);
94
+ }
95
+ return state;
96
+ }
97
+
98
+ /**
99
+ * The box the viewport and the scrollbars sit in.
100
+ *
101
+ * `label` is required and lives here rather than on the viewport, because the
102
+ * name belongs to the whole component: it is what a reader hears when `Tab`
103
+ * lands them in it, and a scroll area that has to be scrolled to be read and is
104
+ * announced as "region" has told them nothing.
105
+ */
106
+ export component ScrollAreaRoot(children: React.Node, label: string, ...rest: Rest) {
107
+ const base = useId();
108
+ const viewportRef = useRef<HTMLElement | null>(null);
109
+ const rememberedRef = useRef<Offset>({ x: 0, y: 0 });
110
+ const scrollbarsRef = useRef<Array<HTMLElement>>([]);
111
+
112
+ const state = useMemo(
113
+ () => ({
114
+ base,
115
+ label,
116
+ rememberedRef,
117
+ report: () => {
118
+ const viewport = viewportRef.current;
119
+ if (viewport == null) {
120
+ return;
121
+ }
122
+ for (const scrollbar of scrollbarsRef.current) {
123
+ write(scrollbar, viewport);
124
+ }
125
+ },
126
+ scrollbarsRef,
127
+ viewportRef,
128
+ }),
129
+ [base, label],
130
+ );
131
+
132
+ return (
133
+ <ScrollAreaContext.Provider value={state}>
134
+ <div {...rest}>{children}</div>
135
+ </ScrollAreaContext.Provider>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * The element that actually scrolls: a named region, in the tab sequence.
141
+ *
142
+ * It carries no key handling at all. See the module header — every key that
143
+ * scrolls a native overflow container scrolls this one because it is one, and
144
+ * the component's contribution is the tab stop that lets those keys arrive.
145
+ */
146
+ export component ScrollAreaViewport(children: React.Node, ...rest: Rest) {
147
+ const area = useScrollArea("ScrollArea.Viewport");
148
+ const { rememberedRef, report, viewportRef } = area;
149
+ const passed = withoutComposed(rest, ["ref"]);
150
+
151
+ useEventListener(viewportRef, "scroll", () => {
152
+ const viewport = viewportRef.current;
153
+ if (viewport == null) {
154
+ return;
155
+ }
156
+ // The reader's own position, including a deliberate scroll back to the
157
+ // top — which is why the restore below never fights them.
158
+ rememberedRef.current = { x: viewport.scrollLeft, y: viewport.scrollTop };
159
+ report();
160
+ });
161
+
162
+ // After every commit, because a commit is what replaces the content: a
163
+ // shorter document makes the browser clamp the offset to fit and it does not
164
+ // put it back when the content grows again.
165
+ useEffect(() => {
166
+ const viewport = viewportRef.current;
167
+ if (viewport == null) {
168
+ return;
169
+ }
170
+ const { x, y } = rememberedRef.current;
171
+ if (y !== 0 && viewport.scrollTop === 0) {
172
+ viewport.scrollTop = y;
173
+ }
174
+ if (x !== 0 && viewport.scrollLeft === 0) {
175
+ viewport.scrollLeft = x;
176
+ }
177
+ report();
178
+ });
179
+
180
+ return (
181
+ <div
182
+ {...passed}
183
+ aria-label={area.label}
184
+ id={`${area.base}-viewport`}
185
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
186
+ viewportRef.current = element;
187
+ })}
188
+ // A named region, which is what makes the tab stop below explicable
189
+ // rather than a place a reader lands and cannot account for.
190
+ role="region"
191
+ // The whole component. Without it the arrow keys and `PageDown` never
192
+ // arrive, and the bottom of this box is unreachable from a keyboard in
193
+ // every browser that does not make scroll containers focusable.
194
+ tabIndex={0}
195
+ >
196
+ {children}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The drawn scrollbar: two numbers and no semantics.
203
+ *
204
+ * `--uf-scroll-thumb-size` is the thumb's length as a fraction of the track and
205
+ * `--uf-scroll-thumb-offset` is where along it the thumb sits, both between 0
206
+ * and 1, so a stylesheet can draw one with a `scale` and a `translate` and
207
+ * measure nothing. `aria-hidden`, because the region it belongs to is already
208
+ * the thing a reader operates.
209
+ */
210
+ export component ScrollAreaScrollbar(
211
+ children?: React.Node,
212
+ orientation?: Orientation = "vertical",
213
+ ...rest: Rest
214
+ ) {
215
+ const area = useScrollArea("ScrollArea.Scrollbar");
216
+ const { report, scrollbarsRef } = area;
217
+ const passed = withoutComposed(rest, ["ref"]);
218
+
219
+ return (
220
+ <div
221
+ {...passed}
222
+ // A picture of the scroll position is not something a screen reader has
223
+ // any use for: it cannot be operated, and the region it describes
224
+ // announces itself.
225
+ aria-hidden="true"
226
+ data-orientation={orientation}
227
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
228
+ const kept = scrollbarsRef.current.filter((each) => each !== element);
229
+ scrollbarsRef.current = element == null ? kept : [...kept, element];
230
+ report();
231
+ })}
232
+ >
233
+ {children}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Write where the content is onto a scrollbar.
240
+ *
241
+ * Imperatively, and only these two properties, for the reason
242
+ * `internal/anchor.js` gives about a placement: they change on every scroll
243
+ * frame, and re-rendering the scroll area and everything in it sixty times a
244
+ * second to move a thumb is the cost this package does not pay. React sets
245
+ * neither property, so a caller's `style` keeps everything in it.
246
+ *
247
+ * Both axes are written on every scrollbar rather than the one its
248
+ * `data-orientation` names, because a stylesheet reads the pair it wants and a
249
+ * branch here would be a second place the orientation is decided.
250
+ */
251
+ function write(scrollbar: HTMLElement, viewport: HTMLElement): void {
252
+ const style = scrollbar.style;
253
+ style.setProperty(
254
+ "--uf-scroll-thumb-size",
255
+ String(fraction(viewport.clientHeight, viewport.scrollHeight)),
256
+ );
257
+ style.setProperty(
258
+ "--uf-scroll-thumb-offset",
259
+ String(fraction(viewport.scrollTop, viewport.scrollHeight - viewport.clientHeight)),
260
+ );
261
+ style.setProperty(
262
+ "--uf-scroll-thumb-size-x",
263
+ String(fraction(viewport.clientWidth, viewport.scrollWidth)),
264
+ );
265
+ style.setProperty(
266
+ "--uf-scroll-thumb-offset-x",
267
+ String(fraction(viewport.scrollLeft, viewport.scrollWidth - viewport.clientWidth)),
268
+ );
269
+ }
270
+
271
+ /**
272
+ * `part / whole`, clamped, and 1 when there is no whole.
273
+ *
274
+ * A document that computes no layout reports every measurement as zero, and
275
+ * `0 / 0` is `NaN` — which a stylesheet reading the custom property renders as
276
+ * a thumb of no size at all rather than as a full-length one.
277
+ */
278
+ function fraction(part: number, whole: number): number {
279
+ if (!(whole > 0)) {
280
+ return 1;
281
+ }
282
+ return Math.min(1, Math.max(0, part / whole));
283
+ }
package/select.js CHANGED
@@ -146,6 +146,10 @@ import {
146
146
  } from "@uniflowed/react";
147
147
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
148
148
 
149
+ import { useInteractOutside } from "./interactions.js";
150
+
151
+ import type { Align, LogicalSide } from "./internal/anchor.js";
152
+ import { useAnchor } from "./internal/anchor.js";
149
153
  import type { Rest } from "./internal/merge-props.js";
150
154
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
151
155
  import type { Movement } from "./internal/roving-focus.js";
@@ -153,6 +157,8 @@ import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving
153
157
  import { useControlled } from "./internal/controlled-state.js";
154
158
  import { FormValue } from "./internal/form-value.js";
155
159
 
160
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
161
+
156
162
  const OPTION_SELECTOR = '[role="option"]';
157
163
  const LISTBOX_SELECTOR = '[role="listbox"]';
158
164
 
@@ -186,7 +192,7 @@ type SelectState = {|
186
192
  /** The id of the option `aria-activedescendant` names, if any. */
187
193
  readonly activeId: string | null,
188
194
  readonly setActiveId: (id: string | null) => void,
189
- readonly pendingLanding: { current: Landing | null },
195
+ readonly pendingLandingRef: { current: Landing | null },
190
196
  readonly triggerRef: { current: HTMLElement | null },
191
197
  readonly listRef: { current: HTMLElement | null },
192
198
  /**
@@ -264,7 +270,7 @@ export component SelectRoot(
264
270
  const [activeId, setActiveId] = useState<string | null>(null);
265
271
  const [labels, setLabels] = useState<{ readonly [string]: string }>({});
266
272
  const [labelled, setLabelled] = useState(false);
267
- const pendingLanding = useRef<Landing | null>(null);
273
+ const pendingLandingRef = useRef<Landing | null>(null);
268
274
  const triggerRef = useRef<HTMLElement | null>(null);
269
275
  const listRef = useRef<HTMLElement | null>(null);
270
276
  const typeahead = useTypeahead();
@@ -307,7 +313,7 @@ export component SelectRoot(
307
313
  choose,
308
314
  activeId,
309
315
  setActiveId,
310
- pendingLanding,
316
+ pendingLandingRef,
311
317
  triggerRef,
312
318
  listRef,
313
319
  labels,
@@ -409,7 +415,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
409
415
  /** Move the cursor within an open list, or open with an instruction. */
410
416
  const move = (end: Movement, preferSelected: boolean) => {
411
417
  if (!select.open) {
412
- select.pendingLanding.current = { kind: "end", end, preferSelected };
418
+ // This is an instruction for the list after the opening commit.
419
+ // uf-lint-disable-next-line react-compiler/immutability
420
+ select.pendingLandingRef.current = { kind: "end", end, preferSelected };
413
421
  select.setOpen(true);
414
422
  return;
415
423
  }
@@ -452,7 +460,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
452
460
  select.setActiveId(null);
453
461
  return;
454
462
  }
455
- select.pendingLanding.current = { kind: "end", end: "first", preferSelected: true };
463
+ // This is an instruction for the list after the opening commit.
464
+ // uf-lint-disable-next-line react-compiler/immutability
465
+ select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
456
466
  select.setOpen(true);
457
467
  })}
458
468
  onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
@@ -494,7 +504,9 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
494
504
  // again as a click.
495
505
  event.preventDefault();
496
506
  if (!select.open) {
497
- select.pendingLanding.current = { kind: "end", end: "first", preferSelected: true };
507
+ // This is an instruction for the list after the opening commit.
508
+ // uf-lint-disable-next-line react-compiler/immutability
509
+ select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
498
510
  select.setOpen(true);
499
511
  return;
500
512
  }
@@ -538,7 +550,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
538
550
  event.preventDefault();
539
551
  // The options are not in the document yet, so the keystroke travels
540
552
  // to the commit that renders them.
541
- select.pendingLanding.current = { kind: "typed", key: event.key };
553
+ // uf-lint-disable-next-line react-compiler/immutability
554
+ select.pendingLandingRef.current = { kind: "typed", key: event.key };
542
555
  select.setOpen(true);
543
556
  return;
544
557
  }
@@ -554,6 +567,8 @@ export component SelectTrigger(children: React.Node, ...rest: Rest) {
554
567
  }
555
568
  })}
556
569
  ref={composeRefs(rest.ref, (element) => {
570
+ // React calls callback refs during commit; the list reads the trigger later.
571
+ // uf-lint-disable-next-line react-compiler/immutability
557
572
  select.triggerRef.current = element;
558
573
  })}
559
574
  role="combobox"
@@ -613,20 +628,46 @@ export component SelectValue(children?: React.Node, placeholder?: React.Node, ..
613
628
  */
614
629
  export component SelectList(
615
630
  children: renders* (SelectOption | SelectGroup | SelectSeparator),
631
+ align?: Align = "start",
632
+ alignOffset?: number = 0,
633
+ avoidCollisions?: boolean = true,
634
+ collisionPadding?: number = 0,
635
+ side?: LogicalSide = "bottom",
636
+ sideOffset?: number = 0,
616
637
  ...rest: Rest
617
638
  ) {
618
639
  const select = useSelect("Select.List");
619
- const { activeId, listRef, pendingLanding, setActiveId, triggerRef, typeahead, value } = select;
640
+ const { activeId, listRef, pendingLandingRef, setActiveId, triggerRef, typeahead, value } =
641
+ select;
620
642
  const close = useStableCallback(() => {
621
643
  select.setOpen(false);
622
644
  select.setActiveId(null);
623
645
  });
624
646
 
647
+ // The popup a select opens is the one case where the trigger's *width* is
648
+ // part of the design rather than a detail: a list narrower than the button it
649
+ // came out of reads as a different control. `--uf-anchor-trigger-width` is
650
+ // written on this element for a stylesheet to use, which is why the
651
+ // measurement is here and not in the caller.
652
+ const anchored = useAnchor({
653
+ align,
654
+ alignOffset,
655
+ anchorRef: triggerRef,
656
+ avoidCollisions,
657
+ collisionPadding,
658
+ open: select.open,
659
+ overlayRef: listRef,
660
+ side,
661
+ sideOffset,
662
+ });
663
+
625
664
  // No dependency list, for the reason `combobox.js` gives: what this reads is
626
665
  // the *rendered* options, and a caller may render different ones on any
627
666
  // render — a change to `children` that no dependency list can describe. Every
628
667
  // write is guarded by a comparison, so it settles after one extra pass rather
629
668
  // than looping.
669
+ // This effect measures caller-rendered options after commit.
670
+ // uf-lint-disable-next-line react-compiler/immutability
630
671
  useEffect(() => {
631
672
  const list = listRef.current;
632
673
  if (list == null) {
@@ -634,9 +675,10 @@ export component SelectList(
634
675
  }
635
676
  const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
636
677
 
637
- const wanted = pendingLanding.current;
678
+ const wanted = pendingLandingRef.current;
638
679
  if (wanted != null) {
639
- pendingLanding.current = null;
680
+ // uf-lint-disable-next-line react-compiler/immutability
681
+ pendingLandingRef.current = null;
640
682
  // An `if` rather than a `match` on `wanted.kind`, because matching on a
641
683
  // property does not refine the object that property came from: inside
642
684
  // `match (wanted.kind)` both arms still see the whole union, and `uf
@@ -664,32 +706,16 @@ export component SelectList(
664
706
  }
665
707
  });
666
708
 
667
- // Keyed on `select.open`, which is load-bearing: this component is mounted
668
- // the whole time and only *renders* while the list is open, so keyed on the
669
- // stable callbacks alone the effect would run once, on the commit where
670
- // `listRef.current` was still null, and never attach the listener at all.
671
- useEffect(() => {
672
- const list = listRef.current;
673
- if (list == null) {
674
- return;
675
- }
676
- const document = list.ownerDocument;
677
- const onOutsidePress = (event: Event) => {
678
- const target: $FlowFixMe = event.target;
679
- if (target == null || list.contains(target)) {
680
- return;
681
- }
682
- // The trigger is not "outside": closing here and letting its own click
683
- // reopen the list makes a press on the trigger a no-op that flickers.
684
- const trigger = triggerRef.current;
685
- if (trigger != null && trigger.contains(target)) {
686
- return;
687
- }
688
- close();
689
- };
690
- document.addEventListener("pointerdown", onOutsidePress, true);
691
- return () => document.removeEventListener("pointerdown", onOutsidePress, true);
692
- }, [select.open, close, listRef, triggerRef]);
709
+ // The refs are read when a press arrives rather than when the listener is
710
+ // attached, which is what makes `select.open` the only thing this depends on:
711
+ // this component is mounted the whole time and only *renders* while the list
712
+ // is open, and a listener attached on the commit where `listRef.current` was
713
+ // still null used to be one that never worked.
714
+ useInteractOutside({
715
+ isDisabled: !select.open,
716
+ onInteractOutside: () => close(),
717
+ refs: [listRef, triggerRef],
718
+ });
693
719
 
694
720
  if (!select.open) {
695
721
  return null;
@@ -701,6 +727,8 @@ export component SelectList(
701
727
  <div
702
728
  {...passed}
703
729
  aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
730
+ data-align={anchored.align}
731
+ data-side={anchored.side}
704
732
  id={`${select.base}-list`}
705
733
  ref={composeRefs(rest.ref, (element) => {
706
734
  listRef.current = element;
@@ -793,8 +821,26 @@ export component SelectOption(
793
821
  * `Select.GroupLabel` is rendered — the same rule, and the same reason, as
794
822
  * `Menu.Group`. The arrow keys pass over the label without stopping on it,
795
823
  * because they only ever look for `role="option"`.
824
+ *
825
+ * `children` is `renders* (SelectOption | SelectGroupLabel)`, which is what a
826
+ * `group` inside a `listbox` may hold: options, and the heading that names
827
+ * them. It took `React.Node` until ubugeeei-prod/uf#562, so a `<div>` in a
828
+ * group was a runtime surprise — an element with no role between two options,
829
+ * which the arrow keys walk straight past and a screen reader reads as a stray
830
+ * line — rather than a type error. `Combobox.Group` has stated the constraint
831
+ * since #558 and this is the same listbox.
832
+ *
833
+ * No `Select.Separator`, and that is deliberate rather than an omission: a rule
834
+ * separates *groups*, so it belongs between them in `Select.List` — which does
835
+ * admit one. A separator inside a group is a rule with nothing on one side of
836
+ * it.
837
+ *
838
+ * **Breaking.** A caller passing anything else — a `<div>` wrapper, a fragment
839
+ * of their own, a component that returns options — now fails `uf check`. The
840
+ * fix is to hand the options to the group directly; a wrapper had no effect on
841
+ * what this renders, because the group's element is the one below.
796
842
  */
797
- export component SelectGroup(children: React.Node, ...rest: Rest) {
843
+ export component SelectGroup(children: renders* (SelectOption | SelectGroupLabel), ...rest: Rest) {
798
844
  const base = useId();
799
845
  const [labelled, setLabelled] = useState(false);
800
846
 
package/separator.js ADDED
@@ -0,0 +1,97 @@
1
+ // @flow
2
+ //
3
+ // A rule, and the one decision in it: whether anybody is told it is there.
4
+ //
5
+ // Two lines of markup, and it belongs in this package rather than in the preset
6
+ // for the same reason `Progress` does — the component *is* a conditional about
7
+ // what a reader hears:
8
+ //
9
+ // * A separator **between groups of content** is `role="separator"` with an
10
+ // `aria-orientation`. A reader moving down the page is told the subject
11
+ // changed, which is the information the line was drawn to give and the only
12
+ // way they get it.
13
+ // * A **decorative** rule — the line under a heading, the hairline between a
14
+ // card's padding and its footer — is `aria-hidden="true"` and announced to
15
+ // nobody. It is a border that happens to be an element.
16
+ //
17
+ // Getting it backwards is silent in both directions: a decorative rule with the
18
+ // role adds a "separator" to every reading of the page, and a real boundary
19
+ // without it takes the boundary away from everyone who is not looking at it.
20
+ //
21
+ // The default is the semantic one, because the two mistakes do not cost the
22
+ // same. A rule wrongly announced is noise a reader can hear and skip; a
23
+ // boundary wrongly silent is information that is simply not there, and nobody
24
+ // finds out. `progress.js` makes the same trade in its own sentence: the safe
25
+ // answer has to be the honest one.
26
+ //
27
+ // # Why this is a `<div>` and not an `<hr>`
28
+ //
29
+ // An `<hr>` already carries `role="separator"`, so for a horizontal rule
30
+ // between two blocks of prose it is the better answer and a caller who can use
31
+ // one should. This exists for what it cannot do.
32
+ //
33
+ // It comes with a border and a margin from the browser's own stylesheet, and a
34
+ // package that ships no styles cannot ship a visible line — every consumer
35
+ // would begin by turning it off. It is horizontal by definition, so a vertical
36
+ // rule between two things in a row is a rotated element rather than a described
37
+ // one. And it is a paragraph-level break in the flow, which is not what a
38
+ // hairline inside a toolbar is.
39
+ //
40
+ // # The two separators that are not this one
41
+ //
42
+ // `Menu.Separator` is the rule between groups of menu items, and belongs to the
43
+ // menu because it has to be skipped by the arrow keys that walk it.
44
+ // `Resizable.Handle` announces itself as a separator too, and is a *control* —
45
+ // the APG window splitter, a separator that behaves like a slider. Neither is
46
+ // this, and reaching for this one in either place loses the behaviour that made
47
+ // them their own components.
48
+ //
49
+ // # No `"use client"`
50
+ //
51
+ // One element, two attributes, nothing to remember. It renders on a server.
52
+
53
+ import type { Orientation } from "./internal/roving-focus.js";
54
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
55
+ import { withProps } from "./internal/merge-props.js";
56
+
57
+ export type { Orientation } from "./internal/roving-focus.js";
58
+
59
+ /**
60
+ * A rule between two things, or a line that is only a line.
61
+ *
62
+ * `decorative` is the whole component. Without it the element is a
63
+ * `role="separator"` a reader is told about; with it the element is hidden from
64
+ * the accessibility tree entirely.
65
+ *
66
+ * <Separator />
67
+ * <Separator orientation="vertical" />
68
+ * <Separator decorative />
69
+ *
70
+ * The decorative one gets `aria-hidden` and no role, rather than
71
+ * `role="presentation"` as well: a `<div>` has nothing to hide behind a
72
+ * presentation role, and one attribute that removes the element from the tree
73
+ * says the whole thing. `Breadcrumb.Separator` carries both because it is an
74
+ * `<li>`, whose `listitem` role would otherwise be counted.
75
+ *
76
+ * `aria-orientation` is written out even for the horizontal case, where ARIA
77
+ * would default to it. It is the attribute a reader of this markup is looking
78
+ * for, and a default that is left implicit is a default somebody has to know.
79
+ *
80
+ * `render` changes the element carrying that decision, not the decision
81
+ * itself: decorative rules stay hidden, semantic rules keep the separator
82
+ * role and orientation.
83
+ */
84
+ export component Separator(
85
+ decorative?: boolean = false,
86
+ orientation?: Orientation = "horizontal",
87
+ render?: RenderProp,
88
+ ...rest: Rest
89
+ ) {
90
+ const props = decorative
91
+ ? withProps(rest, { "aria-hidden": "true" })
92
+ : withProps(rest, { "aria-orientation": orientation, role: "separator" });
93
+ if (render != null) {
94
+ return render(props);
95
+ }
96
+ return <div {...props} />;
97
+ }