@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.41

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 (52) hide show
  1. package/accordion.js +362 -0
  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 +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +171 -0
  10. package/combobox.js +235 -47
  11. package/context-menu.js +215 -0
  12. package/date-picker.js +357 -0
  13. package/dialog.js +235 -197
  14. package/drawer.js +504 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +334 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2327 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +207 -11
  30. package/menu.js +558 -340
  31. package/menubar.js +295 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +367 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +304 -0
  38. package/resizable.js +453 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +901 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +320 -0
  44. package/skeleton.js +163 -0
  45. package/slider.js +411 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +594 -0
  50. package/toggle-group.js +284 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +404 -0
package/collapsible.js ADDED
@@ -0,0 +1,171 @@
1
+ // @flow
2
+ //
3
+ // A button and the region it shows: the disclosure pattern, on its own.
4
+ //
5
+ // This is the smallest component in the package and it is here because the
6
+ // three attributes it gets right are the three everybody leaves out.
7
+ // `<button onClick={() => setOpen(!open)}>` with a `{open && <div>…</div>}`
8
+ // after it looks finished and tells a screen reader nothing: not that the
9
+ // button controls anything, not whether the thing is showing, and not which
10
+ // region it is. A reader hears "Details, button" and has no way to know that
11
+ // pressing it changed the page.
12
+ //
13
+ // So: `aria-expanded` on the trigger, `aria-controls` naming the content —
14
+ // and only while there is content to name, because an `aria-controls` pointing
15
+ // at an id nothing has is a promise the component cannot keep.
16
+ //
17
+ // # The closed content stays in the document
18
+ //
19
+ // `Tabs.Panel` returns `null` when it is not selected and that is right for a
20
+ // tab set. Here it is wrong, and the reason is the browser's find-in-page: text
21
+ // in a section that is not in the document cannot be found, so a page of
22
+ // collapsed sections is a page a reader has to open by hand to search.
23
+ // `internal/disclosure.js` explains what is done instead, and why React needs a
24
+ // hook to say it.
25
+ //
26
+ // # The height, for a stylesheet that animates it
27
+ //
28
+ // `measure` puts the height the content *would* have on
29
+ // `Collapsible.Content` as `--uf-collapsible-height`, correct while the panel
30
+ // is still closed — which is the only moment it is any use, because
31
+ // `height: 0 → var(--uf-collapsible-height)` is a transition that has to know
32
+ // its destination before it starts. `internal/disclosure.js` holds the
33
+ // measuring pass and says why the obvious ways of asking all answer zero, and
34
+ // why the prop is opt-in rather than always on.
35
+ //
36
+ // [data-collapsible-content] {
37
+ // overflow: hidden;
38
+ // transition: height 150ms;
39
+ // height: 0;
40
+ // }
41
+ // [data-collapsible-content]:not([hidden]) {
42
+ // height: var(--uf-collapsible-height);
43
+ // }
44
+ //
45
+ // The selector is the caller's — a class, a `data-*` of their own, whatever
46
+ // they already style with. This package emits the number and no styles at all,
47
+ // which is the same division `internal/anchor.js` keeps for a popover: a
48
+ // stylesheet can say *how* to move, and only the component can say how far.
49
+
50
+ "use client";
51
+
52
+ import * as React from "@uniflowed/react";
53
+ import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
54
+
55
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
56
+ import {
57
+ composeHandlers,
58
+ composeRefs,
59
+ withProps,
60
+ withoutComposed,
61
+ } from "./internal/merge-props.js";
62
+ import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
63
+ import { useControlled } from "./internal/controlled-state.js";
64
+
65
+ type CollapsibleState = {|
66
+ readonly contentId: string,
67
+ readonly open: boolean,
68
+ readonly setOpen: (open: boolean) => void,
69
+ /** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
70
+ readonly present: boolean,
71
+ readonly registerContent: (present: boolean) => void,
72
+ /** Whether the content carries its measured height; see the module header. */
73
+ readonly measure: boolean,
74
+ |};
75
+
76
+ const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
77
+
78
+ hook useCollapsible(part: string): CollapsibleState {
79
+ const state = useContext(CollapsibleContext);
80
+ if (state == null) {
81
+ throw new Error(`${part} must be rendered inside a Collapsible.Root`);
82
+ }
83
+ return state;
84
+ }
85
+
86
+ /**
87
+ * The pair, and the state they agree about.
88
+ *
89
+ * Renders no element of its own: a trigger and its content are siblings in
90
+ * whatever layout the caller wrote, and a wrapper would put a `<div>` between
91
+ * them that the caller then has to style around. `Menu.Root` makes the same
92
+ * choice for the same reason.
93
+ */
94
+ export component CollapsibleRoot(
95
+ children: React.Node,
96
+ defaultOpen?: boolean = false,
97
+ open?: boolean,
98
+ onOpenChange?: (open: boolean) => void,
99
+ measure?: boolean = false,
100
+ ) {
101
+ const contentId = `${useId()}-content`;
102
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
103
+ const [present, setPresent] = useState(false);
104
+
105
+ const state = useMemo(
106
+ () => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent, measure }),
107
+ [contentId, isOpen, setOpen, present, measure],
108
+ );
109
+
110
+ return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
111
+ }
112
+
113
+ /** The control that shows and hides the content. */
114
+ export component CollapsibleTrigger(
115
+ children: React.Node,
116
+ disabled?: boolean = false,
117
+ render?: RenderProp,
118
+ ...rest: Rest
119
+ ) {
120
+ const collapsible = useCollapsible("Collapsible.Trigger");
121
+ const props = withProps(withoutComposed(rest, ["onClick"]), {
122
+ // Named only while the content is in the document. A caller who renders
123
+ // the content conditionally — or not at all until data arrives — would
124
+ // otherwise have this trigger pointing at nothing.
125
+ "aria-controls": collapsible.present ? collapsible.contentId : undefined,
126
+ "aria-expanded": collapsible.open ? "true" : "false",
127
+ children,
128
+ disabled,
129
+ onClick: composeHandlers(rest.onClick, () => {
130
+ if (!disabled) {
131
+ collapsible.setOpen(!collapsible.open);
132
+ }
133
+ }),
134
+ });
135
+
136
+ if (render != null) {
137
+ return render(props);
138
+ }
139
+ return <button {...props} type="button" />;
140
+ }
141
+
142
+ /**
143
+ * The region the trigger shows.
144
+ *
145
+ * It is always rendered and `hidden` while closed, rather than removed — see
146
+ * the module header, and `internal/disclosure.js` for what `hidden` is upgraded
147
+ * to and why that takes an effect.
148
+ */
149
+ export component CollapsibleContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
150
+ const collapsible = useCollapsible("Collapsible.Content");
151
+ const contentRef = useRef<HTMLElement | null>(null);
152
+ usePresence(collapsible.registerContent);
153
+ useUntilFound(contentRef, collapsible.open);
154
+ useMeasuredHeight(contentRef, collapsible.measure);
155
+
156
+ const props = withProps(withoutComposed(rest, ["ref"]), {
157
+ children,
158
+ hidden: !collapsible.open,
159
+ id: collapsible.contentId,
160
+ // React calls callback refs during commit; this node is only read by effects.
161
+ // uf-lint-disable-next-line react-compiler/refs
162
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
163
+ contentRef.current = element;
164
+ }),
165
+ });
166
+
167
+ if (render != null) {
168
+ return render(props);
169
+ }
170
+ return <div {...props} />;
171
+ }
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,9 +127,17 @@ 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";
134
+ import type { Rest } from "./internal/merge-props.js";
67
135
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
68
136
  import { itemsOf, moveTo } from "./internal/roving-focus.js";
69
137
  import { useControlled } from "./internal/controlled-state.js";
138
+ import { FormValue } from "./internal/form-value.js";
139
+
140
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
70
141
 
71
142
  const OPTION_SELECTOR = '[role="option"]';
72
143
  const LISTBOX_SELECTOR = '[role="listbox"]';
@@ -94,7 +165,7 @@ type ComboboxState = {|
94
165
  * and the list does not exist to be measured until the next commit. A ref
95
166
  * rather than state because nothing renders it.
96
167
  */
97
- readonly pendingActive: { current: "first" | "last" | null },
168
+ readonly pendingActiveRef: { current: "first" | "last" | null },
98
169
  readonly inputRef: { current: HTMLElement | null },
99
170
  readonly listRef: { current: HTMLElement | null },
100
171
  /** How many options are in the list, for the live region. */
@@ -114,6 +185,14 @@ hook useCombobox(part: string): ComboboxState {
114
185
  return state;
115
186
  }
116
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
+
117
196
  /**
118
197
  * The combobox.
119
198
  *
@@ -122,6 +201,14 @@ hook useCombobox(part: string): ComboboxState {
122
201
  * `open` is whether the list is showing. A search box owns the text and nothing
123
202
  * else; a form field owns the value; a page with a "browse all" button owns
124
203
  * `open`. Tying them together would make two of those three impossible.
204
+ *
205
+ * `name` is what a form submits, and it exists because that same distinction
206
+ * had a hole in it. `Combobox.Input` renders the *text* — the label the reader
207
+ * sees — so a combobox named `country` inside a `<form>` submitted "United
208
+ * Kingdom" where the application meant `GB`, silently and only in production.
209
+ * Given a `name`, the root renders a hidden control carrying `value` instead;
210
+ * `internal/form-value.js` says why it is an `<input>` and why
211
+ * `@uniflowed/form` does not need it.
125
212
  */
126
213
  export component ComboboxRoot(
127
214
  children: React.Node,
@@ -134,7 +221,8 @@ export component ComboboxRoot(
134
221
  open?: boolean,
135
222
  defaultOpen?: boolean = false,
136
223
  onOpenChange?: (open: boolean) => void,
137
- ...rest: { readonly [string]: mixed }
224
+ name?: string,
225
+ ...rest: Rest
138
226
  ) {
139
227
  const base = useId();
140
228
  const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
@@ -143,7 +231,7 @@ export component ComboboxRoot(
143
231
  const [activeId, setActiveId] = useState<string | null>(null);
144
232
  const [count, setCount] = useState(0);
145
233
  const [labelled, setLabelled] = useState(false);
146
- const pendingActive = useRef<"first" | "last" | null>(null);
234
+ const pendingActiveRef = useRef<"first" | "last" | null>(null);
147
235
  const inputRef = useRef<HTMLElement | null>(null);
148
236
  const listRef = useRef<HTMLElement | null>(null);
149
237
 
@@ -177,7 +265,7 @@ export component ComboboxRoot(
177
265
  clear,
178
266
  activeId,
179
267
  setActiveId,
180
- pendingActive,
268
+ pendingActiveRef,
181
269
  inputRef,
182
270
  listRef,
183
271
  count,
@@ -190,7 +278,10 @@ export component ComboboxRoot(
190
278
 
191
279
  return (
192
280
  <ComboboxContext.Provider value={state}>
193
- <div {...rest}>{children}</div>
281
+ <div {...rest}>
282
+ {children}
283
+ {name == null ? null : <FormValue name={name} value={chosen} />}
284
+ </div>
194
285
  </ComboboxContext.Provider>
195
286
  );
196
287
  }
@@ -203,7 +294,7 @@ export component ComboboxRoot(
203
294
  * because the list names it, and naming a label that is not rendered is worse
204
295
  * than leaving the list unnamed.
205
296
  */
206
- export component ComboboxLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
297
+ export component ComboboxLabel(children: React.Node, ...rest: Rest) {
207
298
  const combobox = useCombobox("Combobox.Label");
208
299
  const register = combobox.registerLabel;
209
300
  useEffect(() => {
@@ -219,8 +310,10 @@ export component ComboboxLabel(children: React.Node, ...rest: { readonly [string
219
310
  }
220
311
 
221
312
  /** The text field, and every key the pattern defines. */
222
- export component ComboboxInput(...rest: { readonly [string]: mixed }) {
313
+ export component ComboboxInput(...rest: Rest) {
223
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
224
317
  const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
225
318
 
226
319
  /** The options in the document right now, in document order. */
@@ -234,7 +327,8 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
234
327
  if (items.length === 0) {
235
328
  // The list is not in the document yet, so leave an instruction for the
236
329
  // commit that puts it there.
237
- combobox.pendingActive.current = movement === "next" ? "first" : "last";
330
+ // uf-lint-disable-next-line react-compiler/immutability
331
+ combobox.pendingActiveRef.current = movement === "next" ? "first" : "last";
238
332
  return;
239
333
  }
240
334
  const at = items.findIndex((item) => item.id === combobox.activeId);
@@ -267,6 +361,8 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
267
361
  // The browser's own dropdown would sit on top of this one.
268
362
  autoComplete="off"
269
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
270
366
  onChange={composeHandlers(rest.onChange, (event: $FlowFixMe) => {
271
367
  combobox.setText(event.target.value);
272
368
  combobox.setOpen(true);
@@ -275,6 +371,8 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
275
371
  // Enter takes something the reader can no longer see.
276
372
  combobox.setActiveId(null);
277
373
  })}
374
+ // Key events read list refs and update the virtual active descendant.
375
+ // uf-lint-disable-next-line react-compiler/refs
278
376
  onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
279
377
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
280
378
  event.preventDefault();
@@ -320,7 +418,10 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
320
418
  combobox.setActiveId(null);
321
419
  }
322
420
  })}
421
+ // React calls callback refs during commit; keyboard handlers read the input later.
422
+ // uf-lint-disable-next-line react-compiler/refs
323
423
  ref={composeRefs(rest.ref, (element) => {
424
+ // uf-lint-disable-next-line react-compiler/immutability
324
425
  combobox.inputRef.current = element;
325
426
  })}
326
427
  role="combobox"
@@ -333,26 +434,60 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
333
434
  /**
334
435
  * The list of options, in the document only while it is open.
335
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
+ *
336
441
  * It also keeps the two things that have to stay true as the caller filters:
337
442
  * the count the live region announces, and the invariant that
338
443
  * `aria-activedescendant` never names an option that has left the list.
339
444
  */
340
445
  export component ComboboxList(
341
- children: renders* ComboboxOption,
342
- ...rest: { readonly [string]: mixed }
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
343
454
  ) {
344
455
  const combobox = useCombobox("Combobox.List");
345
- const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
456
+ const { activeId, count, listRef, inputRef, pendingActiveRef, setActiveId, setCount } = combobox;
346
457
  const close = useStableCallback(() => {
347
458
  combobox.setOpen(false);
348
459
  combobox.setActiveId(null);
349
460
  });
350
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
+
351
484
  // No dependency list on purpose: what this reads is the *rendered* options,
352
485
  // and they change whenever the caller re-filters — which is a change to
353
486
  // `children` that no dependency list can describe. Every write below is
354
487
  // guarded by a comparison, so the effect settles after one extra pass rather
355
488
  // than looping.
489
+ // This effect measures caller-rendered options after commit.
490
+ // uf-lint-disable-next-line react-compiler/immutability
356
491
  useEffect(() => {
357
492
  const list = listRef.current;
358
493
  if (list == null) {
@@ -368,9 +503,10 @@ export component ComboboxList(
368
503
  setCount(items.length);
369
504
  }
370
505
 
371
- const wanted = pendingActive.current;
506
+ const wanted = pendingActiveRef.current;
372
507
  if (wanted != null) {
373
- pendingActive.current = null;
508
+ // uf-lint-disable-next-line react-compiler/immutability
509
+ pendingActiveRef.current = null;
374
510
  setActiveId(moveTo(items, -1, wanted, false)?.id ?? null);
375
511
  return;
376
512
  }
@@ -381,33 +517,18 @@ export component ComboboxList(
381
517
  }
382
518
  });
383
519
 
384
- // Keyed on `combobox.open`, and that is load-bearing. This component is
385
- // mounted the whole time and only *renders* while the list is open, so keyed
386
- // on the stable callbacks alone the effect ran once — on the first commit,
387
- // when `listRef.current` was still null — and never again. The listener was
388
- // never attached, and a press outside the combobox closed nothing.
389
- useEffect(() => {
390
- const list = listRef.current;
391
- if (list == null) {
392
- return;
393
- }
394
- const document = list.ownerDocument;
395
- const onOutsidePress = (event: Event) => {
396
- const target: $FlowFixMe = event.target;
397
- if (target == null || list.contains(target)) {
398
- return;
399
- }
400
- // The field is not "outside": pressing it is how a reader gets back to
401
- // typing, and closing on it would fight the input's own handlers.
402
- const input = inputRef.current;
403
- if (input != null && input.contains(target)) {
404
- return;
405
- }
406
- close();
407
- };
408
- document.addEventListener("pointerdown", onOutsidePress, true);
409
- return () => document.removeEventListener("pointerdown", onOutsidePress, true);
410
- }, [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
+ });
411
532
 
412
533
  if (!combobox.open) {
413
534
  return null;
@@ -416,9 +537,11 @@ export component ComboboxList(
416
537
  const passed = withoutComposed(rest, ["ref"]);
417
538
 
418
539
  return (
419
- <ul
540
+ <div
420
541
  {...passed}
421
542
  aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
543
+ data-align={anchored.align}
544
+ data-side={anchored.side}
422
545
  id={`${combobox.base}-list`}
423
546
  ref={composeRefs(rest.ref, (element) => {
424
547
  listRef.current = element;
@@ -426,7 +549,7 @@ export component ComboboxList(
426
549
  role="listbox"
427
550
  >
428
551
  {children}
429
- </ul>
552
+ </div>
430
553
  );
431
554
  }
432
555
 
@@ -444,7 +567,7 @@ export component ComboboxOption(
444
567
  children: React.Node,
445
568
  label?: string,
446
569
  disabled?: boolean = false,
447
- ...rest: { readonly [string]: mixed }
570
+ ...rest: Rest
448
571
  ) {
449
572
  const combobox = useCombobox("Combobox.Option");
450
573
  const id = useId();
@@ -452,7 +575,7 @@ export component ComboboxOption(
452
575
  const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
453
576
 
454
577
  return (
455
- <li
578
+ <div
456
579
  {...passed}
457
580
  aria-disabled={disabled ? "true" : undefined}
458
581
  aria-selected={combobox.value === value ? "true" : "false"}
@@ -484,7 +607,72 @@ export component ComboboxOption(
484
607
  role="option"
485
608
  >
486
609
  {children}
487
- </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>
488
676
  );
489
677
  }
490
678
 
@@ -495,7 +683,7 @@ export component ComboboxOption(
495
683
  * contain options: an "no matches" row inside one is announced as an option a
496
684
  * reader can choose, and choosing it does nothing.
497
685
  */
498
- export component ComboboxEmpty(children: React.Node, ...rest: { readonly [string]: mixed }) {
686
+ export component ComboboxEmpty(children: React.Node, ...rest: Rest) {
499
687
  const combobox = useCombobox("Combobox.Empty");
500
688
  if (!combobox.open || combobox.count > 0) {
501
689
  return null;
@@ -514,7 +702,7 @@ export component ComboboxEmpty(children: React.Node, ...rest: { readonly [string
514
702
  * `children` overrides the wording — the default is English and a real
515
703
  * application has a translation table.
516
704
  */
517
- export component ComboboxStatus(children?: React.Node, ...rest: { readonly [string]: mixed }) {
705
+ export component ComboboxStatus(children?: React.Node, ...rest: Rest) {
518
706
  const combobox = useCombobox("Combobox.Status");
519
707
  const message = children ?? defaultAnnouncement(combobox.open, combobox.count);
520
708