@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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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 =
|
|
506
|
+
const wanted = pendingActiveRef.current;
|
|
383
507
|
if (wanted != null) {
|
|
384
|
-
|
|
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
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
package/context-menu.js
ADDED
|
@@ -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";
|