@uniflowed/ui 0.0.0-alpha.18 → 0.0.0-alpha.37
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 +61 -54
- package/alert-dialog.js +43 -17
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/checkbox.js +45 -39
- package/collapsible.js +41 -36
- package/context-menu.js +32 -24
- package/dialog.js +143 -119
- package/drawer.js +121 -89
- package/field.js +71 -37
- package/hover-card.js +23 -27
- package/index.js +567 -31
- package/input-otp.js +8 -8
- package/interactions.js +2163 -0
- package/internal/merge-props.js +85 -1
- package/menu.js +236 -213
- package/menubar.js +91 -89
- package/package.json +8 -40
- package/pagination.js +29 -17
- package/popover.js +59 -55
- package/progress.js +21 -16
- package/radio-group.js +79 -75
- package/separator.js +97 -0
- package/sheet.js +41 -17
- package/sidebar.js +10 -10
- package/skeleton.js +159 -0
- package/slider.js +89 -89
- package/switch.js +37 -31
- package/table.js +90 -67
- package/tabs.js +100 -91
- package/toast.js +23 -55
- package/toggle-group.js +52 -49
- package/toggle.js +38 -25
- package/tooltip.js +26 -37
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/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);
|
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,25 @@ export component ToggleGroupRoot(
|
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
195
|
+
const rootProps = withProps(passed, {
|
|
196
|
+
"aria-orientation": orientation,
|
|
197
|
+
children,
|
|
198
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
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: HTMLElement | null) => {
|
|
206
|
+
rootRef.current = element;
|
|
207
|
+
}),
|
|
208
|
+
role: "group",
|
|
209
|
+
});
|
|
192
210
|
|
|
193
211
|
return (
|
|
194
212
|
<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>
|
|
213
|
+
{render != null ? render(rootProps) : <div {...rootProps} />}
|
|
212
214
|
</ToggleGroupContext.Provider>
|
|
213
215
|
);
|
|
214
216
|
}
|
|
@@ -225,6 +227,7 @@ export component ToggleGroupItem(
|
|
|
225
227
|
value: string,
|
|
226
228
|
children?: React.Node,
|
|
227
229
|
disabled?: boolean = false,
|
|
230
|
+
render?: RenderProp,
|
|
228
231
|
...rest: Rest
|
|
229
232
|
) {
|
|
230
233
|
const group = useToggleGroup("ToggleGroup.Item");
|
|
@@ -237,7 +240,7 @@ export component ToggleGroupItem(
|
|
|
237
240
|
// arrow keys that check as they move all belong to the radio group that
|
|
238
241
|
// `ToggleGroup.Root` rendered around this.
|
|
239
242
|
return (
|
|
240
|
-
<RadioGroupItem {...forwarded(rest)} disabled={disabled} value={value}>
|
|
243
|
+
<RadioGroupItem {...forwarded(rest)} disabled={disabled} render={render} value={value}>
|
|
241
244
|
{children}
|
|
242
245
|
</RadioGroupItem>
|
|
243
246
|
);
|
|
@@ -246,35 +249,35 @@ export component ToggleGroupItem(
|
|
|
246
249
|
const on = group.pressed.includes(value);
|
|
247
250
|
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
|
|
248
251
|
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();
|
|
252
|
+
const itemProps = withProps(passed, {
|
|
253
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
254
|
+
"aria-pressed": on ? "true" : "false",
|
|
255
|
+
children,
|
|
256
|
+
id,
|
|
257
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
258
|
+
if (!disabled) {
|
|
272
259
|
group.toggle(value);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
260
|
+
}
|
|
261
|
+
}),
|
|
262
|
+
// The roving tab stop follows real focus rather than leading it, so a
|
|
263
|
+
// pointer that moves focus and an arrow key that moves focus agree
|
|
264
|
+
// without the two having to be kept in step by hand.
|
|
265
|
+
onFocus: composeHandlers(rest.onFocus, (_event: PartEvent) => setActiveId(id)),
|
|
266
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
267
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
271
|
+
// arriving afterwards and pressing this back to where it started.
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
group.toggle(value);
|
|
274
|
+
}),
|
|
275
|
+
tabIndex: group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (render != null) {
|
|
279
|
+
return render(withProps(itemProps, { role: "button" }));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return <button {...itemProps} type="button" />;
|
|
280
283
|
}
|
package/toggle.js
CHANGED
|
@@ -44,49 +44,62 @@
|
|
|
44
44
|
|
|
45
45
|
import * as React from "@uniflowed/react";
|
|
46
46
|
|
|
47
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
48
|
-
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
47
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
48
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
49
49
|
import { useControlled } from "./internal/controlled-state.js";
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A button whose state stays applied: pressed or not.
|
|
53
|
+
*
|
|
54
|
+
* `render` is the escape hatch. A caller-rendered element gets `role="button"`
|
|
55
|
+
* because it may be a link or a `div`; the native button branch keeps the role
|
|
56
|
+
* implicit and only adds `type="button"`, which is true of the element rather
|
|
57
|
+
* than of the toggle behaviour.
|
|
58
|
+
*/
|
|
52
59
|
export component Toggle(
|
|
53
60
|
pressed?: boolean,
|
|
54
61
|
defaultPressed?: boolean = false,
|
|
55
62
|
onPressedChange?: (pressed: boolean) => void,
|
|
56
63
|
disabled?: boolean = false,
|
|
57
64
|
children?: React.Node,
|
|
65
|
+
render?: RenderProp,
|
|
58
66
|
...rest: Rest
|
|
59
67
|
) {
|
|
60
68
|
const [on, setOn] = useControlled(pressed, defaultPressed, onPressedChange);
|
|
61
69
|
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
70
|
+
const semantics = {
|
|
71
|
+
"aria-pressed": on ? "true" : "false",
|
|
72
|
+
children,
|
|
73
|
+
disabled,
|
|
74
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
75
|
+
if (!disabled) {
|
|
76
|
+
setOn(!on);
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
80
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Preventing the default stops `Space` scrolling the page, and stops
|
|
84
|
+
// the browser's own click arriving after this handler and pressing the
|
|
85
|
+
// button a second time — back to where it started, which reads as the
|
|
86
|
+
// key having done nothing at all.
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
setOn(!on);
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (render != null) {
|
|
93
|
+
return render(withProps(passed, { ...semantics, role: "button" }));
|
|
94
|
+
}
|
|
62
95
|
|
|
63
96
|
return (
|
|
64
97
|
<button
|
|
65
|
-
{...passed}
|
|
98
|
+
{...withProps(passed, semantics)}
|
|
66
99
|
// No `role`: this *is* a button, and `aria-pressed` is what makes it a
|
|
67
100
|
// toggle one. Adding `role="button"` to a `<button>` would be noise, and
|
|
68
101
|
// adding any other role would be a lie about what pressing it does.
|
|
69
|
-
aria-pressed={on ? "true" : "false"}
|
|
70
|
-
disabled={disabled}
|
|
71
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
72
|
-
if (!disabled) {
|
|
73
|
-
setOn(!on);
|
|
74
|
-
}
|
|
75
|
-
})}
|
|
76
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
77
|
-
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
// Preventing the default stops `Space` scrolling the page, and stops
|
|
81
|
-
// the browser's own click arriving after this handler and pressing the
|
|
82
|
-
// button a second time — back to where it started, which reads as the
|
|
83
|
-
// key having done nothing at all.
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
setOn(!on);
|
|
86
|
-
})}
|
|
87
102
|
type="button"
|
|
88
|
-
|
|
89
|
-
{children}
|
|
90
|
-
</button>
|
|
103
|
+
/>
|
|
91
104
|
);
|
|
92
105
|
}
|