@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.
- 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 +560 -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 +235 -198
- 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 +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -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 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -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 +112 -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 +404 -0
- 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`
|
|
23
|
+
// # `Enter` submits the form; it does not toggle
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
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
|
-
/**
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|