@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/context-menu.js CHANGED
@@ -58,8 +58,13 @@ import { useCallback, useContext, useMemo, useRef, useState } from "@uniflowed/r
58
58
  import { useLongPress } from "@uniflowed/hooks/dom";
59
59
 
60
60
  import type { Rect } from "./internal/anchor.js";
61
- import type { Rest } from "./internal/merge-props.js";
62
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
61
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
62
+ import {
63
+ composeHandlers,
64
+ composeRefs,
65
+ withProps,
66
+ withoutComposed,
67
+ } from "./internal/merge-props.js";
63
68
  import { MenuAnchorContext, MenuContext, MenuLevel, useMenu } from "./internal/menu-tree.js";
64
69
 
65
70
  /**
@@ -126,7 +131,7 @@ hook usePoint(part: string): PointState {
126
131
  * region of the page. The module header says why it is in the tab order and
127
132
  * when a caller should take it out again.
128
133
  */
129
- export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
134
+ export component ContextMenuTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
130
135
  const menu = useMenu("ContextMenu.Trigger");
131
136
  const { openAt } = usePoint("ContextMenu.Trigger");
132
137
  const triggerRef = useRef<HTMLElement | null>(null);
@@ -149,19 +154,19 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
149
154
  menu.setOpen(true);
150
155
  });
151
156
 
152
- return (
153
- <div
154
- // Above the spread, alone, because it is the one attribute here a caller
155
- // is invited to overrule: the module header promises `tabIndex={-1}` to a
156
- // caller whose trigger already contains something focusable, and a prop
157
- // written *after* `{...passed}` wins over the caller's silently — which
158
- // is a documented escape hatch that does nothing. Everything below the
159
- // spread is this component's own and stays there.
160
- tabIndex={0}
161
- {...passed}
162
- aria-haspopup="menu"
163
- id={`${menu.base}-trigger`}
164
- onContextMenu={composeHandlers(rest.onContextMenu, (event) => {
157
+ const props = withProps(
158
+ // The `tabIndex` goes *underneath* the caller's props, alone, because it is
159
+ // the one attribute here a caller is invited to overrule: the module header
160
+ // promises `tabIndex={-1}` to a caller whose trigger already contains
161
+ // something focusable, and a value that won over the caller's would be a
162
+ // documented escape hatch that does nothing. Everything in the second
163
+ // argument is this component's own and stays on top.
164
+ withProps({ tabIndex: 0 }, passed),
165
+ {
166
+ "aria-haspopup": "menu",
167
+ children,
168
+ id: `${menu.base}-trigger`,
169
+ onContextMenu: composeHandlers(rest.onContextMenu, (event: PartEvent) => {
165
170
  const press: $FlowFixMe = event;
166
171
  // The browser's own menu would otherwise cover this one, and the reader
167
172
  // would be looking at the platform's Back/Reload rather than at the
@@ -170,8 +175,8 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
170
175
  openAt(pointAt(press.clientX ?? 0, press.clientY ?? 0));
171
176
  menu.pendingFocus.current = "first";
172
177
  menu.setOpen(true);
173
- })}
174
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
178
+ }),
179
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
175
180
  // Both spellings. `ContextMenu` is the dedicated key on a PC keyboard;
176
181
  // `Shift+F10` is the one every platform has, and is what a laptop
177
182
  // without that key leaves a reader with.
@@ -182,17 +187,20 @@ export component ContextMenuTrigger(children: React.Node, ...rest: Rest) {
182
187
  }
183
188
  event.preventDefault();
184
189
  openHere();
185
- })}
186
- ref={composeRefs(rest.ref, (element) => {
190
+ }),
191
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
187
192
  triggerRef.current = element;
188
193
  // What focus goes back to when the menu closes. It is deliberately not
189
194
  // registered as the menu's *name*; see the module header.
190
195
  menu.triggerRef.current = element;
191
- })}
192
- >
193
- {children}
194
- </div>
196
+ }),
197
+ },
195
198
  );
199
+
200
+ if (render != null) {
201
+ return render(props);
202
+ }
203
+ return <div {...props} />;
196
204
  }
197
205
 
198
206
  export type { MenuSelect } from "./menu.js";
package/dialog.js CHANGED
@@ -78,8 +78,13 @@ import {
78
78
  import { useScrollLock } from "@uniflowed/hooks/browser";
79
79
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
80
80
 
81
- import type { Rest } from "./internal/merge-props.js";
82
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
81
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
82
+ import {
83
+ composeHandlers,
84
+ composeRefs,
85
+ withProps,
86
+ withoutComposed,
87
+ } from "./internal/merge-props.js";
83
88
  import { focusable } from "./internal/focus.js";
84
89
  import { useControlled } from "./internal/controlled-state.js";
85
90
 
@@ -155,29 +160,34 @@ export component DialogRoot(
155
160
  return <DialogContext.Provider value={state}>{children}</DialogContext.Provider>;
156
161
  }
157
162
 
158
- /** What opens the dialog, and what focus comes back to when it closes. */
159
- export component DialogTrigger(children: React.Node, ...rest: Rest) {
163
+ /**
164
+ * What opens the dialog, and what focus comes back to when it closes.
165
+ *
166
+ * `render` for a trigger that is not a `<button>` — a card, a table row, an
167
+ * icon in somebody else's `<Pressable>`. The ref goes across with everything
168
+ * else, which is what keeps "focus comes back here" true of whatever the
169
+ * caller rendered.
170
+ */
171
+ export component DialogTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
160
172
  const dialog = useDialog("Dialog.Trigger");
161
- const passed = withoutComposed(rest, ["onClick", "ref"]);
162
-
163
- return (
164
- <button
165
- {...passed}
166
- // Only while it is open. An `aria-controls` naming an element that is not
167
- // in the document is worse than no `aria-controls`: a reader is told
168
- // there is somewhere to go and there is not.
169
- aria-controls={dialog.open ? `${dialog.base}-body` : undefined}
170
- aria-expanded={dialog.open ? "true" : "false"}
171
- aria-haspopup="dialog"
172
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(true))}
173
- ref={composeRefs(rest.ref, (element) => {
174
- dialog.triggerRef.current = element;
175
- })}
176
- type="button"
177
- >
178
- {children}
179
- </button>
180
- );
173
+ const props = withProps(withoutComposed(rest, ["onClick", "ref"]), {
174
+ // Only while it is open. An `aria-controls` naming an element that is not
175
+ // in the document is worse than no `aria-controls`: a reader is told
176
+ // there is somewhere to go and there is not.
177
+ "aria-controls": dialog.open ? `${dialog.base}-body` : undefined,
178
+ "aria-expanded": dialog.open ? "true" : "false",
179
+ "aria-haspopup": "dialog",
180
+ children,
181
+ onClick: composeHandlers(rest.onClick, () => dialog.setOpen(true)),
182
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
183
+ dialog.triggerRef.current = element;
184
+ }),
185
+ });
186
+
187
+ if (render != null) {
188
+ return render(props);
189
+ }
190
+ return <button {...props} type="button" />;
181
191
  }
182
192
 
183
193
  /**
@@ -189,12 +199,16 @@ export component DialogTrigger(children: React.Node, ...rest: Rest) {
189
199
  * their own backdrop or omits one entirely must still get it. That lives on
190
200
  * `Dialog.Body`, which is the part that knows where "outside" is.
191
201
  */
192
- export component DialogOverlay(...rest: Rest) {
202
+ export component DialogOverlay(render?: RenderProp, ...rest: Rest) {
193
203
  const dialog = useDialog("Dialog.Overlay");
194
204
  if (!dialog.open) {
195
205
  return null;
196
206
  }
197
- return <div {...rest} aria-hidden="true" data-state="open" />;
207
+ const props = withProps(rest, { "aria-hidden": "true", "data-state": "open" });
208
+ if (render != null) {
209
+ return render(props);
210
+ }
211
+ return <div {...props} />;
198
212
  }
199
213
 
200
214
  /**
@@ -209,6 +223,7 @@ export component DialogBody(
209
223
  dismissOnOutsidePress?: boolean = true,
210
224
  initialFocus?: { current: HTMLElement | null },
211
225
  role?: DialogRole = "dialog",
226
+ render?: RenderProp,
212
227
  ...rest: Rest
213
228
  ) {
214
229
  const dialog = useDialog("Dialog.Body");
@@ -295,72 +310,71 @@ export component DialogBody(
295
310
  return null;
296
311
  }
297
312
 
298
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
299
-
300
- return (
301
- <div
302
- // `passed` first. A caller `ref` used to replace `bodyRef`, which left it
303
- // null, made the Tab branch below return early, and turned the focus trap
304
- // off while the dialog still announced `aria-modal="true"`. A caller
305
- // `onKeyDown` used to replace this one, and Escape stopped closing it.
306
- {...passed}
307
- // Only ids that are in the document: an `aria-labelledby` naming a
308
- // missing element makes a screen reader announce nothing at all, so a
309
- // dialog without a `Dialog.Title` falls through to whatever `aria-label`
310
- // the caller passed instead.
311
- aria-describedby={dialog.described ? `${dialog.base}-description` : undefined}
312
- aria-labelledby={dialog.titled ? `${dialog.base}-title` : undefined}
313
- aria-modal="true"
314
- id={`${dialog.base}-body`}
315
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
316
- if (event.key === "Escape") {
317
- event.preventDefault();
318
- // The dialog behind this one must not also close. Two stacked
319
- // dialogs nest in the DOM, so without this the event bubbled to the
320
- // outer dialog's handler and one Escape closed both.
321
- event.stopPropagation();
322
- close();
323
- return;
324
- }
325
- if (event.key !== "Tab") {
326
- return;
327
- }
328
- const body = bodyRef.current;
329
- if (body == null) {
330
- return;
331
- }
332
- const stops = focusable(body);
333
- // An outer dialog must not also run its trap on this key.
313
+ // The caller's props first. A caller `ref` used to replace `bodyRef`, which
314
+ // left it null, made the Tab branch below return early, and turned the focus
315
+ // trap off while the dialog still announced `aria-modal="true"`. A caller
316
+ // `onKeyDown` used to replace this one, and Escape stopped closing it.
317
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
318
+ // Only ids that are in the document: an `aria-labelledby` naming a
319
+ // missing element makes a screen reader announce nothing at all, so a
320
+ // dialog without a `Dialog.Title` falls through to whatever `aria-label`
321
+ // the caller passed instead.
322
+ "aria-describedby": dialog.described ? `${dialog.base}-description` : undefined,
323
+ "aria-labelledby": dialog.titled ? `${dialog.base}-title` : undefined,
324
+ "aria-modal": "true",
325
+ children,
326
+ id: `${dialog.base}-body`,
327
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
328
+ if (event.key === "Escape") {
329
+ event.preventDefault();
330
+ // The dialog behind this one must not also close. Two stacked
331
+ // dialogs nest in the DOM, so without this the event bubbled to the
332
+ // outer dialog's handler and one Escape closed both.
334
333
  event.stopPropagation();
335
- if (stops.length === 0) {
336
- // Nothing to move to, so Tab must not leave either.
337
- event.preventDefault();
338
- return;
339
- }
340
- const first = stops[0];
341
- const last = stops[stops.length - 1];
342
- const active = body.ownerDocument?.activeElement;
343
- // Wrap at the ends. This is the whole of "focus cannot leave"; every
344
- // other Tab press is the browser's own business.
345
- if (event.shiftKey && (active === first || active === body)) {
346
- event.preventDefault();
347
- last.focus();
348
- } else if (!event.shiftKey && active === last) {
349
- event.preventDefault();
350
- first.focus();
351
- }
352
- })}
353
- ref={composeRefs(rest.ref, (element) => {
354
- bodyRef.current = element;
355
- })}
356
- role={role}
357
- // So the dialog can hold focus itself when it contains nothing focusable,
358
- // and so the trap has somewhere to put focus that is still inside.
359
- tabIndex={-1}
360
- >
361
- {children}
362
- </div>
363
- );
334
+ close();
335
+ return;
336
+ }
337
+ if (event.key !== "Tab") {
338
+ return;
339
+ }
340
+ const body = bodyRef.current;
341
+ if (body == null) {
342
+ return;
343
+ }
344
+ const stops = focusable(body);
345
+ // An outer dialog must not also run its trap on this key.
346
+ event.stopPropagation();
347
+ if (stops.length === 0) {
348
+ // Nothing to move to, so Tab must not leave either.
349
+ event.preventDefault();
350
+ return;
351
+ }
352
+ const first = stops[0];
353
+ const last = stops[stops.length - 1];
354
+ const active = body.ownerDocument?.activeElement;
355
+ // Wrap at the ends. This is the whole of "focus cannot leave"; every
356
+ // other Tab press is the browser's own business.
357
+ if (event.shiftKey && (active === first || active === body)) {
358
+ event.preventDefault();
359
+ last.focus();
360
+ } else if (!event.shiftKey && active === last) {
361
+ event.preventDefault();
362
+ first.focus();
363
+ }
364
+ }),
365
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
366
+ bodyRef.current = element;
367
+ }),
368
+ role,
369
+ // So the dialog can hold focus itself when it contains nothing focusable,
370
+ // and so the trap has somewhere to put focus that is still inside.
371
+ tabIndex: -1,
372
+ });
373
+
374
+ if (render != null) {
375
+ return render(props);
376
+ }
377
+ return <div {...props} />;
364
378
  }
365
379
 
366
380
  /**
@@ -370,7 +384,7 @@ export component DialogBody(
370
384
  * rendered — a conditional title that is absent used to leave the dialog
371
385
  * pointing at an id nothing had.
372
386
  */
373
- export component DialogTitle(children: React.Node, ...rest: Rest) {
387
+ export component DialogTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
374
388
  const dialog = useDialog("Dialog.Title");
375
389
  const register = dialog.registerTitle;
376
390
  useEffect(() => {
@@ -378,11 +392,15 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
378
392
  return () => register(false);
379
393
  }, [register]);
380
394
 
381
- return (
382
- <h2 {...rest} id={`${dialog.base}-title`}>
383
- {children}
384
- </h2>
385
- );
395
+ const props = withProps(rest, { children, id: `${dialog.base}-title` });
396
+ // `<h2>` is a default rather than a decision. Which heading level a dialog's
397
+ // name is depends on what is around it — ubugeeei-prod/uf#276 is the same
398
+ // observation about an accordion — and `render` is how a caller says so
399
+ // without losing the id `aria-labelledby` points at.
400
+ if (render != null) {
401
+ return render(props);
402
+ }
403
+ return <h2 {...props} />;
386
404
  }
387
405
 
388
406
  /**
@@ -392,7 +410,7 @@ export component DialogTitle(children: React.Node, ...rest: Rest) {
392
410
  * the one moment the reader has to decide whether they care — so this is where
393
411
  * "this cannot be undone" belongs, not in body text further down.
394
412
  */
395
- export component DialogDescription(children: React.Node, ...rest: Rest) {
413
+ export component DialogDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
396
414
  const dialog = useDialog("Dialog.Description");
397
415
  const register = dialog.registerDescription;
398
416
  useEffect(() => {
@@ -400,11 +418,11 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
400
418
  return () => register(false);
401
419
  }, [register]);
402
420
 
403
- return (
404
- <p {...rest} id={`${dialog.base}-description`}>
405
- {children}
406
- </p>
407
- );
421
+ const props = withProps(rest, { children, id: `${dialog.base}-description` });
422
+ if (render != null) {
423
+ return render(props);
424
+ }
425
+ return <p {...props} />;
408
426
  }
409
427
 
410
428
  /**
@@ -416,29 +434,35 @@ export component DialogDescription(children: React.Node, ...rest: Rest) {
416
434
  * styling layer has a name to attach to, and contributes no semantics because
417
435
  * it has none to contribute.
418
436
  */
419
- export component DialogHeader(children: React.Node, ...rest: Rest) {
420
- return <div {...rest}>{children}</div>;
437
+ export component DialogHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
438
+ const props = withProps(rest, { children });
439
+ if (render != null) {
440
+ return render(props);
441
+ }
442
+ return <div {...props} />;
421
443
  }
422
444
 
423
445
  /** The bottom of the dialog, where the actions go. See `Dialog.Header`. */
424
- export component DialogFooter(children: React.Node, ...rest: Rest) {
425
- return <div {...rest}>{children}</div>;
446
+ export component DialogFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
447
+ const props = withProps(rest, { children });
448
+ if (render != null) {
449
+ return render(props);
450
+ }
451
+ return <div {...props} />;
426
452
  }
427
453
 
428
454
  /** A button that closes the dialog. */
429
- export component DialogClose(children: React.Node, ...rest: Rest) {
455
+ export component DialogClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
430
456
  const dialog = useDialog("Dialog.Close");
431
- const passed = withoutComposed(rest, ["onClick"]);
432
-
433
- return (
434
- <button
435
- {...passed}
436
- onClick={composeHandlers(rest.onClick, () => dialog.setOpen(false))}
437
- type="button"
438
- >
439
- {children}
440
- </button>
441
- );
457
+ const props = withProps(withoutComposed(rest, ["onClick"]), {
458
+ children,
459
+ onClick: composeHandlers(rest.onClick, () => dialog.setOpen(false)),
460
+ });
461
+
462
+ if (render != null) {
463
+ return render(props);
464
+ }
465
+ return <button {...props} type="button" />;
442
466
  }
443
467
 
444
468
  /**