@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/carousel.js ADDED
@@ -0,0 +1,410 @@
1
+ // @flow
2
+ //
3
+ // A carousel: content that moves, which is the one thing on a page a
4
+ // specification tells you to let people stop.
5
+ //
6
+ // # What it gives that a list does not
7
+ //
8
+ // Nothing, for a reader who can see it. A carousel is a list of things shown
9
+ // one at a time, and the plain HTML it replaces — a list — is better at every
10
+ // job except fitting in a small space. So the whole of this module is the part
11
+ // that keeps the replacement from being worse than the list:
12
+ //
13
+ // * **It can be stopped.** WCAG 2.2.2, *Pause, Stop, Hide*: anything that
14
+ // starts automatically, moves, and lasts more than five seconds needs a
15
+ // mechanism to pause it. `Carousel.Pause` is that mechanism, and it must be
16
+ // the **first** focusable thing inside the carousel — a pause button after
17
+ // the slides is a pause button nobody reaches in time. That is enforced
18
+ // here rather than suggested: an autoplaying carousel whose first focus
19
+ // stop is not the pause control raises.
20
+ // * **It says what it is.** `aria-roledescription="carousel"` on a named
21
+ // group, and `aria-roledescription="slide"` with "3 of 7" on each slide.
22
+ // Without them a reader is told "group, group" and has no way to know
23
+ // where they are or how much of it there is.
24
+ // * **It stops announcing itself while it moves.** The slide container is
25
+ // `aria-live="off"` while it is rotating and `"polite"` while it is not.
26
+ // A live region that reads out every slide of an auto-rotating carousel is
27
+ // unusable, and one that never announces anything makes the Next button
28
+ // silent.
29
+ // * **`Tab` cannot walk into a slide nobody can see.** This is the bug that
30
+ // survives every other fix. The slides that are scrolled out of view are
31
+ // still in the DOM, so their links and buttons are still focus stops — and
32
+ // a reader who tabs into one is in content the page is not showing. They
33
+ // are `inert`, which takes them out of the tab order *and* out of the
34
+ // accessibility tree, and which `internal/focus.js` already skips.
35
+ // * **It respects `prefers-reduced-motion`.** A reader who asked their system
36
+ // to stop moving things gets a carousel that does not rotate on its own.
37
+ // `usePrefersReducedMotion` from `@uniflowed/hooks/browser`.
38
+ //
39
+ // Rotation also stops while the pointer is over it and while focus is inside
40
+ // it — a reader in the middle of reading a slide should not have it taken away
41
+ // — and once `Carousel.Pause` has been pressed it stays stopped, because that
42
+ // was a decision rather than a hover.
43
+ //
44
+ // # Why the caller counts the slides
45
+ //
46
+ // `Carousel.Root` takes `count` and `Carousel.Item` takes `index`, the same way
47
+ // `Table.Root` takes `rowCount` and `Table.Row` takes `index`. The alternative
48
+ // — counting the children — is wrong the first time a caller renders a slide
49
+ // conditionally, filters a list, or wraps one in a component of their own, and
50
+ // it is wrong silently: the label says "3 of 6" in a carousel with seven
51
+ // slides, which is exactly the sentence a reader is relying on.
52
+ //
53
+ // # It is a `group`, not a `region`
54
+ //
55
+ // A `region` is a landmark, and a landmark is a promise that this is one of the
56
+ // handful of places worth jumping to on the page. A gallery of photographs
57
+ // three screens down is not, and a page with four carousels in it would put
58
+ // four entries in a reader's landmark list. `role="group"` says the same thing
59
+ // about the relationship between the slides without making that claim; a caller
60
+ // whose carousel *is* the page can pass `role="region"` and get it.
61
+
62
+ "use client";
63
+
64
+ import * as React from "@uniflowed/react";
65
+ import {
66
+ createContext,
67
+ useCallback,
68
+ useContext,
69
+ useEffect,
70
+ useId,
71
+ useMemo,
72
+ useRef,
73
+ useState,
74
+ } from "@uniflowed/react";
75
+ import { usePrefersReducedMotion } from "@uniflowed/hooks/browser";
76
+
77
+ import type { Orientation } from "./internal/roving-focus.js";
78
+ import type { Rest } from "./internal/merge-props.js";
79
+ import {
80
+ composeHandlers,
81
+ composeRefs,
82
+ forwarded,
83
+ withoutComposed,
84
+ } from "./internal/merge-props.js";
85
+ import { focusable } from "./internal/focus.js";
86
+ import { useControlled } from "./internal/controlled-state.js";
87
+
88
+ export type { Orientation } from "./internal/roving-focus.js";
89
+
90
+ type CarouselState = {|
91
+ readonly base: string,
92
+ readonly count: number,
93
+ readonly index: number,
94
+ readonly setIndex: (next: number) => void,
95
+ readonly loop: boolean,
96
+ readonly orientation: Orientation,
97
+ /** Whether it is rotating right now, which is what `aria-live` reads. */
98
+ readonly rotating: boolean,
99
+ /** Whether the reader stopped it on purpose, which nothing but they undo. */
100
+ readonly stopped: boolean,
101
+ readonly setStopped: (stopped: boolean) => void,
102
+ /** Whether a rotation was ever asked for, so `Carousel.Pause` can say so. */
103
+ readonly rotates: boolean,
104
+ readonly registerPause: (present: boolean) => void,
105
+ |};
106
+
107
+ const CarouselContext: React.Context<CarouselState | null> = createContext(null);
108
+
109
+ /**
110
+ * The carousel a part belongs to.
111
+ *
112
+ * Raising rather than returning null, for the reason `useDialog` gives: a
113
+ * `Carousel.Item` outside a root would render a slide labelled "1 of 0".
114
+ */
115
+ hook useCarousel(part: string): CarouselState {
116
+ const state = useContext(CarouselContext);
117
+ if (state == null) {
118
+ throw new Error(`${part} must be rendered inside a Carousel.Root`);
119
+ }
120
+ return state;
121
+ }
122
+
123
+ /**
124
+ * The carousel: a named group of slides, one of them showing.
125
+ *
126
+ * `autoplay` is how many milliseconds each slide is shown for, or `null` for a
127
+ * carousel that only moves when it is asked to. A carousel that rotates must
128
+ * hold a `Carousel.Pause`, and that one must be the first thing `Tab` reaches
129
+ * inside it; both are checked.
130
+ */
131
+ export component CarouselRoot(
132
+ children: React.Node,
133
+ autoplay?: number | null = null,
134
+ count: number,
135
+ defaultIndex?: number = 0,
136
+ index?: number,
137
+ label: string,
138
+ loop?: boolean = true,
139
+ onIndexChange?: (index: number) => void,
140
+ orientation?: Orientation = "horizontal",
141
+ ...rest: Rest
142
+ ) {
143
+ const base = useId();
144
+ const [current, setIndex] = useControlled(index, defaultIndex, onIndexChange);
145
+ const rootRef = useRef<HTMLElement | null>(null);
146
+ const paused = useRef(0);
147
+ const [stopped, setStopped] = useState(false);
148
+ // Whether the pointer or focus is resting on it. State rather than a ref,
149
+ // because the timer below is an effect and has to be torn down when it
150
+ // changes.
151
+ const [held, setHeld] = useState(false);
152
+ const reducedMotion = usePrefersReducedMotion();
153
+ const passed = withoutComposed(rest, [
154
+ "onBlur",
155
+ "onFocus",
156
+ "onPointerEnter",
157
+ "onPointerLeave",
158
+ "ref",
159
+ ]);
160
+ // Stable, so `Carousel.Pause`'s registration effect runs once rather than
161
+ // once per render of the root — which would decrement and re-increment the
162
+ // count, and leave it at zero for exactly as long as it takes the check
163
+ // below to read it.
164
+ const registerPause = useCallback((present: boolean) => {
165
+ paused.current += present ? 1 : -1;
166
+ }, []);
167
+
168
+ // A reader who asked their system to stop moving things has answered this
169
+ // question already, and the answer is not "rotate anyway and offer a button".
170
+ const rotates = autoplay != null && !reducedMotion;
171
+ const rotating = rotates && !stopped && !held;
172
+
173
+ const state = useMemo(
174
+ () => ({
175
+ base,
176
+ count,
177
+ index: current,
178
+ loop,
179
+ orientation,
180
+ registerPause,
181
+ rotates,
182
+ rotating,
183
+ setIndex,
184
+ setStopped,
185
+ stopped,
186
+ }),
187
+ [base, count, current, loop, orientation, registerPause, rotates, rotating, setIndex, stopped],
188
+ );
189
+
190
+ useEffect(() => {
191
+ if (!rotating || count <= 1) {
192
+ return;
193
+ }
194
+ // The global timer rather than the document's, the same as
195
+ // `internal/hover-intent.js`: one clock for the package, and the one a
196
+ // caller's fake timers replace.
197
+ const timer = setTimeout(() => {
198
+ setIndex(current === count - 1 ? 0 : current + 1);
199
+ }, autoplay ?? 0);
200
+ return () => {
201
+ clearTimeout(timer);
202
+ };
203
+ // `current` is named, so each slide's turn is timed from the moment it
204
+ // arrived rather than from a repeating interval that keeps running while
205
+ // the reader presses Next.
206
+ }, [autoplay, count, current, rotating, setIndex]);
207
+
208
+ useEffect(() => {
209
+ const root = rootRef.current;
210
+ if (!rotates || root == null) {
211
+ return;
212
+ }
213
+ if (paused.current === 0) {
214
+ throw new Error(
215
+ "A Carousel.Root with autoplay must hold a Carousel.Pause: WCAG 2.2.2 " +
216
+ "requires a mechanism to stop anything that moves by itself for more " +
217
+ "than five seconds.",
218
+ );
219
+ }
220
+ const first = focusable(root)[0];
221
+ if (first == null || first.getAttribute("data-uf-carousel-pause") == null) {
222
+ throw new Error(
223
+ "Carousel.Pause must be the first focusable element inside " +
224
+ "Carousel.Root: a pause control the reader reaches after the slides " +
225
+ "is one they reach after the thing they wanted to stop.",
226
+ );
227
+ }
228
+ }, [rotates]);
229
+
230
+ return (
231
+ <CarouselContext.Provider value={state}>
232
+ <div
233
+ {...passed}
234
+ aria-label={label}
235
+ // What the reader is told instead of "group". Everything else here is
236
+ // arrangement; this is the sentence.
237
+ aria-roledescription="carousel"
238
+ data-orientation={orientation}
239
+ onBlur={composeHandlers(rest.onBlur, (event: $FlowFixMe) => {
240
+ if (!event.currentTarget?.contains?.(event.relatedTarget)) {
241
+ setHeld(false);
242
+ }
243
+ })}
244
+ // Rotation stops while a reader is in it and starts again when they
245
+ // leave — unless they stopped it deliberately, which `stopped` keeps.
246
+ onFocus={composeHandlers(rest.onFocus, () => setHeld(true))}
247
+ onPointerEnter={composeHandlers(rest.onPointerEnter, () => setHeld(true))}
248
+ onPointerLeave={composeHandlers(rest.onPointerLeave, () => setHeld(false))}
249
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
250
+ rootRef.current = element;
251
+ })}
252
+ role="group"
253
+ >
254
+ {children}
255
+ </div>
256
+ </CarouselContext.Provider>
257
+ );
258
+ }
259
+
260
+ /**
261
+ * The slides, and the live region that says which one is showing.
262
+ *
263
+ * `aria-live="off"` while it rotates: a live region reading out a slide every
264
+ * four seconds is a page a screen reader cannot be used on. `"polite"` the rest
265
+ * of the time, so pressing Next says something.
266
+ */
267
+ export component CarouselContent(children: React.Node, ...rest: Rest) {
268
+ const carousel = useCarousel("Carousel.Content");
269
+
270
+ return (
271
+ <div
272
+ {...rest}
273
+ aria-live={carousel.rotating ? "off" : "polite"}
274
+ data-orientation={carousel.orientation}
275
+ id={`${carousel.base}-content`}
276
+ >
277
+ {children}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ /**
283
+ * One slide, which says where in the set it is and gets out of the way when it
284
+ * is not the one showing.
285
+ *
286
+ * `inert` rather than a class: the slides that are not showing are still in the
287
+ * document, and without it `Tab` walks into a link nobody can see. It also
288
+ * takes the subtree out of the accessibility tree, which is what stops a reader
289
+ * being read six slides in a row.
290
+ */
291
+ export component CarouselItem(children: React.Node, index: number, ...rest: Rest) {
292
+ const carousel = useCarousel("Carousel.Item");
293
+ const current = index === carousel.index;
294
+
295
+ return (
296
+ <div
297
+ {...rest}
298
+ // "3 of 7", which is the only way a reader knows where they are. A caller
299
+ // who has a better name for the slide keeps it.
300
+ aria-label={
301
+ rest["aria-label"] == null && rest["aria-labelledby"] == null
302
+ ? `${String(index + 1)} of ${String(carousel.count)}`
303
+ : undefined
304
+ }
305
+ aria-roledescription="slide"
306
+ data-state={current ? "active" : "inactive"}
307
+ // React renders `inert` from a boolean, and `undefined` removes it.
308
+ inert={current ? undefined : true}
309
+ role="group"
310
+ >
311
+ {children}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ /**
317
+ * The control WCAG 2.2.2 is about, and the first thing `Tab` reaches.
318
+ *
319
+ * It says which state pressing it produces, which is what a toggle button is
320
+ * for: `aria-pressed` on a pause button is the announcement "pause, pressed",
321
+ * and a reader who has stopped a carousel wants to be told it is stopped.
322
+ */
323
+ export component CarouselPause(
324
+ children?: React.Node,
325
+ pauseLabel?: string = "Stop the carousel",
326
+ playLabel?: string = "Start the carousel",
327
+ ...rest: Rest
328
+ ) {
329
+ const carousel = useCarousel("Carousel.Pause");
330
+ const register = carousel.registerPause;
331
+ const passed = withoutComposed(rest, ["onClick"]);
332
+ const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
333
+
334
+ useEffect(() => {
335
+ register(true);
336
+ return () => register(false);
337
+ }, [register]);
338
+
339
+ return (
340
+ <button
341
+ {...passed}
342
+ aria-controls={`${carousel.base}-content`}
343
+ aria-label={named ? undefined : carousel.stopped ? playLabel : pauseLabel}
344
+ aria-pressed={carousel.stopped ? "true" : "false"}
345
+ // How `Carousel.Root` recognises this button as the pause control without
346
+ // reaching into React's tree, which it has no way to do from an effect.
347
+ data-uf-carousel-pause=""
348
+ onClick={composeHandlers(rest.onClick, () => carousel.setStopped(!carousel.stopped))}
349
+ type="button"
350
+ >
351
+ {children}
352
+ </button>
353
+ );
354
+ }
355
+
356
+ /** The button that goes back one slide. */
357
+ export component CarouselPrevious(
358
+ children?: React.Node,
359
+ label?: string = "Previous slide",
360
+ ...rest: Rest
361
+ ) {
362
+ return (
363
+ <CarouselStep {...forwarded(rest)} label={label} step={-1}>
364
+ {children}
365
+ </CarouselStep>
366
+ );
367
+ }
368
+
369
+ /** The button that goes forward one slide. */
370
+ export component CarouselNext(children?: React.Node, label?: string = "Next slide", ...rest: Rest) {
371
+ return (
372
+ <CarouselStep {...forwarded(rest)} label={label} step={1}>
373
+ {children}
374
+ </CarouselStep>
375
+ );
376
+ }
377
+
378
+ /**
379
+ * Both of the stepping buttons.
380
+ *
381
+ * One component because the difference is a sign and a name, and two copies of
382
+ * the wrapping arithmetic is how a carousel comes to loop in one direction and
383
+ * stop in the other.
384
+ */
385
+ component CarouselStep(children?: React.Node, label: string, step: number, ...rest: Rest) {
386
+ const carousel = useCarousel(step < 0 ? "Carousel.Previous" : "Carousel.Next");
387
+ const passed = withoutComposed(rest, ["onClick"]);
388
+ const last = carousel.count - 1;
389
+ const at = step < 0 ? 0 : last;
390
+ const wrapped = step < 0 ? last : 0;
391
+ const ends = carousel.index === at;
392
+
393
+ return (
394
+ <button
395
+ {...passed}
396
+ aria-controls={`${carousel.base}-content`}
397
+ aria-label={rest["aria-label"] == null ? label : undefined}
398
+ // Disabled at the end of a carousel that does not loop, because a button
399
+ // that does nothing is a button a reader presses twice before believing
400
+ // it.
401
+ disabled={!carousel.loop && ends}
402
+ onClick={composeHandlers(rest.onClick, () => {
403
+ carousel.setIndex(ends ? wrapped : carousel.index + step);
404
+ })}
405
+ type="button"
406
+ >
407
+ {children}
408
+ </button>
409
+ );
410
+ }
package/checkbox.js CHANGED
@@ -20,22 +20,197 @@
20
20
  // `indeterminate` is a prop, and clicking a mixed checkbox reports `true`,
21
21
  // which is the state a reader expects "select all" to move to.
22
22
  //
23
- // # `Enter` is deliberately not handled
23
+ // # `Enter` submits the form; it does not toggle
24
24
  //
25
- // `Space` toggles; `Enter` is left alone, so a checkbox inside a form still
26
- // submits it. That is the difference between a control that answers a question
27
- // and one that operates a thing — `switch.js` takes `Enter` because a switch is
28
- // the second kind.
25
+ // This is the difference between a control that answers a question and one that
26
+ // operates a thing — `switch.js` takes `Enter` because a switch is the second
27
+ // kind — and for a long time this header claimed it by *leaving the key alone*.
28
+ // Neither half of that claim survived contact with the component, which is
29
+ // ubugeeei-prod/uf#324:
30
+ //
31
+ // * **A `<button type="button">` never submits a form.** That is the whole of
32
+ // what `type="button"` means, and this renders one. So the form was not
33
+ // submitted by anybody.
34
+ // * **And leaving a key unhandled does not make it inert.** It lets the
35
+ // *default action* happen, and the default action of `Enter` on a focused
36
+ // `<button>` is a click — which this component's own `onClick` turns into a
37
+ // toggle. So a focused checkbox both failed to submit the form and changed
38
+ // its own state, which is the opposite of the intent on both counts.
39
+ //
40
+ // What a native `<input type="checkbox">` does with `Enter` is *implicit
41
+ // submission*: the key does not touch the checkbox, and the form around it is
42
+ // submitted as though its default button had been pressed. That is the
43
+ // behaviour this replaces, so it is the behaviour it owes, and it is written
44
+ // out here because a styled substitute that silently drops it is exactly the
45
+ // kind of regression `index.js` says this package exists to prevent.
46
+ //
47
+ // So `Enter` is claimed — `preventDefault()`, which is what stops the browser's
48
+ // click and the toggle behind it — and turned back into a submission of the
49
+ // `<form>` the control is in. Outside a form it does nothing at all, which is
50
+ // again what the native control does. `requestSubmit` rather than `submit`
51
+ // because only the first fires the `submit` event and runs constraint
52
+ // validation, and a `<form onSubmit>` that never heard about the submission is
53
+ // the failure this would otherwise trade for the old one.
54
+ //
55
+ // The default button is passed to it rather than left out. `requestSubmit()`
56
+ // with no argument submits with *no* submitter, so a form whose handler reads
57
+ // `event.submitter` — a Server Action's `formAction`, a "save" and a "save and
58
+ // close" beside each other — would be told nobody pressed anything. Implicit
59
+ // submission names the default button, so this does too.
60
+ //
61
+ // Which button that is, and whether a form with no button submits at all, are
62
+ // both the specification's questions rather than this component's, and both are
63
+ // answered below: a submit button belongs to the form that *owns* it rather
64
+ // than to the form it sits inside, and a form with no submit button submits
65
+ // itself only while at most one of its fields blocks implicit submission.
29
66
 
30
67
  "use client";
31
68
 
32
69
  import * as React from "@uniflowed/react";
33
70
 
34
- import type { Rest } from "./internal/merge-props.js";
35
- import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
71
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
72
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
36
73
  import { useControlled } from "./internal/controlled-state.js";
37
74
 
38
- /** A checkbox, which may also be mixed. */
75
+ /**
76
+ * The button a form would submit itself through, or nothing.
77
+ *
78
+ * The specification's "default button" is the first submit button in tree order
79
+ * **whose form owner is this form**, and neither half of that is "a
80
+ * descendant". A control's form owner is the `form` attribute when it has one
81
+ * and the nearest ancestor `<form>` otherwise, so the two cases a subtree
82
+ * search gets wrong are both real markup:
83
+ *
84
+ * * `<button type="submit" form="signup">` *beside* the form — the pattern a
85
+ * dialog's footer is written in — is the default button and a subtree
86
+ * search never sees it. Missing it is not a small error: it falls through
87
+ * to the no-submitter branch, which is the exact `event.submitter` this
88
+ * component exists to answer.
89
+ * * `<button type="submit" form="other">` *inside* the form belongs to the
90
+ * other one, and handing it to `requestSubmit` throws `NotFoundError` —
91
+ * which reaches a reader as a key that does nothing and a console the page
92
+ * did not write.
93
+ *
94
+ * So the search is over the form's root and each candidate is asked which form
95
+ * it belongs to. The root rather than the document, because a form in a shadow
96
+ * tree, or one rendered but not yet inserted, has to find its own buttons and
97
+ * only its own.
98
+ *
99
+ * A `<button>` with no `type` is a submit button, which is the case most easily
100
+ * missed. A disabled one is skipped, because the browser skips it — implicit
101
+ * submission through a button nobody could press is not a thing the platform
102
+ * does.
103
+ */
104
+ function defaultButtonOf(form: HTMLElement): HTMLElement | null {
105
+ const searched: $FlowFixMe = (form as $FlowFixMe).getRootNode?.() ?? form.ownerDocument;
106
+ if (searched == null || typeof searched.querySelectorAll !== "function") {
107
+ return null;
108
+ }
109
+ const candidates = searched.querySelectorAll(
110
+ 'button:not([type]), button[type="submit"], input[type="submit"], input[type="image"]',
111
+ );
112
+ for (const candidate of candidates) {
113
+ const button: $FlowFixMe = candidate;
114
+ if (button.form === form && button.disabled !== true) {
115
+ return button as $FlowFixMe;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * The `<input>` types that block implicit submission.
123
+ *
124
+ * The specification's list, copied rather than reasoned about, because what is
125
+ * being reproduced is what the browser does. A checkbox, a radio, a hidden
126
+ * field, a `<select>` and a `<textarea>` are not on it.
127
+ */
128
+ const BLOCKING_TYPES: Set<string> = new Set([
129
+ "date",
130
+ "datetime-local",
131
+ "email",
132
+ "month",
133
+ "number",
134
+ "password",
135
+ "search",
136
+ "tel",
137
+ "text",
138
+ "time",
139
+ "url",
140
+ "week",
141
+ ]);
142
+
143
+ /**
144
+ * Whether the platform would decline to submit `form` from the form itself.
145
+ *
146
+ * The other half of the implicit submission rule, and the half a script never
147
+ * meets: a form with **no** submit button is submitted implicitly only when at
148
+ * most one of its fields blocks implicit submission. A login form with a
149
+ * username and a password and no button is the everyday case — `Enter` in
150
+ * either field does nothing at all in every browser.
151
+ *
152
+ * `requestSubmit()` does not apply that rule, and is right not to: it is the
153
+ * route a script takes to submit deliberately. A component reproducing the
154
+ * *implicit* mechanism has to apply it here, or `Enter` on a checkbox submits
155
+ * forms that `Enter` in the text field beside it would not — which is the same
156
+ * class of divergence this whole change is about, pointing the other way.
157
+ */
158
+ function moreThanOneFieldBlocks(form: $FlowFixMe): boolean {
159
+ const fields: $FlowFixMe = form.elements;
160
+ if (fields == null) {
161
+ return false;
162
+ }
163
+ let blocking = 0;
164
+ for (const field of fields) {
165
+ const control: $FlowFixMe = field;
166
+ // `.type` rather than the attribute: it is missing on `<input>` and any
167
+ // value the specification does not know is the Text state, and both of
168
+ // those block.
169
+ if (control.tagName === "INPUT" && BLOCKING_TYPES.has(String(control.type))) {
170
+ blocking += 1;
171
+ if (blocking > 1) {
172
+ return true;
173
+ }
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Submit the form this control is in, the way `Enter` on a native checkbox does.
181
+ *
182
+ * Does nothing when there is no form, when the browser has no `requestSubmit` —
183
+ * it is everywhere current, and a checkbox that threw on an old one would be
184
+ * worse than a key that does nothing — or when the form is one the platform
185
+ * would not submit implicitly either, which is the rule
186
+ * `moreThanOneFieldBlocks` states.
187
+ */
188
+ function submitImplicitly(control: HTMLElement): void {
189
+ const form: $FlowFixMe = (control as $FlowFixMe).form;
190
+ if (form == null || typeof form.requestSubmit !== "function") {
191
+ return;
192
+ }
193
+ const submitter = defaultButtonOf(form);
194
+ if (submitter == null) {
195
+ // A form with no submit button submits itself, but only under the rule
196
+ // `moreThanOneFieldBlocks` carries — `requestSubmit()` will not apply it,
197
+ // so this does.
198
+ if (!moreThanOneFieldBlocks(form)) {
199
+ form.requestSubmit();
200
+ }
201
+ return;
202
+ }
203
+ form.requestSubmit(submitter);
204
+ }
205
+
206
+ /**
207
+ * A checkbox, which may also be mixed.
208
+ *
209
+ * `render` is the escape hatch, and the implicit submission above is the reason
210
+ * it hands over `onKeyDown` rather than attaching it: whatever element a caller
211
+ * renders is the one `Enter` arrives on, and it is that element's `form` the
212
+ * key walks up to.
213
+ */
39
214
  export component Checkbox(
40
215
  checked?: boolean,
41
216
  defaultChecked?: boolean = false,
@@ -43,6 +218,7 @@ export component Checkbox(
43
218
  onCheckedChange?: (checked: boolean) => void,
44
219
  disabled?: boolean = false,
45
220
  children?: React.Node,
221
+ render?: RenderProp,
46
222
  ...rest: Rest
47
223
  ) {
48
224
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
@@ -50,31 +226,39 @@ export component Checkbox(
50
226
  // underneath it": a half-selected "select all" that clears itself on the
51
227
  // first click is the behaviour every table in every application gets wrong.
52
228
  const next = indeterminate ? true : !on;
53
- const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
54
-
55
- return (
56
- <button
57
- {...passed}
58
- aria-checked={indeterminate ? "mixed" : on ? "true" : "false"}
59
- disabled={disabled}
60
- onClick={composeHandlers(rest.onClick, () => {
61
- if (!disabled) {
62
- setOn(next);
63
- }
64
- })}
65
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
66
- if (disabled || event.key !== " ") {
67
- return;
68
- }
229
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
230
+ "aria-checked": indeterminate ? "mixed" : on ? "true" : "false",
231
+ children,
232
+ disabled,
233
+ onClick: composeHandlers(rest.onClick, () => {
234
+ if (!disabled) {
235
+ setOn(next);
236
+ }
237
+ }),
238
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
239
+ if (disabled) {
240
+ return;
241
+ }
242
+ if (event.key === " ") {
69
243
  // Stops `Space` scrolling the page, and stops the browser's own click
70
244
  // arriving afterwards and toggling this a second time.
71
245
  event.preventDefault();
72
246
  setOn(next);
73
- })}
74
- role="checkbox"
75
- type="button"
76
- >
77
- {children}
78
- </button>
79
- );
247
+ return;
248
+ }
249
+ if (event.key === "Enter") {
250
+ // Claimed, and *not* to make the key inert: the default action here
251
+ // is a click on this button, and a click on this button toggles. See
252
+ // the module header for the whole of it.
253
+ event.preventDefault();
254
+ submitImplicitly(event.currentTarget as $FlowFixMe);
255
+ }
256
+ }),
257
+ role: "checkbox",
258
+ });
259
+
260
+ if (render != null) {
261
+ return render(props);
262
+ }
263
+ return <button {...props} type="button" />;
80
264
  }