@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/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 {...rest}>{children}</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 passed = withoutComposed(rest, ["onKeyDown"]);
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
- return (
150
- <div
151
- {...passed}
152
- // A screen reader announces the axis, and it is also what tells a reader
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
- const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
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
- return (
198
- <button
199
- // `passed` first, and everything this component owns after it. A caller
200
- // `onClick` used to replace the selection handler, so clicking a tab did
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(value: string, children: React.Node, ...rest: Rest) {
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
- return (
267
- <div
268
- {...rest}
269
- aria-labelledby={`${tabs.base}-tab-${value}`}
270
- id={`${tabs.base}-panel-${value}`}
271
- role="tabpanel"
272
- // The panel itself is focusable so that Tab out of the tab list lands on
273
- // the content the tab describes, which is where the reader expects to go
274
- // and where a panel of plain prose has nothing else to offer.
275
- tabIndex={0}
276
- >
277
- {children}
278
- </div>
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 a store in this module, read through `useSyncExternalStore`,
96
- // which is what `ubugeeei-redundancy.md` requires of an external store: cached
97
- // immutable snapshots, and a server snapshot consistent with them. Nothing here
98
- // is a mutable array a render reads.
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 that hook is actually asking for, and the store
101
- // below has those three and nothing more:
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
- // getter hands back the array it is holding rather than building one, and a
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, and what is watching it.
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 nothing — importing this file
222
- * allocates one frozen array and one empty `Set` and starts no work, and
223
- * nothing on a server calls `toast()`, because this module is `"use client"`.
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
- let queue: $ReadOnlyArray<Notification> = NONE;
226
- const watchers: Set<() => void> = new Set();
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 next = change(queue);
248
- if (next === queue) {
222
+ const current = read(queueAtom);
223
+ const next = change(current);
224
+ if (next === current) {
249
225
  return;
250
226
  }
251
- queue = next;
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
- watchers.add(listener);
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 queue;
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
- return (
251
- <button
252
- {...passed}
253
- aria-disabled={disabled ? "true" : undefined}
254
- aria-pressed={on ? "true" : "false"}
255
- id={id}
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
- tabIndex={group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1}
275
- type="button"
276
- >
277
- {children}
278
- </button>
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
- /** A button whose state stays applied: pressed or not. */
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
  }