@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/tabs.js
CHANGED
|
@@ -50,8 +50,8 @@ import {
|
|
|
50
50
|
useState,
|
|
51
51
|
} from "@uniflowed/react";
|
|
52
52
|
|
|
53
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
54
|
-
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
53
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
54
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
55
55
|
import { moveOnKey } from "./internal/roving-focus.js";
|
|
56
56
|
import { useControlled } from "./internal/controlled-state.js";
|
|
57
57
|
import type { Orientation } from "./internal/roving-focus.js";
|
|
@@ -94,6 +94,7 @@ export component TabsRoot(
|
|
|
94
94
|
onValueChange?: (value: string) => void,
|
|
95
95
|
activationMode?: ActivationMode = "automatic",
|
|
96
96
|
orientation?: Orientation = "horizontal",
|
|
97
|
+
render?: RenderProp,
|
|
97
98
|
...rest: Rest
|
|
98
99
|
) {
|
|
99
100
|
const base = useId();
|
|
@@ -125,9 +126,11 @@ export component TabsRoot(
|
|
|
125
126
|
[base, selected, select, orientation, activationMode, mounted, registerPanel],
|
|
126
127
|
);
|
|
127
128
|
|
|
129
|
+
const props = withProps(rest, { children });
|
|
130
|
+
|
|
128
131
|
return (
|
|
129
132
|
<TabsContext.Provider value={state}>
|
|
130
|
-
<div {...
|
|
133
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
131
134
|
</TabsContext.Provider>
|
|
132
135
|
);
|
|
133
136
|
}
|
|
@@ -142,38 +145,37 @@ export component TabsRoot(
|
|
|
142
145
|
* tabs push themselves into as they mount answers with mount order, which stops
|
|
143
146
|
* being document order the first time a tab is conditional.
|
|
144
147
|
*/
|
|
145
|
-
export component TabsList(children: renders* TabsTab, ...rest: Rest) {
|
|
148
|
+
export component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
|
|
146
149
|
const tabs = useTabs("Tabs.List");
|
|
147
|
-
const
|
|
150
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
|
|
151
|
+
// A screen reader announces the axis, and it is also what tells a reader
|
|
152
|
+
// which arrow keys to try.
|
|
153
|
+
"aria-orientation": tabs.orientation,
|
|
154
|
+
children,
|
|
155
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
156
|
+
const list: $FlowFixMe = event.currentTarget;
|
|
157
|
+
// The list the key arrived on carries the answer to both halves of
|
|
158
|
+
// this: which items there are, and which way the page reads — so a tab
|
|
159
|
+
// set inside somebody else's `dir="rtl"` walks the right way without
|
|
160
|
+
// the caller having had to know it needed to say so.
|
|
161
|
+
const next = moveOnKey(event, list, {
|
|
162
|
+
item: '[role="tab"]',
|
|
163
|
+
owner: '[role="tablist"]',
|
|
164
|
+
orientation: tabs.orientation,
|
|
165
|
+
wrap: true,
|
|
166
|
+
skipDisabled: true,
|
|
167
|
+
});
|
|
168
|
+
if (next != null && tabs.activation === "automatic") {
|
|
169
|
+
tabs.select(next.getAttribute("data-value") ?? "");
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
role: "tablist",
|
|
173
|
+
});
|
|
148
174
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// which arrow keys to try.
|
|
154
|
-
aria-orientation={tabs.orientation}
|
|
155
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
156
|
-
const list: $FlowFixMe = event.currentTarget;
|
|
157
|
-
// The list the key arrived on carries the answer to both halves of
|
|
158
|
-
// this: which items there are, and which way the page reads — so a tab
|
|
159
|
-
// set inside somebody else's `dir="rtl"` walks the right way without
|
|
160
|
-
// the caller having had to know it needed to say so.
|
|
161
|
-
const next = moveOnKey(event, list, {
|
|
162
|
-
item: '[role="tab"]',
|
|
163
|
-
owner: '[role="tablist"]',
|
|
164
|
-
orientation: tabs.orientation,
|
|
165
|
-
wrap: true,
|
|
166
|
-
skipDisabled: true,
|
|
167
|
-
});
|
|
168
|
-
if (next != null && tabs.activation === "automatic") {
|
|
169
|
-
tabs.select(next.getAttribute("data-value") ?? "");
|
|
170
|
-
}
|
|
171
|
-
})}
|
|
172
|
-
role="tablist"
|
|
173
|
-
>
|
|
174
|
-
{children}
|
|
175
|
-
</div>
|
|
176
|
-
);
|
|
175
|
+
if (render != null) {
|
|
176
|
+
return render(props);
|
|
177
|
+
}
|
|
178
|
+
return <div {...props} />;
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
/**
|
|
@@ -188,54 +190,55 @@ export component TabsTab(
|
|
|
188
190
|
value: string,
|
|
189
191
|
children: React.Node,
|
|
190
192
|
disabled?: boolean = false,
|
|
193
|
+
render?: RenderProp,
|
|
191
194
|
...rest: Rest
|
|
192
195
|
) {
|
|
193
196
|
const tabs = useTabs("Tabs.Tab");
|
|
194
197
|
const active = tabs.selected === value;
|
|
195
|
-
|
|
198
|
+
// The caller's props first, and everything this component owns after them. A
|
|
199
|
+
// caller `onClick` used to replace the selection handler, so clicking a tab
|
|
200
|
+
// did nothing at all. `render` is handed this same object in this same
|
|
201
|
+
// order, which is why a tab rendered as somebody else's element is not a
|
|
202
|
+
// weaker tab.
|
|
203
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
|
|
204
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
205
|
+
// Only when the panel is actually mounted. Panels are rendered on demand,
|
|
206
|
+
// and a tab pointing `aria-controls` at an id that is not in the document
|
|
207
|
+
// tells a reader there is somewhere to go and then has nowhere to send
|
|
208
|
+
// them.
|
|
209
|
+
"aria-controls": tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined,
|
|
210
|
+
"aria-selected": active ? "true" : "false",
|
|
211
|
+
children,
|
|
212
|
+
// Read by the list's key handler, which finds tabs in the document rather
|
|
213
|
+
// than in a registry and so needs each one to carry its own value.
|
|
214
|
+
"data-value": value,
|
|
215
|
+
id: `${tabs.base}-tab-${value}`,
|
|
216
|
+
onClick: composeHandlers(rest.onClick, () => {
|
|
217
|
+
if (!disabled) {
|
|
218
|
+
tabs.select(value);
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
222
|
+
// Manual activation's other half: the arrows moved focus here without
|
|
223
|
+
// selecting, and this is how the reader says they meant it.
|
|
224
|
+
if (event.key !== "Enter" && event.key !== " ") {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
if (!disabled) {
|
|
229
|
+
tabs.select(value);
|
|
230
|
+
}
|
|
231
|
+
}),
|
|
232
|
+
role: "tab",
|
|
233
|
+
// The roving tabindex: Tab reaches the selected tab and nothing else in
|
|
234
|
+
// the list, so it moves past the whole set in one press.
|
|
235
|
+
tabIndex: active ? 0 : -1,
|
|
236
|
+
});
|
|
196
237
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// nothing at all.
|
|
202
|
-
{...passed}
|
|
203
|
-
aria-disabled={disabled ? "true" : undefined}
|
|
204
|
-
// Only when the panel is actually mounted. Panels are rendered on demand,
|
|
205
|
-
// and a tab pointing `aria-controls` at an id that is not in the document
|
|
206
|
-
// tells a reader there is somewhere to go and then has nowhere to send
|
|
207
|
-
// them.
|
|
208
|
-
aria-controls={tabs.mounted.includes(value) ? `${tabs.base}-panel-${value}` : undefined}
|
|
209
|
-
aria-selected={active ? "true" : "false"}
|
|
210
|
-
// Read by the list's key handler, which finds tabs in the document rather
|
|
211
|
-
// than in a registry and so needs each one to carry its own value.
|
|
212
|
-
data-value={value}
|
|
213
|
-
id={`${tabs.base}-tab-${value}`}
|
|
214
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
215
|
-
if (!disabled) {
|
|
216
|
-
tabs.select(value);
|
|
217
|
-
}
|
|
218
|
-
})}
|
|
219
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
220
|
-
// Manual activation's other half: the arrows moved focus here without
|
|
221
|
-
// selecting, and this is how the reader says they meant it.
|
|
222
|
-
if (event.key !== "Enter" && event.key !== " ") {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
event.preventDefault();
|
|
226
|
-
if (!disabled) {
|
|
227
|
-
tabs.select(value);
|
|
228
|
-
}
|
|
229
|
-
})}
|
|
230
|
-
role="tab"
|
|
231
|
-
// The roving tabindex: Tab reaches the selected tab and nothing else in
|
|
232
|
-
// the list, so it moves past the whole set in one press.
|
|
233
|
-
tabIndex={active ? 0 : -1}
|
|
234
|
-
type="button"
|
|
235
|
-
>
|
|
236
|
-
{children}
|
|
237
|
-
</button>
|
|
238
|
-
);
|
|
238
|
+
if (render != null) {
|
|
239
|
+
return render(props);
|
|
240
|
+
}
|
|
241
|
+
return <button {...props} type="button" />;
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
/**
|
|
@@ -246,7 +249,12 @@ export component TabsTab(
|
|
|
246
249
|
* subscription rather than "the selected value equals mine", because a caller
|
|
247
250
|
* may render a subset of panels, or none at all until data arrives.
|
|
248
251
|
*/
|
|
249
|
-
export component TabsPanel(
|
|
252
|
+
export component TabsPanel(
|
|
253
|
+
value: string,
|
|
254
|
+
children: React.Node,
|
|
255
|
+
render?: RenderProp,
|
|
256
|
+
...rest: Rest
|
|
257
|
+
) {
|
|
250
258
|
const tabs = useTabs("Tabs.Panel");
|
|
251
259
|
const register = tabs.registerPanel;
|
|
252
260
|
const selected = tabs.selected === value;
|
|
@@ -263,18 +271,19 @@ export component TabsPanel(value: string, children: React.Node, ...rest: Rest) {
|
|
|
263
271
|
return null;
|
|
264
272
|
}
|
|
265
273
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
274
|
+
const props = withProps(rest, {
|
|
275
|
+
"aria-labelledby": `${tabs.base}-tab-${value}`,
|
|
276
|
+
children,
|
|
277
|
+
id: `${tabs.base}-panel-${value}`,
|
|
278
|
+
role: "tabpanel",
|
|
279
|
+
// The panel itself is focusable so that Tab out of the tab list lands on
|
|
280
|
+
// the content the tab describes, which is where the reader expects to go
|
|
281
|
+
// and where a panel of plain prose has nothing else to offer.
|
|
282
|
+
tabIndex: 0,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (render != null) {
|
|
286
|
+
return render(props);
|
|
287
|
+
}
|
|
288
|
+
return <div {...props} />;
|
|
280
289
|
}
|
package/tag-group.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { CollectionRoot } from "./internal/collection.js";
|
|
5
|
+
import type { CollectionProps } from "./internal/collection.js";
|
|
6
|
+
export component TagGroup(...props: CollectionProps) {
|
|
7
|
+
return <CollectionRoot options={props} kind="tags" />;
|
|
8
|
+
}
|
package/time-field.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { SegmentedField } from "./internal/segmented-field.js";
|
|
5
|
+
import type { DateFieldProps } from "./internal/segmented-field.js";
|
|
6
|
+
export component TimeField(...props: DateFieldProps) {
|
|
7
|
+
return <SegmentedField options={props} time={true} />;
|
|
8
|
+
}
|
package/toast.js
CHANGED
|
@@ -92,16 +92,18 @@
|
|
|
92
92
|
//
|
|
93
93
|
// `toast("Saved")` is called from an event handler, from a `catch`, from a
|
|
94
94
|
// Server Action's error path — none of which have a component to put state in.
|
|
95
|
-
// So the queue is
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
95
|
+
// So the queue is an atom in `@uniflowed/state`, declared at module scope and
|
|
96
|
+
// written by the four module functions below. The live region reads it through
|
|
97
|
+
// React's external-store hook via `read` and `subscribe`, deliberately against
|
|
98
|
+
// the default store: a function in a module has no context with which to find a
|
|
99
|
+
// scoped store, and letting a nearby `<Provider>` redirect the region would
|
|
100
|
+
// split `toast()` from the place that displays it.
|
|
99
101
|
//
|
|
100
|
-
// Three properties are what
|
|
101
|
-
//
|
|
102
|
+
// Three properties are what the hook is actually asking for, and the atom gives
|
|
103
|
+
// this module those three without a second store implementation:
|
|
102
104
|
//
|
|
103
105
|
// - *An immutable snapshot whose reference changes only on a write.* The
|
|
104
|
-
//
|
|
106
|
+
// atom holds the array itself rather than building one on every read, and a
|
|
105
107
|
// write that computes the value already there is dropped rather than
|
|
106
108
|
// announced. A getter that returns a fresh `[]` is a new identity every time
|
|
107
109
|
// React asks, which renders, which asks again — the infinite loop React
|
|
@@ -117,38 +119,10 @@
|
|
|
117
119
|
// of notifications. A queue something could scope to a subtree would leave a
|
|
118
120
|
// region showing an empty stack while `toast()` filled up a store it had no
|
|
119
121
|
// way to find.
|
|
120
|
-
//
|
|
121
|
-
// # Why this is not an atom in `@uniflowed/state`
|
|
122
|
-
//
|
|
123
|
-
// It was one, and it should be one again. A queue read through
|
|
124
|
-
// `useSyncExternalStore` is exactly what an atom is for, `@uniflowed/state` is
|
|
125
|
-
// what this project offers instead of Jotai, and the version of this file that
|
|
126
|
-
// imported `atom`, `read`, `subscribe` and `write` was not making a mistake.
|
|
127
|
-
//
|
|
128
|
-
// It cannot ship that way today. `@uniflowed/ui` is published to npm and
|
|
129
|
-
// `@uniflowed/state` is not: its name has never been bound, binding it takes a
|
|
130
|
-
// person with an `npm login` session and a 2FA prompt, and that is
|
|
131
|
-
// ubugeeei-prod/uf#210. Until it happens the package waits in
|
|
132
|
-
// `tools/release/pending-packages.txt`. A published package whose dependency
|
|
133
|
-
// is missing installs as nothing — `ETARGET` on the first thing a user types —
|
|
134
|
-
// so `tools/release/verify-npm.sh` refuses to release `@uniflowed/ui` while it
|
|
135
|
-
// declares that dependency, and it is right to refuse.
|
|
136
|
-
//
|
|
137
|
-
// So the store below is the shippable design rather than the better one. It is
|
|
138
|
-
// a second implementation of something this repository already has, written
|
|
139
|
-
// out by hand because the first one cannot be installed. When #210 binds the
|
|
140
|
-
// name and `state` moves into `published-packages.txt`, this goes back to an
|
|
141
|
-
// atom and this section goes with it.
|
|
142
|
-
//
|
|
143
|
-
// This is the first `useSyncExternalStore` in this package, and `index.js` says
|
|
144
|
-
// the package deliberately does not use one. That sentence is about reading
|
|
145
|
-
// *layout* during a render, which is what the DOM-measuring components here
|
|
146
|
-
// avoid by writing what they measured into state from an effect. A queue that
|
|
147
|
-
// lives outside React is the case the API is for, and `index.js` now says both
|
|
148
|
-
// things.
|
|
149
122
|
|
|
150
123
|
"use client";
|
|
151
124
|
|
|
125
|
+
import { atom, read, subscribe, write } from "@uniflowed/state";
|
|
152
126
|
import * as React from "@uniflowed/react";
|
|
153
127
|
import {
|
|
154
128
|
createContext,
|
|
@@ -215,15 +189,16 @@ const DEFAULT_DURATION = 5000;
|
|
|
215
189
|
const NONE: $ReadOnlyArray<Notification> = Object.freeze([]);
|
|
216
190
|
|
|
217
191
|
/**
|
|
218
|
-
* The queue
|
|
192
|
+
* The queue atom.
|
|
219
193
|
*
|
|
220
194
|
* Module scope is the requirement rather than a convenience: `toast()` has
|
|
221
|
-
* nowhere else to put this. It costs a server
|
|
222
|
-
*
|
|
223
|
-
*
|
|
195
|
+
* nowhere else to put this. It costs a server little — importing this file
|
|
196
|
+
* declares one atom over one frozen array and starts no work, and nothing on a
|
|
197
|
+
* server calls `toast()`, because this module is `"use client"`.
|
|
224
198
|
*/
|
|
225
|
-
|
|
226
|
-
|
|
199
|
+
const queueAtom = atom<$ReadOnlyArray<Notification>>(NONE, {
|
|
200
|
+
debugLabel: "ui.toastQueue",
|
|
201
|
+
});
|
|
227
202
|
|
|
228
203
|
/**
|
|
229
204
|
* Replace the queue, and wake what is watching it.
|
|
@@ -244,16 +219,12 @@ const watchers: Set<() => void> = new Set();
|
|
|
244
219
|
function writeQueue(
|
|
245
220
|
change: (current: $ReadOnlyArray<Notification>) => $ReadOnlyArray<Notification>,
|
|
246
221
|
): void {
|
|
247
|
-
const
|
|
248
|
-
|
|
222
|
+
const current = read(queueAtom);
|
|
223
|
+
const next = change(current);
|
|
224
|
+
if (next === current) {
|
|
249
225
|
return;
|
|
250
226
|
}
|
|
251
|
-
|
|
252
|
-
for (const watcher of Array.from(watchers)) {
|
|
253
|
-
if (watchers.has(watcher)) {
|
|
254
|
-
watcher();
|
|
255
|
-
}
|
|
256
|
-
}
|
|
227
|
+
write(queueAtom, next);
|
|
257
228
|
}
|
|
258
229
|
|
|
259
230
|
/** Ids are this module's, because `useId` needs a component and `toast()` is not one. */
|
|
@@ -330,10 +301,7 @@ export function dismissAllToasts(): void {
|
|
|
330
301
|
|
|
331
302
|
/** Module-level and therefore stable, which is what stops React re-subscribing. */
|
|
332
303
|
function subscribeToQueue(listener: () => void): () => void {
|
|
333
|
-
|
|
334
|
-
return () => {
|
|
335
|
-
watchers.delete(listener);
|
|
336
|
-
};
|
|
304
|
+
return subscribe(queueAtom, listener);
|
|
337
305
|
}
|
|
338
306
|
|
|
339
307
|
/**
|
|
@@ -346,7 +314,7 @@ function subscribeToQueue(listener: () => void): () => void {
|
|
|
346
314
|
* `"use client"`.
|
|
347
315
|
*/
|
|
348
316
|
function readQueue(): $ReadOnlyArray<Notification> {
|
|
349
|
-
return
|
|
317
|
+
return read(queueAtom);
|
|
350
318
|
}
|
|
351
319
|
|
|
352
320
|
const NotificationContext: React.Context<Notification | null> = createContext(null);
|
|
@@ -388,26 +356,26 @@ export component ToastRegion(
|
|
|
388
356
|
...rest: Rest
|
|
389
357
|
) {
|
|
390
358
|
const queued = useSyncExternalStore(subscribeToQueue, readQueue, readQueue);
|
|
391
|
-
const
|
|
359
|
+
const regionRef = useElementRef<HTMLElement>();
|
|
392
360
|
// Where `F6` came from, so pressing it again gives focus back.
|
|
393
|
-
const
|
|
361
|
+
const cameFromRef = useRef<HTMLElement | null>(null);
|
|
394
362
|
const passed = withoutComposed(rest, ["ref"]);
|
|
395
363
|
|
|
396
364
|
useKeyCombo(
|
|
397
365
|
"f6",
|
|
398
366
|
() => {
|
|
399
|
-
const element =
|
|
367
|
+
const element = regionRef.current;
|
|
400
368
|
if (element == null) {
|
|
401
369
|
return;
|
|
402
370
|
}
|
|
403
371
|
const active: $FlowFixMe = element.ownerDocument.activeElement;
|
|
404
372
|
if (active != null && element.contains(active)) {
|
|
405
|
-
const back =
|
|
406
|
-
|
|
373
|
+
const back = cameFromRef.current;
|
|
374
|
+
cameFromRef.current = null;
|
|
407
375
|
back?.focus?.();
|
|
408
376
|
return;
|
|
409
377
|
}
|
|
410
|
-
|
|
378
|
+
cameFromRef.current = active;
|
|
411
379
|
element.focus();
|
|
412
380
|
},
|
|
413
381
|
// The one case that matters is a notification arriving while the reader is
|
|
@@ -432,7 +400,7 @@ export component ToastRegion(
|
|
|
432
400
|
{...passed}
|
|
433
401
|
aria-label={label}
|
|
434
402
|
ref={composeRefs(rest.ref, (element) => {
|
|
435
|
-
|
|
403
|
+
regionRef.current = element;
|
|
436
404
|
})}
|
|
437
405
|
role="region"
|
|
438
406
|
// So `F6` can put focus on the region itself, including while it is
|
|
@@ -462,13 +430,13 @@ export component ToastRegion(
|
|
|
462
430
|
export component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
463
431
|
const notification = useNotification("Toast.Root");
|
|
464
432
|
const base = useId();
|
|
465
|
-
const
|
|
433
|
+
const elementRef = useElementRef<HTMLElement>();
|
|
466
434
|
const [titled, setTitled] = useState(false);
|
|
467
435
|
const [described, setDescribed] = useState(false);
|
|
468
436
|
const passed = withoutComposed(rest, ["ref"]);
|
|
469
437
|
|
|
470
|
-
const hovered = useHover(
|
|
471
|
-
const focusInside = useFocusWithin(
|
|
438
|
+
const hovered = useHover(elementRef);
|
|
439
|
+
const focusInside = useFocusWithin(elementRef);
|
|
472
440
|
const documentVisible = useDocumentVisible();
|
|
473
441
|
const paused = hovered || focusInside || !documentVisible;
|
|
474
442
|
|
|
@@ -497,6 +465,8 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
|
497
465
|
// A notification whose duration was changed under it — "Uploading…" with no
|
|
498
466
|
// duration becoming "Uploaded" with one — starts its countdown from there.
|
|
499
467
|
useEffect(() => {
|
|
468
|
+
// The timeout duration is state because render hands it to useTimeout.
|
|
469
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
500
470
|
setLeft(duration);
|
|
501
471
|
startedAt.current = Date.now();
|
|
502
472
|
}, [duration]);
|
|
@@ -518,7 +488,7 @@ export component ToastRoot(children: React.Node, ...rest: Rest) {
|
|
|
518
488
|
aria-describedby={described ? parts.descriptionId : undefined}
|
|
519
489
|
aria-labelledby={titled ? parts.titleId : undefined}
|
|
520
490
|
ref={composeRefs(rest.ref, (node) => {
|
|
521
|
-
|
|
491
|
+
elementRef.current = node;
|
|
522
492
|
})}
|
|
523
493
|
role="group"
|
|
524
494
|
>
|
package/toggle-group.js
CHANGED
|
@@ -60,11 +60,12 @@ import {
|
|
|
60
60
|
useState,
|
|
61
61
|
} from "@uniflowed/react";
|
|
62
62
|
|
|
63
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
63
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
64
64
|
import {
|
|
65
65
|
composeHandlers,
|
|
66
66
|
composeRefs,
|
|
67
67
|
forwarded,
|
|
68
|
+
withProps,
|
|
68
69
|
withoutComposed,
|
|
69
70
|
} from "./internal/merge-props.js";
|
|
70
71
|
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
@@ -134,6 +135,7 @@ export component ToggleGroupRoot(
|
|
|
134
135
|
value?: $ReadOnlyArray<string>,
|
|
135
136
|
onValueChange?: (value: $ReadOnlyArray<string>) => void,
|
|
136
137
|
orientation?: Orientation = "horizontal",
|
|
138
|
+
render?: RenderProp,
|
|
137
139
|
...rest: Rest
|
|
138
140
|
) {
|
|
139
141
|
const [pressed, setPressed] = useControlled<$ReadOnlyArray<string>>(
|
|
@@ -180,6 +182,7 @@ export component ToggleGroupRoot(
|
|
|
180
182
|
{...forwarded(rest)}
|
|
181
183
|
onValueChange={chooseOne}
|
|
182
184
|
orientation={orientation}
|
|
185
|
+
render={render}
|
|
183
186
|
value={pressed[0] ?? null}
|
|
184
187
|
>
|
|
185
188
|
{children}
|
|
@@ -189,26 +192,26 @@ export component ToggleGroupRoot(
|
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
195
|
+
const rootProps = withProps(passed, {
|
|
196
|
+
children,
|
|
197
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
198
|
+
const group: $FlowFixMe = event.currentTarget;
|
|
199
|
+
// Moves and nothing else. Pressing every button the arrows pass over
|
|
200
|
+
// is what a `single` group does, and doing it here would apply half a
|
|
201
|
+
// dozen commands on the way to the one the reader wanted.
|
|
202
|
+
moveOnKey(event, group, toggleSet(orientation));
|
|
203
|
+
}),
|
|
204
|
+
// React calls callback refs during commit; keyboard handlers read it later.
|
|
205
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
206
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
207
|
+
rootRef.current = element;
|
|
208
|
+
}),
|
|
209
|
+
role: "group",
|
|
210
|
+
});
|
|
192
211
|
|
|
193
212
|
return (
|
|
194
213
|
<ToggleGroupContext.Provider value={state}>
|
|
195
|
-
<div
|
|
196
|
-
{...passed}
|
|
197
|
-
aria-orientation={orientation}
|
|
198
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
199
|
-
const group: $FlowFixMe = event.currentTarget;
|
|
200
|
-
// Moves and nothing else. Pressing every button the arrows pass over
|
|
201
|
-
// is what a `single` group does, and doing it here would apply half a
|
|
202
|
-
// dozen commands on the way to the one the reader wanted.
|
|
203
|
-
moveOnKey(event, group, toggleSet(orientation));
|
|
204
|
-
})}
|
|
205
|
-
ref={composeRefs(rest.ref, (element) => {
|
|
206
|
-
rootRef.current = element;
|
|
207
|
-
})}
|
|
208
|
-
role="group"
|
|
209
|
-
>
|
|
210
|
-
{children}
|
|
211
|
-
</div>
|
|
214
|
+
{render != null ? render(rootProps) : <div {...rootProps} />}
|
|
212
215
|
</ToggleGroupContext.Provider>
|
|
213
216
|
);
|
|
214
217
|
}
|
|
@@ -225,6 +228,7 @@ export component ToggleGroupItem(
|
|
|
225
228
|
value: string,
|
|
226
229
|
children?: React.Node,
|
|
227
230
|
disabled?: boolean = false,
|
|
231
|
+
render?: RenderProp,
|
|
228
232
|
...rest: Rest
|
|
229
233
|
) {
|
|
230
234
|
const group = useToggleGroup("ToggleGroup.Item");
|
|
@@ -237,7 +241,7 @@ export component ToggleGroupItem(
|
|
|
237
241
|
// arrow keys that check as they move all belong to the radio group that
|
|
238
242
|
// `ToggleGroup.Root` rendered around this.
|
|
239
243
|
return (
|
|
240
|
-
<RadioGroupItem {...forwarded(rest)} disabled={disabled} value={value}>
|
|
244
|
+
<RadioGroupItem {...forwarded(rest)} disabled={disabled} render={render} value={value}>
|
|
241
245
|
{children}
|
|
242
246
|
</RadioGroupItem>
|
|
243
247
|
);
|
|
@@ -246,35 +250,35 @@ export component ToggleGroupItem(
|
|
|
246
250
|
const on = group.pressed.includes(value);
|
|
247
251
|
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
|
|
248
252
|
const setActiveId = group.setActiveId;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
257
|
-
if (!disabled) {
|
|
258
|
-
group.toggle(value);
|
|
259
|
-
}
|
|
260
|
-
})}
|
|
261
|
-
// The roving tab stop follows real focus rather than leading it, so a
|
|
262
|
-
// pointer that moves focus and an arrow key that moves focus agree
|
|
263
|
-
// without the two having to be kept in step by hand.
|
|
264
|
-
onFocus={composeHandlers(rest.onFocus, () => setActiveId(id))}
|
|
265
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
266
|
-
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
270
|
-
// arriving afterwards and pressing this back to where it started.
|
|
271
|
-
event.preventDefault();
|
|
253
|
+
const itemProps = withProps(passed, {
|
|
254
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
255
|
+
"aria-pressed": on ? "true" : "false",
|
|
256
|
+
children,
|
|
257
|
+
id,
|
|
258
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
259
|
+
if (!disabled) {
|
|
272
260
|
group.toggle(value);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
261
|
+
}
|
|
262
|
+
}),
|
|
263
|
+
// The roving tab stop follows real focus rather than leading it, so a
|
|
264
|
+
// pointer that moves focus and an arrow key that moves focus agree
|
|
265
|
+
// without the two having to be kept in step by hand.
|
|
266
|
+
onFocus: composeHandlers(rest.onFocus, (_event: PartEvent) => setActiveId(id)),
|
|
267
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
268
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
272
|
+
// arriving afterwards and pressing this back to where it started.
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
group.toggle(value);
|
|
275
|
+
}),
|
|
276
|
+
tabIndex: group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (render != null) {
|
|
280
|
+
return render(withProps(itemProps, { role: "button" }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return <button {...itemProps} type="button" />;
|
|
280
284
|
}
|