@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/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
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A popover: a dialog that is not modal, which is the whole of the difference.
|
|
4
|
+
//
|
|
5
|
+
// `dialog.js` is modal and only modal, on purpose — every line of it is a
|
|
6
|
+
// promise that the rest of the page is unavailable. A popover makes the
|
|
7
|
+
// opposite promise, and it has to make it in every one of the same places:
|
|
8
|
+
//
|
|
9
|
+
// * No `aria-modal`, because the page behind is still there.
|
|
10
|
+
// * Nothing is made `inert` and nothing is `aria-hidden`, because a reader
|
|
11
|
+
// may still reach it.
|
|
12
|
+
// * The page is not scroll-locked, because a wheel over a popover scrolling
|
|
13
|
+
// the page behind is what a reader expects from something that did not
|
|
14
|
+
// take the page over.
|
|
15
|
+
// * **`Tab` leaves.** This is the load-bearing one. A trap is what makes a
|
|
16
|
+
// modal dialog safe and what makes a popover a hole a reader falls into:
|
|
17
|
+
// they tabbed in, they tab out, and a component that wraps them back to
|
|
18
|
+
// the first control has taken the page away without ever saying so.
|
|
19
|
+
//
|
|
20
|
+
// So a popover is not a `Dialog` with a flag. A flag would mean every one of
|
|
21
|
+
// the behaviours above reading it, and the failure of the one that forgot would
|
|
22
|
+
// be a dialog that is not modal while announcing that it is — silent, and
|
|
23
|
+
// wrong in the direction that traps people.
|
|
24
|
+
//
|
|
25
|
+
// What it *does* share with a dialog is the part a reader notices when it is
|
|
26
|
+
// missing: focus moves into it when it opens, `Escape` closes it, and focus
|
|
27
|
+
// goes back to the trigger — unless the reader dismissed it by pressing or
|
|
28
|
+
// tabbing somewhere else, in which case it stays where they put it.
|
|
29
|
+
//
|
|
30
|
+
// # Where it goes
|
|
31
|
+
//
|
|
32
|
+
// `internal/anchor.js`, the same as every other overlay here: `side`, `align`,
|
|
33
|
+
// `sideOffset` and `alignOffset` place it against the trigger, it flips and
|
|
34
|
+
// slides to stay on the screen, and it reports where it ended up as `data-side`
|
|
35
|
+
// and `data-align` so a stylesheet can point an arrow without measuring
|
|
36
|
+
// anything itself.
|
|
37
|
+
//
|
|
38
|
+
// # Its name
|
|
39
|
+
//
|
|
40
|
+
// `role="dialog"` needs an accessible name, and a popover has no `Title` part
|
|
41
|
+
// to take one from — shadcn's does not either, and adding one would make the
|
|
42
|
+
// common case (a form, a colour picker, a date picker) carry a heading nobody
|
|
43
|
+
// asked for. So the body is named after the button that opened it, which is
|
|
44
|
+
// true and is what a reader would say the popover is, and a caller who passes
|
|
45
|
+
// `aria-label` or `aria-labelledby` of their own keeps it.
|
|
46
|
+
|
|
47
|
+
"use client";
|
|
48
|
+
|
|
49
|
+
import * as React from "@uniflowed/react";
|
|
50
|
+
import {
|
|
51
|
+
createContext,
|
|
52
|
+
useContext,
|
|
53
|
+
useEffect,
|
|
54
|
+
useId,
|
|
55
|
+
useMemo,
|
|
56
|
+
useRef,
|
|
57
|
+
useState,
|
|
58
|
+
} from "@uniflowed/react";
|
|
59
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
60
|
+
|
|
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";
|
|
71
|
+
import { focusable } from "./internal/focus.js";
|
|
72
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
73
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
74
|
+
import { usePresence } from "./internal/disclosure.js";
|
|
75
|
+
|
|
76
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
77
|
+
|
|
78
|
+
type PopoverState = {|
|
|
79
|
+
readonly base: string,
|
|
80
|
+
readonly open: boolean,
|
|
81
|
+
readonly setOpen: (open: boolean) => void,
|
|
82
|
+
/** What opened it, where it is anchored, and where focus goes back to. */
|
|
83
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
84
|
+
/**
|
|
85
|
+
* Whether a trigger is rendered, so the body only names one that exists.
|
|
86
|
+
*
|
|
87
|
+
* A popover opened by `defaultOpen` in a page with no trigger is a real
|
|
88
|
+
* arrangement — a first-run hint pointing at something — and an
|
|
89
|
+
* `aria-labelledby` naming the id that trigger *would* have had makes a
|
|
90
|
+
* screen reader announce nothing at all.
|
|
91
|
+
*/
|
|
92
|
+
readonly triggered: boolean,
|
|
93
|
+
readonly registerTrigger: (present: boolean) => void,
|
|
94
|
+
|};
|
|
95
|
+
|
|
96
|
+
const PopoverContext: React.Context<PopoverState | null> = createContext(null);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The popover a part belongs to.
|
|
100
|
+
*
|
|
101
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
102
|
+
* `Popover.Body` outside a root would render a `role="dialog"` that nothing
|
|
103
|
+
* opens, closes or names, and it would look correct.
|
|
104
|
+
*/
|
|
105
|
+
hook usePopover(part: string): PopoverState {
|
|
106
|
+
const state = useContext(PopoverContext);
|
|
107
|
+
if (state == null) {
|
|
108
|
+
throw new Error(`${part} must be rendered inside a Popover.Root`);
|
|
109
|
+
}
|
|
110
|
+
return state;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The popover, open or closed. Uncontrolled unless `open` is given.
|
|
115
|
+
*
|
|
116
|
+
* Renders no element of its own: the trigger and the body are siblings in
|
|
117
|
+
* whatever layout the caller wrote, and a wrapper would put a `<div>` between
|
|
118
|
+
* them for the caller to style around.
|
|
119
|
+
*/
|
|
120
|
+
export component PopoverRoot(
|
|
121
|
+
children: React.Node,
|
|
122
|
+
defaultOpen?: boolean = false,
|
|
123
|
+
open?: boolean,
|
|
124
|
+
onOpenChange?: (open: boolean) => void,
|
|
125
|
+
) {
|
|
126
|
+
const base = useId();
|
|
127
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
128
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
129
|
+
const [triggered, setTriggered] = useState(false);
|
|
130
|
+
|
|
131
|
+
const state = useMemo(
|
|
132
|
+
() => ({
|
|
133
|
+
base,
|
|
134
|
+
open: isOpen,
|
|
135
|
+
registerTrigger: setTriggered,
|
|
136
|
+
setOpen,
|
|
137
|
+
triggerRef,
|
|
138
|
+
triggered,
|
|
139
|
+
}),
|
|
140
|
+
[base, isOpen, setOpen, triggered],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return <PopoverContext.Provider value={state}>{children}</PopoverContext.Provider>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The button that opens the popover, and what it is anchored to.
|
|
148
|
+
*
|
|
149
|
+
* A toggle rather than an opener: pressing the button of an open popover closes
|
|
150
|
+
* it, which is what every disclosure does and what a reader who pressed it by
|
|
151
|
+
* accident expects. The outside-press handler in `Popover.Body` knows the
|
|
152
|
+
* trigger is not "outside" for exactly this reason — closing there and letting
|
|
153
|
+
* this click reopen made the press a no-op that flickered.
|
|
154
|
+
*/
|
|
155
|
+
export component PopoverTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
156
|
+
const popover = usePopover("Popover.Trigger");
|
|
157
|
+
const passed = withoutComposed(rest, ["onClick", "ref"]);
|
|
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
|
+
});
|
|
175
|
+
|
|
176
|
+
if (render != null) {
|
|
177
|
+
return render(props);
|
|
178
|
+
}
|
|
179
|
+
return <button {...props} type="button" />;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The popover itself: positioned, focused, dismissible, and not a trap.
|
|
184
|
+
*
|
|
185
|
+
* The three ways out are the three a reader tries. `Escape` closes it and gives
|
|
186
|
+
* focus back to the trigger, because the reader is still where they were. A
|
|
187
|
+
* press outside closes it and leaves focus alone, because they have already
|
|
188
|
+
* moved on. `Tab` past the last control inside closes it for the same reason
|
|
189
|
+
* and leaves focus where the browser put it — which is the behaviour that
|
|
190
|
+
* separates this from a dialog, and the one a copy of `dialog.js` with the
|
|
191
|
+
* `aria-modal` deleted would get wrong.
|
|
192
|
+
*/
|
|
193
|
+
export component PopoverBody(
|
|
194
|
+
children: React.Node,
|
|
195
|
+
align?: Align = "center",
|
|
196
|
+
alignOffset?: number = 0,
|
|
197
|
+
avoidCollisions?: boolean = true,
|
|
198
|
+
collisionPadding?: number = 0,
|
|
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",
|
|
209
|
+
sideOffset?: number = 0,
|
|
210
|
+
...rest: Rest
|
|
211
|
+
) {
|
|
212
|
+
const popover = usePopover("Popover.Body");
|
|
213
|
+
const bodyRef = useRef<HTMLElement | null>(null);
|
|
214
|
+
// Stable, so the effect below depends on `open` and on nothing else. Keyed on
|
|
215
|
+
// `setOpen` it re-ran whenever the caller passed a fresh `onOpenChange`
|
|
216
|
+
// closure — which is every render — and re-running it took focus back from
|
|
217
|
+
// wherever the reader had moved it inside the popover.
|
|
218
|
+
const close = useStableCallback(() => popover.setOpen(false));
|
|
219
|
+
// Set when the reader left rather than closed: a press outside, or a Tab that
|
|
220
|
+
// carried them out. Focus is theirs from then on, and dragging it back to the
|
|
221
|
+
// trigger would undo the thing they just did.
|
|
222
|
+
const left = useRef(false);
|
|
223
|
+
const triggerRef = popover.triggerRef;
|
|
224
|
+
|
|
225
|
+
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
226
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
227
|
+
const anchored = useAnchor({
|
|
228
|
+
align,
|
|
229
|
+
alignOffset,
|
|
230
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
231
|
+
anchorRef: triggerRef,
|
|
232
|
+
avoidCollisions,
|
|
233
|
+
collisionPadding,
|
|
234
|
+
// `open` is popover metadata; no ref value is read during render.
|
|
235
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
236
|
+
open: popover.open,
|
|
237
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
238
|
+
overlayRef: bodyRef,
|
|
239
|
+
side,
|
|
240
|
+
sideOffset,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const body = bodyRef.current;
|
|
245
|
+
if (!popover.open || body == null) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const document = body.ownerDocument;
|
|
249
|
+
const trigger = triggerRef.current;
|
|
250
|
+
// Whatever had focus, which is the trigger for a popover that was opened
|
|
251
|
+
// and the previously focused element for one that opened itself.
|
|
252
|
+
const opener = trigger ?? (document.activeElement as $FlowFixMe);
|
|
253
|
+
|
|
254
|
+
const outside = (target: EventTarget | null): boolean => {
|
|
255
|
+
const node: $FlowFixMe = target;
|
|
256
|
+
// The trigger is outside the body and is not "outside" for this purpose.
|
|
257
|
+
return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Tab out is a dismissal, not an escape hatch that leaves a popover open
|
|
261
|
+
// behind the reader: a non-modal overlay whose reader has gone is one they
|
|
262
|
+
// can no longer press Escape at, because Escape is handled where focus is.
|
|
263
|
+
const onFocusMoved = (event: Event) => {
|
|
264
|
+
if (!outside(event.target)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
left.current = true;
|
|
268
|
+
close();
|
|
269
|
+
};
|
|
270
|
+
document.addEventListener("focusin", onFocusMoved, true);
|
|
271
|
+
|
|
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();
|
|
281
|
+
|
|
282
|
+
return () => {
|
|
283
|
+
document.removeEventListener("focusin", onFocusMoved, true);
|
|
284
|
+
if (left.current) {
|
|
285
|
+
left.current = false;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Only when focus would otherwise be lost to `<body>`, the same
|
|
289
|
+
// condition `menu.js` restores under: a popover closed by a control
|
|
290
|
+
// inside it that moved focus somewhere deliberate must not have that
|
|
291
|
+
// undone.
|
|
292
|
+
const active = document.activeElement;
|
|
293
|
+
if (active == null || active === document.body || body.contains(active)) {
|
|
294
|
+
opener?.focus?.();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
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]);
|
|
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
|
|
319
|
+
if (!popover.open) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
324
|
+
// Named by its trigger unless the caller said otherwise. Setting it anyway
|
|
325
|
+
// would override an `aria-label` they passed — `aria-labelledby` wins — and
|
|
326
|
+
// leave the popover announced as its button rather than as itself.
|
|
327
|
+
const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
|
|
328
|
+
|
|
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} />;
|
|
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
|
}
|