@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/pagination.js
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
import * as React from "@uniflowed/react";
|
|
46
46
|
|
|
47
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
48
|
-
import { withoutComposed } from "./internal/merge-props.js";
|
|
47
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
48
|
+
import { withProps, withoutComposed } from "./internal/merge-props.js";
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* The pagination, as a named landmark, and the region that announces it.
|
|
@@ -62,16 +62,16 @@ export component PaginationRoot(
|
|
|
62
62
|
page?: number | null = null,
|
|
63
63
|
pageCount?: number | null = null,
|
|
64
64
|
announcePage?: (page: number, pageCount: number) => string,
|
|
65
|
+
render?: RenderProp,
|
|
65
66
|
...rest: Rest
|
|
66
67
|
) {
|
|
67
68
|
const message =
|
|
68
69
|
page == null || pageCount == null ? "" : (announcePage ?? defaultAnnouncement)(page, pageCount);
|
|
70
|
+
const props = withProps(rest, { "aria-label": label, children });
|
|
69
71
|
|
|
70
72
|
return (
|
|
71
73
|
<>
|
|
72
|
-
<nav {...
|
|
73
|
-
{children}
|
|
74
|
-
</nav>
|
|
74
|
+
{render == null ? <nav {...props} /> : render(withProps(props, { role: "navigation" }))}
|
|
75
75
|
{/*
|
|
76
76
|
Beside the navigation rather than inside it, so a reader walking the
|
|
77
77
|
landmark hears the links and not a sentence about them — and mounted
|
|
@@ -93,9 +93,14 @@ export component PaginationRoot(
|
|
|
93
93
|
*/
|
|
94
94
|
export component PaginationContent(
|
|
95
95
|
children: renders* (PaginationItem | PaginationPrevious | PaginationNext),
|
|
96
|
+
render?: RenderProp,
|
|
96
97
|
...rest: Rest
|
|
97
98
|
) {
|
|
98
|
-
|
|
99
|
+
const props = withProps(rest, { children });
|
|
100
|
+
if (render != null) {
|
|
101
|
+
return render(withProps(props, { role: "list" }));
|
|
102
|
+
}
|
|
103
|
+
return <ul {...props} />;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
@@ -108,10 +113,11 @@ export component PaginationItem(
|
|
|
108
113
|
children: React.Node,
|
|
109
114
|
current?: boolean = false,
|
|
110
115
|
disabled?: boolean = false,
|
|
116
|
+
render?: RenderProp,
|
|
111
117
|
...rest: Rest
|
|
112
118
|
) {
|
|
113
119
|
return (
|
|
114
|
-
<PageLink current={current} disabled={disabled} rest={rest}>
|
|
120
|
+
<PageLink current={current} disabled={disabled} render={render} rest={rest}>
|
|
115
121
|
{children}
|
|
116
122
|
</PageLink>
|
|
117
123
|
);
|
|
@@ -129,10 +135,11 @@ export component PaginationPrevious(
|
|
|
129
135
|
children?: React.Node,
|
|
130
136
|
label?: string = "Previous page",
|
|
131
137
|
disabled?: boolean = false,
|
|
138
|
+
render?: RenderProp,
|
|
132
139
|
...rest: Rest
|
|
133
140
|
) {
|
|
134
141
|
return (
|
|
135
|
-
<PageLink disabled={disabled} label={label} rest={rest}>
|
|
142
|
+
<PageLink disabled={disabled} label={label} render={render} rest={rest}>
|
|
136
143
|
{children}
|
|
137
144
|
</PageLink>
|
|
138
145
|
);
|
|
@@ -143,10 +150,11 @@ export component PaginationNext(
|
|
|
143
150
|
children?: React.Node,
|
|
144
151
|
label?: string = "Next page",
|
|
145
152
|
disabled?: boolean = false,
|
|
153
|
+
render?: RenderProp,
|
|
146
154
|
...rest: Rest
|
|
147
155
|
) {
|
|
148
156
|
return (
|
|
149
|
-
<PageLink disabled={disabled} label={label} rest={rest}>
|
|
157
|
+
<PageLink disabled={disabled} label={label} render={render} rest={rest}>
|
|
150
158
|
{children}
|
|
151
159
|
</PageLink>
|
|
152
160
|
);
|
|
@@ -162,11 +170,10 @@ export component PaginationNext(
|
|
|
162
170
|
* say, correctly, that `disabled` might not be a boolean. Handing the bag over
|
|
163
171
|
* as one value keeps it a bag until it reaches the element it was always for.
|
|
164
172
|
*
|
|
165
|
-
* `disabled` drops the `href`
|
|
166
|
-
*
|
|
167
|
-
* tab order
|
|
168
|
-
*
|
|
169
|
-
* it another way is told why it does nothing.
|
|
173
|
+
* `disabled` drops the `href` and keeps the link role. That is the ARIA shape
|
|
174
|
+
* for "this page direction exists, but cannot be taken now": it is out of the
|
|
175
|
+
* tab order because there is no `href`, still named as Previous or Next, and
|
|
176
|
+
* announced as disabled rather than as a silent generic element.
|
|
170
177
|
*/
|
|
171
178
|
component PageLink(
|
|
172
179
|
rest: Rest,
|
|
@@ -174,19 +181,24 @@ component PageLink(
|
|
|
174
181
|
current?: boolean = false,
|
|
175
182
|
disabled?: boolean = false,
|
|
176
183
|
label?: string,
|
|
184
|
+
render?: RenderProp,
|
|
177
185
|
) {
|
|
178
186
|
const passed = withoutComposed(rest, disabled ? ["href"] : []);
|
|
187
|
+
const props = withProps(passed, {
|
|
188
|
+
"aria-current": current ? "page" : undefined,
|
|
189
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
190
|
+
"aria-label": label,
|
|
191
|
+
children,
|
|
192
|
+
role: disabled ? "link" : undefined,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (render != null) {
|
|
196
|
+
return <li>{render(withProps(props, { role: "link" }))}</li>;
|
|
197
|
+
}
|
|
179
198
|
|
|
180
199
|
return (
|
|
181
200
|
<li>
|
|
182
|
-
<a
|
|
183
|
-
{...passed}
|
|
184
|
-
aria-current={current ? "page" : undefined}
|
|
185
|
-
aria-disabled={disabled ? "true" : undefined}
|
|
186
|
-
aria-label={label}
|
|
187
|
-
>
|
|
188
|
-
{children}
|
|
189
|
-
</a>
|
|
201
|
+
<a {...props} />
|
|
190
202
|
</li>
|
|
191
203
|
);
|
|
192
204
|
}
|
package/popover.js
CHANGED
|
@@ -58,15 +58,22 @@ import {
|
|
|
58
58
|
} from "@uniflowed/react";
|
|
59
59
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
60
60
|
|
|
61
|
-
import
|
|
62
|
-
|
|
63
|
-
import {
|
|
61
|
+
import { useInteractOutside } from "./interactions.js";
|
|
62
|
+
|
|
63
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
64
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
65
|
+
import {
|
|
66
|
+
composeHandlers,
|
|
67
|
+
composeRefs,
|
|
68
|
+
withProps,
|
|
69
|
+
withoutComposed,
|
|
70
|
+
} from "./internal/merge-props.js";
|
|
64
71
|
import { focusable } from "./internal/focus.js";
|
|
65
72
|
import { useAnchor } from "./internal/anchor.js";
|
|
66
73
|
import { useControlled } from "./internal/controlled-state.js";
|
|
67
74
|
import { usePresence } from "./internal/disclosure.js";
|
|
68
75
|
|
|
69
|
-
export type { Align, Side } from "./internal/anchor.js";
|
|
76
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
70
77
|
|
|
71
78
|
type PopoverState = {|
|
|
72
79
|
readonly base: string,
|
|
@@ -145,30 +152,31 @@ export component PopoverRoot(
|
|
|
145
152
|
* trigger is not "outside" for exactly this reason — closing there and letting
|
|
146
153
|
* this click reopen made the press a no-op that flickered.
|
|
147
154
|
*/
|
|
148
|
-
export component PopoverTrigger(children: React.Node, ...rest: Rest) {
|
|
155
|
+
export component PopoverTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
149
156
|
const popover = usePopover("Popover.Trigger");
|
|
150
157
|
const passed = withoutComposed(rest, ["onClick", "ref"]);
|
|
151
158
|
usePresence(popover.registerTrigger);
|
|
159
|
+
const props = withProps(passed, {
|
|
160
|
+
// Only while it is open: an `aria-controls` naming an element that is not
|
|
161
|
+
// in the document tells a reader there is somewhere to go and then has
|
|
162
|
+
// nowhere to send them.
|
|
163
|
+
"aria-controls": popover.open ? `${popover.base}-body` : undefined,
|
|
164
|
+
"aria-expanded": popover.open ? "true" : "false",
|
|
165
|
+
"aria-haspopup": "dialog",
|
|
166
|
+
children,
|
|
167
|
+
id: `${popover.base}-trigger`,
|
|
168
|
+
onClick: composeHandlers(rest.onClick, () => popover.setOpen(!popover.open)),
|
|
169
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
170
|
+
// React calls callback refs during commit; focus restoration reads it later.
|
|
171
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
172
|
+
popover.triggerRef.current = element;
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
152
175
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// in the document tells a reader there is somewhere to go and then has
|
|
158
|
-
// nowhere to send them.
|
|
159
|
-
aria-controls={popover.open ? `${popover.base}-body` : undefined}
|
|
160
|
-
aria-expanded={popover.open ? "true" : "false"}
|
|
161
|
-
aria-haspopup="dialog"
|
|
162
|
-
id={`${popover.base}-trigger`}
|
|
163
|
-
onClick={composeHandlers(rest.onClick, () => popover.setOpen(!popover.open))}
|
|
164
|
-
ref={composeRefs(rest.ref, (element) => {
|
|
165
|
-
popover.triggerRef.current = element;
|
|
166
|
-
})}
|
|
167
|
-
type="button"
|
|
168
|
-
>
|
|
169
|
-
{children}
|
|
170
|
-
</button>
|
|
171
|
-
);
|
|
176
|
+
if (render != null) {
|
|
177
|
+
return render(props);
|
|
178
|
+
}
|
|
179
|
+
return <button {...props} type="button" />;
|
|
172
180
|
}
|
|
173
181
|
|
|
174
182
|
/**
|
|
@@ -188,7 +196,16 @@ export component PopoverBody(
|
|
|
188
196
|
alignOffset?: number = 0,
|
|
189
197
|
avoidCollisions?: boolean = true,
|
|
190
198
|
collisionPadding?: number = 0,
|
|
191
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Where focus lands when it opens, when the first focus stop is the wrong
|
|
201
|
+
* answer. The same prop `Dialog.Body` takes, deliberately spelled the same
|
|
202
|
+
* way: `DatePicker.Calendar` fills it with the day that holds the grid's tab
|
|
203
|
+
* stop, because a reader who opened a date picker is looking for the date and
|
|
204
|
+
* not for the button that steps back a month.
|
|
205
|
+
*/
|
|
206
|
+
initialFocus?: { current: HTMLElement | null },
|
|
207
|
+
render?: RenderProp,
|
|
208
|
+
side?: LogicalSide = "bottom",
|
|
192
209
|
sideOffset?: number = 0,
|
|
193
210
|
...rest: Rest
|
|
194
211
|
) {
|
|
@@ -205,13 +222,19 @@ export component PopoverBody(
|
|
|
205
222
|
const left = useRef(false);
|
|
206
223
|
const triggerRef = popover.triggerRef;
|
|
207
224
|
|
|
225
|
+
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
226
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
208
227
|
const anchored = useAnchor({
|
|
209
228
|
align,
|
|
210
229
|
alignOffset,
|
|
230
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
211
231
|
anchorRef: triggerRef,
|
|
212
232
|
avoidCollisions,
|
|
213
233
|
collisionPadding,
|
|
234
|
+
// `open` is popover metadata; no ref value is read during render.
|
|
235
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
214
236
|
open: popover.open,
|
|
237
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
215
238
|
overlayRef: bodyRef,
|
|
216
239
|
side,
|
|
217
240
|
sideOffset,
|
|
@@ -234,17 +257,6 @@ export component PopoverBody(
|
|
|
234
257
|
return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
|
|
235
258
|
};
|
|
236
259
|
|
|
237
|
-
const onOutsidePress = (event: Event) => {
|
|
238
|
-
if (!outside(event.target)) {
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
left.current = true;
|
|
242
|
-
close();
|
|
243
|
-
};
|
|
244
|
-
// Capture, so a press is seen even where something below it stops the
|
|
245
|
-
// event — a menu inside the popover, for instance.
|
|
246
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
247
|
-
|
|
248
260
|
// Tab out is a dismissal, not an escape hatch that leaves a popover open
|
|
249
261
|
// behind the reader: a non-modal overlay whose reader has gone is one they
|
|
250
262
|
// can no longer press Escape at, because Escape is handled where focus is.
|
|
@@ -257,13 +269,17 @@ export component PopoverBody(
|
|
|
257
269
|
};
|
|
258
270
|
document.addEventListener("focusin", onFocusMoved, true);
|
|
259
271
|
|
|
260
|
-
//
|
|
261
|
-
// nothing focusable
|
|
262
|
-
// the handler below.
|
|
263
|
-
|
|
272
|
+
// Where the caller said, then the first thing worth acting on, then the
|
|
273
|
+
// popover itself when it holds nothing focusable - so focus is inside it
|
|
274
|
+
// whichever of the three answers, and Escape reaches the handler below.
|
|
275
|
+
//
|
|
276
|
+
// The named element has to still be *in* this popover, for the reason
|
|
277
|
+
// `dialog.js` gives at the same line: a ref left from a previous opening
|
|
278
|
+
// would move focus somewhere the reader did not open.
|
|
279
|
+
const named = initialFocus?.current ?? null;
|
|
280
|
+
((named != null && body.contains(named) ? named : focusable(body)[0]) ?? body).focus();
|
|
264
281
|
|
|
265
282
|
return () => {
|
|
266
|
-
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
267
283
|
document.removeEventListener("focusin", onFocusMoved, true);
|
|
268
284
|
if (left.current) {
|
|
269
285
|
left.current = false;
|
|
@@ -278,8 +294,28 @@ export component PopoverBody(
|
|
|
278
294
|
opener?.focus?.();
|
|
279
295
|
}
|
|
280
296
|
};
|
|
281
|
-
|
|
297
|
+
// The ref objects are stable; this effect reads them after the opening commit.
|
|
298
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
299
|
+
}, [popover.open, triggerRef, close, initialFocus]);
|
|
282
300
|
|
|
301
|
+
// A press outside is the other way a reader leaves, and the trigger is not
|
|
302
|
+
// "outside" for the reason the module header gives.
|
|
303
|
+
// The outside-interaction hook accepts refs; it reads them from event handlers.
|
|
304
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
305
|
+
useInteractOutside({
|
|
306
|
+
// `open` is popover metadata; the hook reads refs from event handlers.
|
|
307
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
308
|
+
isDisabled: !popover.open,
|
|
309
|
+
onInteractOutside: () => {
|
|
310
|
+
left.current = true;
|
|
311
|
+
close();
|
|
312
|
+
},
|
|
313
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
314
|
+
refs: [bodyRef, triggerRef],
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// `open` is popover metadata; no ref value is read during render.
|
|
318
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
283
319
|
if (!popover.open) {
|
|
284
320
|
return null;
|
|
285
321
|
}
|
|
@@ -290,37 +326,42 @@ export component PopoverBody(
|
|
|
290
326
|
// leave the popover announced as its button rather than as itself.
|
|
291
327
|
const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
|
|
292
328
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
329
|
+
const props = withProps(passed, {
|
|
330
|
+
// `triggered` and `base` are popover metadata, not ref values.
|
|
331
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
332
|
+
"aria-labelledby": named || !popover.triggered ? undefined : `${popover.base}-trigger`,
|
|
333
|
+
children,
|
|
334
|
+
"data-align": anchored.align,
|
|
335
|
+
"data-side": anchored.side,
|
|
336
|
+
"data-state": "open",
|
|
337
|
+
// `base` is popover metadata, not a ref value.
|
|
338
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
339
|
+
id: `${popover.base}-body`,
|
|
340
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
341
|
+
if (event.key !== "Escape") {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
// This popover, not the dialog around it. Two overlays nest in the
|
|
346
|
+
// DOM, so without this one Escape closed both.
|
|
347
|
+
event.stopPropagation();
|
|
348
|
+
close();
|
|
349
|
+
}),
|
|
350
|
+
// React calls callback refs during commit; placement effects and traps read it later.
|
|
351
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
352
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
353
|
+
bodyRef.current = element;
|
|
354
|
+
}),
|
|
355
|
+
// No `aria-modal`. The page behind a popover is still available, and
|
|
356
|
+
// saying otherwise is the one lie a screen reader cannot see through.
|
|
357
|
+
role: "dialog",
|
|
358
|
+
// So the popover can hold focus itself when it contains nothing focusable,
|
|
359
|
+
// and so Escape has somewhere to be heard.
|
|
360
|
+
tabIndex: -1,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (render != null) {
|
|
364
|
+
return render(props);
|
|
365
|
+
}
|
|
366
|
+
return <div {...props} />;
|
|
326
367
|
}
|
package/progress.js
CHANGED
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
|
|
39
39
|
import * as React from "@uniflowed/react";
|
|
40
40
|
|
|
41
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
41
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
42
|
+
import { withProps } from "./internal/merge-props.js";
|
|
42
43
|
import { clamp } from "./internal/range.js";
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -57,6 +58,9 @@ import { clamp } from "./internal/range.js";
|
|
|
57
58
|
* `Slider`, whose value may be its own, a progress bar's value arrived as a
|
|
58
59
|
* prop, so the caller already has everything they need to size a bar with and
|
|
59
60
|
* a helpful custom property would only be their own arithmetic handed back.
|
|
61
|
+
*
|
|
62
|
+
* `render` changes the element and not the accessibility contract: the
|
|
63
|
+
* progressbar role and value attributes are the props handed to the caller.
|
|
60
64
|
*/
|
|
61
65
|
export component Progress(
|
|
62
66
|
value?: number | null = null,
|
|
@@ -64,23 +68,24 @@ export component Progress(
|
|
|
64
68
|
max?: number = 100,
|
|
65
69
|
valueText?: string,
|
|
66
70
|
children?: React.Node,
|
|
71
|
+
render?: RenderProp,
|
|
67
72
|
...rest: Rest
|
|
68
73
|
) {
|
|
69
74
|
const known = value == null ? null : clamp(value, min, max);
|
|
75
|
+
const props = withProps(rest, {
|
|
76
|
+
"aria-valuemax": max,
|
|
77
|
+
"aria-valuemin": min,
|
|
78
|
+
// Omitted, not zeroed. `aria-valuenow="0"` tells a reader that nothing
|
|
79
|
+
// has happened; leaving it out tells them the amount is unknown, which
|
|
80
|
+
// is the true one and the one a spinner means.
|
|
81
|
+
"aria-valuenow": known ?? undefined,
|
|
82
|
+
"aria-valuetext": valueText,
|
|
83
|
+
children,
|
|
84
|
+
role: "progressbar",
|
|
85
|
+
});
|
|
70
86
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
aria-valuemin={min}
|
|
76
|
-
// Omitted, not zeroed. `aria-valuenow="0"` tells a reader that nothing
|
|
77
|
-
// has happened; leaving it out tells them the amount is unknown, which
|
|
78
|
-
// is the true one and the one a spinner means.
|
|
79
|
-
aria-valuenow={known ?? undefined}
|
|
80
|
-
aria-valuetext={valueText}
|
|
81
|
-
role="progressbar"
|
|
82
|
-
>
|
|
83
|
-
{children}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
87
|
+
if (render != null) {
|
|
88
|
+
return render(props);
|
|
89
|
+
}
|
|
90
|
+
return <div {...props} />;
|
|
86
91
|
}
|
package/radio-group.js
CHANGED
|
@@ -76,8 +76,13 @@
|
|
|
76
76
|
import * as React from "@uniflowed/react";
|
|
77
77
|
import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
|
|
78
78
|
|
|
79
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
80
|
-
import {
|
|
79
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
80
|
+
import {
|
|
81
|
+
composeHandlers,
|
|
82
|
+
composeRefs,
|
|
83
|
+
withoutComposed,
|
|
84
|
+
withProps,
|
|
85
|
+
} from "./internal/merge-props.js";
|
|
81
86
|
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
82
87
|
import type { Orientation, RovingSet } from "./internal/roving-focus.js";
|
|
83
88
|
import { useControlled } from "./internal/controlled-state.js";
|
|
@@ -146,6 +151,7 @@ export component RadioGroupRoot(
|
|
|
146
151
|
onValueChange?: (value: string) => void,
|
|
147
152
|
orientation?: Orientation = "vertical",
|
|
148
153
|
name?: string,
|
|
154
|
+
render?: RenderProp,
|
|
149
155
|
...rest: Rest
|
|
150
156
|
) {
|
|
151
157
|
// `onValueChange` promises a `string` while the group's *state* is
|
|
@@ -171,44 +177,48 @@ export component RadioGroupRoot(
|
|
|
171
177
|
|
|
172
178
|
const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
|
|
173
179
|
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
180
|
+
const content = (
|
|
181
|
+
<>
|
|
182
|
+
{children}
|
|
183
|
+
{/*
|
|
184
|
+
A form submits `<input>` elements, and none of the parts above is one.
|
|
185
|
+
Without this the group is a control a reader can operate and a form
|
|
186
|
+
cannot read, which is the same hole `Combobox` still has.
|
|
187
|
+
|
|
188
|
+
`type="hidden"` rather than a visually hidden real radio, because the
|
|
189
|
+
buttons above already carry the whole of the accessible semantics: a
|
|
190
|
+
second set of native radios would be announced as a second set of
|
|
191
|
+
answers, and hiding them from the accessibility tree to stop that
|
|
192
|
+
leaves elements a form's own validation would then point its
|
|
193
|
+
"please choose one" at.
|
|
194
|
+
*/}
|
|
195
|
+
{name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
const props = withProps(passed, {
|
|
199
|
+
"aria-orientation": orientation,
|
|
200
|
+
children: content,
|
|
201
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
202
|
+
const group: $FlowFixMe = event.currentTarget;
|
|
203
|
+
const next = moveOnKey(event, group, radioSet(orientation));
|
|
204
|
+
if (next != null) {
|
|
205
|
+
// Checking in the same key press is not a shortcut, it is the
|
|
206
|
+
// pattern: a radio group whose arrows moved focus without checking
|
|
207
|
+
// leaves a reader believing they have answered when they have not.
|
|
208
|
+
select(next.getAttribute("data-value") ?? "");
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
// React calls callback refs during commit; keyboard handlers read it later.
|
|
212
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
213
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
214
|
+
rootRef.current = element;
|
|
215
|
+
}),
|
|
216
|
+
role: "radiogroup",
|
|
217
|
+
});
|
|
174
218
|
|
|
175
219
|
return (
|
|
176
220
|
<RadioGroupContext.Provider value={state}>
|
|
177
|
-
<div
|
|
178
|
-
{...passed}
|
|
179
|
-
// A reader is told which axis this runs along, and it is also what says
|
|
180
|
-
// which pair of arrow keys is live.
|
|
181
|
-
aria-orientation={orientation}
|
|
182
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
183
|
-
const group: $FlowFixMe = event.currentTarget;
|
|
184
|
-
const next = moveOnKey(event, group, radioSet(orientation));
|
|
185
|
-
if (next != null) {
|
|
186
|
-
// Checking in the same key press is not a shortcut, it is the
|
|
187
|
-
// pattern: a radio group whose arrows moved focus without checking
|
|
188
|
-
// leaves a reader believing they have answered when they have not.
|
|
189
|
-
select(next.getAttribute("data-value") ?? "");
|
|
190
|
-
}
|
|
191
|
-
})}
|
|
192
|
-
ref={composeRefs(rest.ref, (element) => {
|
|
193
|
-
rootRef.current = element;
|
|
194
|
-
})}
|
|
195
|
-
role="radiogroup"
|
|
196
|
-
>
|
|
197
|
-
{children}
|
|
198
|
-
{/*
|
|
199
|
-
A form submits `<input>` elements, and none of the parts above is one.
|
|
200
|
-
Without this the group is a control a reader can operate and a form
|
|
201
|
-
cannot read, which is the same hole `Combobox` still has.
|
|
202
|
-
|
|
203
|
-
`type="hidden"` rather than a visually hidden real radio, because the
|
|
204
|
-
buttons above already carry the whole of the accessible semantics: a
|
|
205
|
-
second set of native radios would be announced as a second set of
|
|
206
|
-
answers, and hiding them from the accessibility tree to stop that
|
|
207
|
-
leaves elements a form's own validation would then point its
|
|
208
|
-
"please choose one" at.
|
|
209
|
-
*/}
|
|
210
|
-
{name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
|
|
211
|
-
</div>
|
|
221
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
212
222
|
</RadioGroupContext.Provider>
|
|
213
223
|
);
|
|
214
224
|
}
|
|
@@ -226,6 +236,7 @@ export component RadioGroupItem(
|
|
|
226
236
|
value: string,
|
|
227
237
|
children?: React.Node,
|
|
228
238
|
disabled?: boolean = false,
|
|
239
|
+
render?: RenderProp,
|
|
229
240
|
...rest: Rest
|
|
230
241
|
) {
|
|
231
242
|
const group = useRadioGroup("RadioGroup.Item");
|
|
@@ -233,41 +244,39 @@ export component RadioGroupItem(
|
|
|
233
244
|
const checked = group.selected === value;
|
|
234
245
|
const item = useMemo(() => ({ checked }), [checked]);
|
|
235
246
|
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
247
|
+
const props = withProps(passed, {
|
|
248
|
+
"aria-checked": checked ? "true" : "false",
|
|
249
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
250
|
+
// Read by the group's key handler, which finds items in the document
|
|
251
|
+
// rather than in a registry and so needs each one to carry its value.
|
|
252
|
+
"data-value": value,
|
|
253
|
+
children,
|
|
254
|
+
id,
|
|
255
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
256
|
+
if (!disabled) {
|
|
257
|
+
group.select(value);
|
|
258
|
+
}
|
|
259
|
+
}),
|
|
260
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
261
|
+
if (disabled || event.key !== " ") {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Stops `Space` scrolling the page — which is what makes a
|
|
265
|
+
// hand-written radio feel broken even when it works — and stops the
|
|
266
|
+
// browser's own click arriving afterwards to check this again.
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
group.select(value);
|
|
269
|
+
}),
|
|
270
|
+
role: "radio",
|
|
271
|
+
// The roving tab stop: the chosen answer, or the first one while there
|
|
272
|
+
// is no answer, so `Tab` reaches the group in either state and leaves
|
|
273
|
+
// it in one press.
|
|
274
|
+
tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
|
|
275
|
+
});
|
|
236
276
|
|
|
237
277
|
return (
|
|
238
278
|
<RadioItemContext.Provider value={item}>
|
|
239
|
-
<button
|
|
240
|
-
{...passed}
|
|
241
|
-
aria-checked={checked ? "true" : "false"}
|
|
242
|
-
aria-disabled={disabled ? "true" : undefined}
|
|
243
|
-
// Read by the group's key handler, which finds items in the document
|
|
244
|
-
// rather than in a registry and so needs each one to carry its value.
|
|
245
|
-
data-value={value}
|
|
246
|
-
id={id}
|
|
247
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
248
|
-
if (!disabled) {
|
|
249
|
-
group.select(value);
|
|
250
|
-
}
|
|
251
|
-
})}
|
|
252
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
253
|
-
if (disabled || event.key !== " ") {
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
// Stops `Space` scrolling the page — which is what makes a
|
|
257
|
-
// hand-written radio feel broken even when it works — and stops the
|
|
258
|
-
// browser's own click arriving afterwards to check this again.
|
|
259
|
-
event.preventDefault();
|
|
260
|
-
group.select(value);
|
|
261
|
-
})}
|
|
262
|
-
role="radio"
|
|
263
|
-
// The roving tab stop: the chosen answer, or the first one while there
|
|
264
|
-
// is no answer, so `Tab` reaches the group in either state and leaves
|
|
265
|
-
// it in one press.
|
|
266
|
-
tabIndex={checked || (group.selected == null && group.firstId === id) ? 0 : -1}
|
|
267
|
-
type="button"
|
|
268
|
-
>
|
|
269
|
-
{children}
|
|
270
|
-
</button>
|
|
279
|
+
{render == null ? <button {...props} type="button" /> : render(props)}
|
|
271
280
|
</RadioItemContext.Provider>
|
|
272
281
|
);
|
|
273
282
|
}
|
|
@@ -282,7 +291,7 @@ export component RadioGroupItem(
|
|
|
282
291
|
* `[aria-checked="true"] > *`, and so the "only while chosen" part is not
|
|
283
292
|
* something each caller reimplements.
|
|
284
293
|
*/
|
|
285
|
-
export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
|
|
294
|
+
export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
286
295
|
const item = useContext(RadioItemContext);
|
|
287
296
|
if (item == null) {
|
|
288
297
|
throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
|
|
@@ -290,9 +299,6 @@ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
|
|
|
290
299
|
if (!item.checked) {
|
|
291
300
|
return null;
|
|
292
301
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
{children}
|
|
296
|
-
</span>
|
|
297
|
-
);
|
|
302
|
+
const props = withProps(rest, { "aria-hidden": "true", children });
|
|
303
|
+
return render == null ? <span {...props} /> : render(props);
|
|
298
304
|
}
|