@uniflowed/ui 0.0.0-alpha.7 → 0.0.0-alpha.9

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/dialog.js CHANGED
@@ -53,6 +53,7 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
53
53
 
54
54
  import type { Rest } from "./internal/merge-props.js";
55
55
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
56
+ import { focusable } from "./internal/focus.js";
56
57
  import { useControlled } from "./internal/controlled-state.js";
57
58
 
58
59
  type DialogState = {|
@@ -370,28 +371,6 @@ export component DialogClose(children: React.Node, ...rest: Rest) {
370
371
  );
371
372
  }
372
373
 
373
- /**
374
- * The focus stops inside an element, in document order.
375
- *
376
- * Disabled controls and `tabindex="-1"` are excluded because the browser
377
- * excludes them, and anything inside `[hidden]`, `[inert]` or `aria-hidden` is
378
- * excluded because a reader cannot reach it.
379
- */
380
- function focusable(root: HTMLElement): Array<HTMLElement> {
381
- const selector =
382
- 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
383
- return Array.from(root.querySelectorAll(selector)).filter(
384
- (element: $FlowFixMe) =>
385
- // All three attributes hide a whole subtree, so all three are checked on
386
- // the ancestors. Reading `aria-hidden` off the element alone returned a
387
- // button inside `<div aria-hidden="true">` as a focus stop, and the trap
388
- // then moved focus to a control no screen reader exposes.
389
- element.closest("[hidden]") == null &&
390
- element.closest("[inert]") == null &&
391
- element.closest('[aria-hidden="true"]') == null,
392
- );
393
- }
394
-
395
374
  /**
396
375
  * Take everything outside `element` out of the page, and give it back.
397
376
  *
package/hover-card.js ADDED
@@ -0,0 +1,334 @@
1
+ // @flow
2
+ //
3
+ // A hover card: the preview a name expands into, taken seriously.
4
+ //
5
+ // It is a tooltip's sibling and it is not a tooltip, and the difference is what
6
+ // is inside. A tooltip holds a phrase and must never be focusable; a hover card
7
+ // holds an avatar, a paragraph and two links, and every one of those has to be
8
+ // reachable — so the pointer has to be able to get there and so does `Tab`.
9
+ //
10
+ // That makes SC 1.4.13's hoverable clause the whole component rather than a
11
+ // detail of it: the gap between a name and the card that describes it takes a
12
+ // moment to cross with a mouse, longer with a trackpad, and much longer for a
13
+ // reader magnifying the screen. Closing on `pointerleave` snatches it away
14
+ // mid-journey. `internal/hover-intent.js` is the delay that stops that, shared
15
+ // with `tooltip.js` so the two cannot drift.
16
+ //
17
+ // # What it is not
18
+ //
19
+ // **Not a dialog.** It carries no `role="dialog"` and no `aria-modal`: nothing
20
+ // about it is modal, focus is not moved into it when it opens, and announcing a
21
+ // dialog a reader never asked for is worse than announcing nothing. Its content
22
+ // is in the document immediately after its trigger, so the reading order
23
+ // carries it and `Tab` reaches it — which is the whole of what a keyboard
24
+ // reader needs from it.
25
+ //
26
+ // **Not described by `aria-describedby`.** A card of links flattened into one
27
+ // description string is a sentence nobody can act on: the links stop being
28
+ // links. `tooltip.js` describes its trigger because a tooltip is a phrase; this
29
+ // does not, because this is not.
30
+ //
31
+ // # Touch
32
+ //
33
+ // It does not open on touch, for the reason `tooltip.js` gives at more length:
34
+ // there is no hover on a touch screen, and the only gesture left is the tap the
35
+ // trigger itself needs. A hover card is therefore an *enrichment* — the link
36
+ // under it must go somewhere useful on its own, because a reader on a phone
37
+ // will only ever get the link.
38
+
39
+ "use client";
40
+
41
+ import * as React from "@uniflowed/react";
42
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
43
+ import { useEventListener } from "@uniflowed/hooks/dom";
44
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
+
46
+ import type { Align, Side } from "./internal/anchor.js";
47
+ import type { HoverIntent } from "./internal/hover-intent.js";
48
+ import type { Rest } from "./internal/merge-props.js";
49
+ import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
50
+ import {
51
+ DEFAULT_CLOSE_DELAY,
52
+ DEFAULT_OPEN_DELAY,
53
+ useDismissOnEscape,
54
+ useFocusableTrigger,
55
+ useHoverIntent,
56
+ } from "./internal/hover-intent.js";
57
+ import { useAnchor } from "./internal/anchor.js";
58
+ import { useControlled } from "./internal/controlled-state.js";
59
+
60
+ export type { Align, Side } from "./internal/anchor.js";
61
+
62
+ type HoverCardState = {|
63
+ readonly base: string,
64
+ readonly open: boolean,
65
+ readonly setOpen: (open: boolean) => void,
66
+ readonly triggerRef: { current: HTMLElement | null },
67
+ readonly intent: HoverIntent,
68
+ readonly openDelay: number,
69
+ readonly closeDelay: number,
70
+ /** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
71
+ readonly dismissed: { current: boolean },
72
+ |};
73
+
74
+ const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
75
+
76
+ hook useHoverCard(part: string): HoverCardState {
77
+ const state = useContext(HoverCardContext);
78
+ if (state == null) {
79
+ throw new Error(`${part} must be rendered inside a HoverCard.Root`);
80
+ }
81
+ return state;
82
+ }
83
+
84
+ /**
85
+ * A hover card and the thing it previews.
86
+ *
87
+ * `closeDelay` is longer than a tooltip's would need to be on purpose: it is
88
+ * the time the reader has to reach the card, and a card holding links is a card
89
+ * they are reaching for.
90
+ */
91
+ export component HoverCardRoot(
92
+ children: React.Node,
93
+ closeDelay?: number = DEFAULT_CLOSE_DELAY,
94
+ defaultOpen?: boolean = false,
95
+ onOpenChange?: (open: boolean) => void,
96
+ open?: boolean,
97
+ openDelay?: number = DEFAULT_OPEN_DELAY,
98
+ ) {
99
+ const base = useId();
100
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
101
+ const triggerRef = useRef<HTMLElement | null>(null);
102
+ const dismissed = useRef(false);
103
+ const intent = useHoverIntent(setOpen);
104
+
105
+ const state = useMemo(
106
+ () => ({
107
+ base,
108
+ closeDelay,
109
+ dismissed,
110
+ intent,
111
+ open: isOpen,
112
+ openDelay,
113
+ setOpen,
114
+ triggerRef,
115
+ }),
116
+ [base, closeDelay, intent, isOpen, openDelay, setOpen],
117
+ );
118
+
119
+ return <HoverCardContext.Provider value={state}>{children}</HoverCardContext.Provider>;
120
+ }
121
+
122
+ /**
123
+ * What the card is about, which is usually a link.
124
+ *
125
+ * `render` is how it becomes one: `<HoverCard.Trigger render={(props) => <a
126
+ * href={profile} {...props}>@ada</a>} />`. Whatever it ends up as has to be
127
+ * reachable by keyboard, and `useFocusableTrigger` refuses anything else —
128
+ * a hover card on a `<span>` is one a keyboard reader can never see.
129
+ */
130
+ export component HoverCardTrigger(
131
+ children?: React.Node,
132
+ render?: (props: Rest) => React.Node,
133
+ ...rest: Rest
134
+ ) {
135
+ const card = useHoverCard("HoverCard.Trigger");
136
+ const { closeDelay, dismissed, intent, openDelay, triggerRef } = card;
137
+ useFocusableTrigger(triggerRef, "HoverCard.Trigger");
138
+
139
+ // A press focuses the trigger, and a card opening under the reader's own
140
+ // click would cover what they just went to. See `tooltip.js`.
141
+ const pressed = useRef(false);
142
+
143
+ useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
144
+ if (event.pointerType === "touch" || dismissed.current) {
145
+ return;
146
+ }
147
+ intent.openAfter(openDelay);
148
+ });
149
+ useEventListener(triggerRef, "pointerleave", () => {
150
+ dismissed.current = false;
151
+ intent.closeAfter(closeDelay);
152
+ });
153
+ useEventListener(triggerRef, "pointerdown", () => {
154
+ pressed.current = true;
155
+ intent.cancel();
156
+ });
157
+ useEventListener(triggerRef, "focusin", () => {
158
+ if (pressed.current) {
159
+ pressed.current = false;
160
+ return;
161
+ }
162
+ // The focus a dismissed card hands *back* to this trigger must not reopen
163
+ // it, which is the whole reason the flag exists: without it `Escape` closes
164
+ // the card, focus returns here, and the card comes straight back.
165
+ if (dismissed.current) {
166
+ return;
167
+ }
168
+ // A reader who tabbed here has said what they want; only the pointer is
169
+ // guessed at, so only the pointer waits.
170
+ intent.openAfter(0);
171
+ });
172
+ useEventListener(triggerRef, "focusout", () => {
173
+ pressed.current = false;
174
+ dismissed.current = false;
175
+ // `closeAfter` rather than a close, and this is where the delay earns its
176
+ // keep a second time: `Tab` from the trigger *into* the card is a leave
177
+ // followed immediately by an arrival, and the card's own `focusin` calls
178
+ // this off before it runs. A `closeDelay` of nought would close the card
179
+ // in the instant the reader reached it, which is why the default is not
180
+ // nought and why a caller who sets one should not set that.
181
+ intent.closeAfter(closeDelay);
182
+ });
183
+
184
+ // Annotated because this one is not written inside a `ref={...}`, and there
185
+ // is nothing else here for Flow to infer the element's type from.
186
+ const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
187
+ triggerRef.current = element;
188
+ });
189
+
190
+ if (render != null) {
191
+ return render(withProps(withoutComposed(rest, ["ref"]), { ref: attach }));
192
+ }
193
+
194
+ return (
195
+ <button {...withoutComposed(rest, ["ref"])} ref={attach} type="button">
196
+ {children}
197
+ </button>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The card.
203
+ *
204
+ * Stays while the pointer is over it and while focus is inside it, which are
205
+ * the same rule applied to the two ways a reader can be in it. `Escape` closes
206
+ * it from either — and when focus was inside, focus goes back to the trigger,
207
+ * because a card that took its own links away and left focus on `<body>` would
208
+ * send the reader back to the top of the page.
209
+ */
210
+ export component HoverCardBody(
211
+ children: React.Node,
212
+ align?: Align = "center",
213
+ alignOffset?: number = 0,
214
+ avoidCollisions?: boolean = true,
215
+ collisionPadding?: number = 0,
216
+ side?: Side = "bottom",
217
+ sideOffset?: number = 0,
218
+ ...rest: Rest
219
+ ) {
220
+ const card = useHoverCard("HoverCard.Body");
221
+ const { closeDelay, intent, open, triggerRef } = card;
222
+ const bodyRef = useRef<HTMLElement | null>(null);
223
+ // Whether the reader is *in* the card, as opposed to over it. It decides one
224
+ // thing and it cannot be asked afterwards: a card closed while it held focus
225
+ // has to hand focus back, and by the time the effect below is cleaned up the
226
+ // element is gone from the document and `activeElement` has already fallen to
227
+ // `<body>` — so the answer is kept while it is still true.
228
+ const held = useRef(false);
229
+ const close = useStableCallback(() => {
230
+ card.dismissed.current = true;
231
+ intent.cancel();
232
+ card.setOpen(false);
233
+ });
234
+
235
+ const anchored = useAnchor({
236
+ align,
237
+ alignOffset,
238
+ anchorRef: triggerRef,
239
+ avoidCollisions,
240
+ collisionPadding,
241
+ open,
242
+ overlayRef: bodyRef,
243
+ side,
244
+ sideOffset,
245
+ });
246
+
247
+ useDismissOnEscape(open, bodyRef, close);
248
+
249
+ // Keyed on `open`, because the element does not exist until then; see
250
+ // `tooltip.js` for why `useEventListener` cannot be used here.
251
+ useEffect(() => {
252
+ const body = bodyRef.current;
253
+ if (!open || body == null) {
254
+ return;
255
+ }
256
+ const stay = () => intent.cancel();
257
+ const go = () => intent.closeAfter(closeDelay);
258
+ const arrived = () => {
259
+ held.current = true;
260
+ stay();
261
+ };
262
+ const gone = () => {
263
+ held.current = false;
264
+ go();
265
+ };
266
+ body.addEventListener("pointerenter", stay);
267
+ body.addEventListener("pointerleave", go);
268
+ // `focusin` and `focusout` rather than `focus` and `blur`: the pair that
269
+ // bubbles is the one that hears a reader moving between two links *inside*
270
+ // the card, where the leave is immediately followed by an arrival and the
271
+ // scheduled close is called off before it runs.
272
+ body.addEventListener("focusin", arrived);
273
+ body.addEventListener("focusout", gone);
274
+
275
+ return () => {
276
+ body.removeEventListener("pointerenter", stay);
277
+ body.removeEventListener("pointerleave", go);
278
+ body.removeEventListener("focusin", arrived);
279
+ body.removeEventListener("focusout", gone);
280
+ };
281
+ }, [open, intent, closeDelay, triggerRef]);
282
+
283
+ // Focus goes back to the trigger when the card *closes* under the reader's
284
+ // focus, which is what `Escape` does: focus was on a link that no longer
285
+ // exists, and leaving it on `<body>` sends the reader back to the top of the
286
+ // page. A card that closed because the pointer left, with focus somewhere
287
+ // else entirely, has no business moving it.
288
+ //
289
+ // Its own effect, keyed on `open` alone. It used to live in the cleanup of
290
+ // the listener effect above, which runs whenever any of that effect's
291
+ // dependencies change — and `closeDelay` is a caller's prop. A caller
292
+ // changing it while the card was open with focus inside pulled focus off the
293
+ // link the reader was on, and the effect then re-attached with `held` reset.
294
+ useEffect(() => {
295
+ if (open) {
296
+ return;
297
+ }
298
+ if (held.current) {
299
+ held.current = false;
300
+ triggerRef.current?.focus?.();
301
+ }
302
+ }, [open, triggerRef]);
303
+
304
+ // And on unmount, which the effect above cannot see: a card removed while
305
+ // the reader is inside it leaves focus on a node that is gone.
306
+ useEffect(
307
+ () => () => {
308
+ if (held.current) {
309
+ held.current = false;
310
+ triggerRef.current?.focus?.();
311
+ }
312
+ },
313
+ [triggerRef],
314
+ );
315
+
316
+ if (!open) {
317
+ return null;
318
+ }
319
+
320
+ return (
321
+ <div
322
+ {...withoutComposed(rest, ["ref"])}
323
+ data-align={anchored.align}
324
+ data-side={anchored.side}
325
+ data-state="open"
326
+ id={`${card.base}-body`}
327
+ ref={composeRefs(rest.ref, (element) => {
328
+ bodyRef.current = element;
329
+ })}
330
+ >
331
+ {children}
332
+ </div>
333
+ );
334
+ }
package/index.js CHANGED
@@ -112,24 +112,36 @@
112
112
  // role, which is why it is beside one rather than with the layout.
113
113
  // - `table.js` and `pagination.js` — the sort that is announced, the selection
114
114
  // that can be mixed, and the rows a page is not showing.
115
+ // - `popover.js`, `tooltip.js` and `hover-card.js` — the three anchored
116
+ // overlays, which are one component seen from three distances: one you
117
+ // click, one you hover, and one you hover and then read. They are three
118
+ // modules because what a reader is told differs in every one — a popover is
119
+ // a dialog that is not modal, a tooltip describes its trigger and may never
120
+ // take focus, a hover card is neither and holds links — and because a flag
121
+ // selecting between them would be one flag every behaviour had to read.
115
122
  //
116
123
  // Every name below is exported from one of those, so a consumer may import
117
124
  // `@uniflowed/ui` or `@uniflowed/ui/dialog` and get the same thing. The split
118
125
  // is by primitive because that is the unit a reader looks for, the unit a
119
126
  // bundler drops, and the unit the WAI-ARIA practices are written in.
120
127
  //
121
- // `internal/` holds six modules and nothing else, each a rule the primitives
128
+ // `internal/` holds nine modules and nothing else, each a rule the primitives
122
129
  // must apply identically and a consumer must not be able to apply differently:
123
130
  // `merge-props.js` (the caller's props go on first, the component's semantics
124
131
  // last), `controlled-state.js` (what "controlled" means here),
125
132
  // `roving-focus.js` (how a set of items is found and moved between),
126
133
  // `disclosure.js` (how a button says whether a region is showing, and how a
127
134
  // closed region stays findable), `form-value.js` (what a `<form>` submits for a
128
- // control the browser has never heard of), and `range.js` (the arithmetic that
135
+ // control the browser has never heard of), `range.js` (the arithmetic that
129
136
  // keeps `aria-valuemin`, `aria-valuemax` and `aria-valuenow` true about each
130
- // other). Each says in its own header why it is unreachable rather than
131
- // exported. There is no `internal/props.js`-shaped bag of helpers: a module
132
- // that cannot say what it is about does not belong in this package.
137
+ // other), `anchor.js` (where an overlay goes, and what it does when it does not
138
+ // fit where it was asked to go), `focus.js` (which elements a reader can reach,
139
+ // which a focus trap and a popover want opposite things from), and
140
+ // `hover-intent.js` (what WCAG requires of content shown on hover or focus,
141
+ // which is three clauses and one mechanism). Each says in its own header why it
142
+ // is unreachable rather than exported. There is no `internal/props.js`-shaped
143
+ // bag of helpers: a module that cannot say what it is about does not belong in
144
+ // this package.
133
145
 
134
146
  import {
135
147
  AccordionContent,
@@ -161,6 +173,7 @@ import {
161
173
  DialogTrigger,
162
174
  } from "./dialog.js";
163
175
  import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from "./field.js";
176
+ import { HoverCardBody, HoverCardRoot, HoverCardTrigger } from "./hover-card.js";
164
177
  import {
165
178
  MenuBody,
166
179
  MenuGroup,
@@ -187,6 +200,7 @@ import {
187
200
  PaginationPrevious,
188
201
  PaginationRoot,
189
202
  } from "./pagination.js";
203
+ import { PopoverBody, PopoverRoot, PopoverTrigger } from "./popover.js";
190
204
  import { Progress } from "./progress.js";
191
205
  import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
192
206
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./resizable.js";
@@ -230,9 +244,14 @@ import {
230
244
  } from "./toast.js";
231
245
  import { Toggle } from "./toggle.js";
232
246
  import { ToggleGroupItem, ToggleGroupRoot } from "./toggle-group.js";
247
+ import { TooltipBody, TooltipProvider, TooltipRoot, TooltipTrigger } from "./tooltip.js";
233
248
 
234
249
  export type { AccordionType } from "./accordion.js";
235
250
  export type { ActivationMode } from "./tabs.js";
251
+ // Where an anchored overlay opens, for a caller who holds one in a variable or
252
+ // a prop of their own. Unions rather than strings, so `side="botom"` is a type
253
+ // error at the call rather than an overlay that quietly opens somewhere else.
254
+ export type { Align, Side } from "./popover.js";
236
255
  export type { Sort } from "./table.js";
237
256
  export type { Notification, ToastChanges, ToastOptions, Urgency } from "./toast.js";
238
257
  export type { ToggleGroupType } from "./toggle-group.js";
@@ -526,6 +545,81 @@ export const Select = {
526
545
  Separator: SelectSeparator,
527
546
  };
528
547
 
548
+ /**
549
+ * A dialog that is not modal, anchored to the button that opened it.
550
+ *
551
+ * Focus moves in, `Escape` closes it and gives focus back, and `Tab` *leaves* —
552
+ * the page behind a popover is still there, still scrollable and still
553
+ * tabbable, which is every way in which it is not a `Dialog`.
554
+ *
555
+ * <Popover.Root>
556
+ * <Popover.Trigger>Filters</Popover.Trigger>
557
+ * <Popover.Body align="start" side="bottom" sideOffset={8}>
558
+ * <label>
559
+ * Only mine <input type="checkbox" />
560
+ * </label>
561
+ * </Popover.Body>
562
+ * </Popover.Root>
563
+ *
564
+ * `Popover.Body` reports where it ended up as `data-side` and `data-align`, and
565
+ * writes the trigger's width and the room it had as custom properties, so a
566
+ * stylesheet can point an arrow and cap a height without measuring anything.
567
+ */
568
+ export const Popover = {
569
+ Root: PopoverRoot,
570
+ Trigger: PopoverTrigger,
571
+ Body: PopoverBody,
572
+ };
573
+
574
+ /**
575
+ * A phrase about a control, on hover and on focus, that WCAG would accept.
576
+ *
577
+ * Dismissible with `Escape`, hoverable — the pointer can travel onto it — and
578
+ * never focusable. It does not open on touch, deliberately, so the trigger must
579
+ * carry its own name for a reader holding a phone.
580
+ *
581
+ * <Tooltip.Provider delayDuration={700} skipDelayDuration={300}>
582
+ * <Tooltip.Root>
583
+ * <Tooltip.Trigger aria-label="Bold">B</Tooltip.Trigger>
584
+ * <Tooltip.Body>Bold (⌘B)</Tooltip.Body>
585
+ * </Tooltip.Root>
586
+ * <Tooltip.Root>
587
+ * <Tooltip.Trigger aria-label="Italic">I</Tooltip.Trigger>
588
+ * <Tooltip.Body>Italic (⌘I)</Tooltip.Body>
589
+ * </Tooltip.Root>
590
+ * </Tooltip.Provider>
591
+ *
592
+ * `Tooltip.Provider` is what makes the second icon in that toolbar answer at
593
+ * once instead of making the reader wait the delay again. A tooltip outside one
594
+ * is a complete tooltip with a delay of its own.
595
+ */
596
+ export const Tooltip = {
597
+ Provider: TooltipProvider,
598
+ Root: TooltipRoot,
599
+ Trigger: TooltipTrigger,
600
+ Body: TooltipBody,
601
+ };
602
+
603
+ /**
604
+ * The preview a name expands into: hovered, focused, and full of links.
605
+ *
606
+ * Not a tooltip — its contents are reachable, by pointer and by `Tab` — and not
607
+ * a dialog, because nothing about it is modal.
608
+ *
609
+ * <HoverCard.Root>
610
+ * <HoverCard.Trigger render={(props) => <a href="/ada" {...props}>@ada</a>} />
611
+ * <HoverCard.Body>
612
+ * <p>Ada Lovelace</p>
613
+ * <a href="/ada/notes">Notes</a>
614
+ * </HoverCard.Body>
615
+ * </HoverCard.Root>
616
+ */
617
+ export const HoverCard = {
618
+ Root: HoverCardRoot,
619
+ Trigger: HoverCardTrigger,
620
+ Body: HoverCardBody,
621
+ };
622
+
529
623
  /**
530
624
  * Notifications, in a live region that was watching before them.
531
625
  *