@uniflowed/ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/select.js CHANGED
@@ -156,6 +156,7 @@ import type { Movement } from "./internal/roving-focus.js";
156
156
  import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving-focus.js";
157
157
  import { useControlled } from "./internal/controlled-state.js";
158
158
  import { FormValue } from "./internal/form-value.js";
159
+ import { presenceProps, usePresence } from "./internal/presence.js";
159
160
 
160
161
  export type { Align, LogicalSide, Side } from "./internal/anchor.js";
161
162
 
@@ -642,6 +643,9 @@ component SelectList(
642
643
  select.setOpen(false);
643
644
  select.setActiveId(null);
644
645
  });
646
+ // On the page while its exit runs. The trigger's `aria-controls` and
647
+ // `aria-activedescendant`, and the outside press, stay keyed on `open`.
648
+ const presence = usePresence(select.open, listRef);
645
649
 
646
650
  // The popup a select opens is the one case where the trigger's *width* is
647
651
  // part of the design rather than a detail: a list narrower than the button it
@@ -654,7 +658,7 @@ component SelectList(
654
658
  anchorRef: triggerRef,
655
659
  avoidCollisions,
656
660
  collisionPadding,
657
- open: select.open,
661
+ open: presence.present,
658
662
  overlayRef: listRef,
659
663
  side,
660
664
  sideOffset,
@@ -713,7 +717,7 @@ component SelectList(
713
717
  refs: [listRef, triggerRef],
714
718
  });
715
719
 
716
- if (!select.open) {
720
+ if (!presence.present) {
717
721
  return null;
718
722
  }
719
723
 
@@ -722,6 +726,7 @@ component SelectList(
722
726
  return (
723
727
  <div
724
728
  {...passed}
729
+ {...presenceProps(presence)}
725
730
  aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
726
731
  data-align={anchored.align}
727
732
  data-side={anchored.side}
package/tabs.js CHANGED
@@ -30,6 +30,27 @@
30
30
  // component that swallows it has taken scrolling away from every reader who
31
31
  // uses the keyboard to read.
32
32
  //
33
+ // # Where the selected tab is, for an indicator that slides
34
+ //
35
+ // An underline that moves from one tab to the next, rather than one that goes
36
+ // out under the old tab and comes on under the new, needs one number no
37
+ // stylesheet can work out: where the selected tab is inside the list. So
38
+ // `Tabs.List` measures it and writes four custom properties on itself:
39
+ //
40
+ // --uf-tabs-indicator-left --uf-tabs-indicator-top
41
+ // --uf-tabs-indicator-width --uf-tabs-indicator-height
42
+ //
43
+ // The selected tab's box, relative to the list's padding box — the box an
44
+ // absolutely positioned child of the list is placed in — in CSS pixels. They
45
+ // are **numbers without a unit**, which is the one form a stylesheet can use
46
+ // both ways: `calc(var(--uf-tabs-indicator-left) * 1px)` is a length, and
47
+ // `scaleX(var(--uf-tabs-indicator-width))` stretches a one-pixel bar to the
48
+ // tab's width, which moves the indicator with `transform` alone and lays
49
+ // nothing out while it travels. They are measured again whenever the list
50
+ // renders and whenever the list or a tab changes size, and removed when no tab
51
+ // is selected. As everywhere in this package, the numbers are the component's
52
+ // and the look is the stylesheet's: nothing is drawn here.
53
+ //
33
54
  // # Composition is type-checked
34
55
  //
35
56
  // `Tabs.List` takes `renders* TabsTab`, so putting a `<button>` in the list is
@@ -47,12 +68,18 @@ import {
47
68
  useEffect,
48
69
  useId,
49
70
  useMemo,
71
+ useRef,
50
72
  useState,
51
73
  } from "@uniflowed/react";
52
74
 
53
75
  import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
54
- import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
55
- import { moveOnKey } from "./internal/roving-focus.js";
76
+ import {
77
+ composeHandlers,
78
+ composeRefs,
79
+ withProps,
80
+ withoutComposed,
81
+ } from "./internal/merge-props.js";
82
+ import { itemsOf, moveOnKey } from "./internal/roving-focus.js";
56
83
  import { useControlled } from "./internal/controlled-state.js";
57
84
  import type { Orientation } from "./internal/roving-focus.js";
58
85
 
@@ -147,7 +174,45 @@ component TabsRoot(
147
174
  */
148
175
  component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Rest) {
149
176
  const tabs = useTabs("Tabs.List");
150
- const props = withProps(withoutComposed(rest, ["onKeyDown"]), {
177
+ const listRef = useRef<HTMLElement | null>(null);
178
+ const selected = tabs.selected;
179
+
180
+ // Every render, with no dependency list, for the reason
181
+ // `internal/disclosure.js` gives for its height: the tabs a caller renders
182
+ // can change on any render, and what the indicator follows is where the
183
+ // selected one ended up. Every write is compared first, so a render that
184
+ // moved nothing writes nothing.
185
+ useEffect(() => {
186
+ const list = listRef.current;
187
+ if (list != null) {
188
+ placeIndicator(list);
189
+ }
190
+ });
191
+
192
+ // A tab can move without anything here rendering: a web font arriving, a
193
+ // label a sibling component translated, the window narrowing a list that
194
+ // wraps. Keyed on `selected` so the observer is watching the tabs there are
195
+ // now, which is when a new one may have arrived.
196
+ useEffect(() => {
197
+ const list = listRef.current;
198
+ const view = list?.ownerDocument?.defaultView;
199
+ if (list == null || view == null) {
200
+ return;
201
+ }
202
+ // Read off the window, for the reason `internal/anchor.js` gives.
203
+ const host: $FlowFixMe = view;
204
+ if (typeof host.ResizeObserver !== "function") {
205
+ return;
206
+ }
207
+ const sizes = new host.ResizeObserver(() => placeIndicator(list));
208
+ sizes.observe(list);
209
+ for (const tab of itemsOf(list, TAB, TAB_LIST)) {
210
+ sizes.observe(tab);
211
+ }
212
+ return () => sizes.disconnect();
213
+ }, [selected]);
214
+
215
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
151
216
  // A screen reader announces the axis, and it is also what tells a reader
152
217
  // which arrow keys to try.
153
218
  "aria-orientation": tabs.orientation,
@@ -159,8 +224,8 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
159
224
  // set inside somebody else's `dir="rtl"` walks the right way without
160
225
  // the caller having had to know it needed to say so.
161
226
  const next = moveOnKey(event, list, {
162
- item: '[role="tab"]',
163
- owner: '[role="tablist"]',
227
+ item: TAB,
228
+ owner: TAB_LIST,
164
229
  orientation: tabs.orientation,
165
230
  wrap: true,
166
231
  skipDisabled: true,
@@ -169,6 +234,11 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
169
234
  tabs.select(next.getAttribute("data-value") ?? "");
170
235
  }
171
236
  }),
237
+ // React calls callback refs during commit; the indicator effects read it later.
238
+ // uf-lint-disable-next-line react-compiler/refs
239
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
240
+ listRef.current = element;
241
+ }),
172
242
  role: "tablist",
173
243
  });
174
244
 
@@ -178,6 +248,57 @@ component TabsList(children: renders* TabsTab, render?: RenderProp, ...rest: Res
178
248
  return <div {...props} />;
179
249
  }
180
250
 
251
+ /** A tab, and the list that owns it, as the arrow keys and the indicator find them. */
252
+ const TAB = '[role="tab"]';
253
+ const TAB_LIST = '[role="tablist"]';
254
+
255
+ /** The four properties the module header describes, in the order they are written. */
256
+ const INDICATOR = [
257
+ "--uf-tabs-indicator-left",
258
+ "--uf-tabs-indicator-top",
259
+ "--uf-tabs-indicator-width",
260
+ "--uf-tabs-indicator-height",
261
+ ];
262
+
263
+ /**
264
+ * Write the selected tab's box on `list`, or take it away when none is.
265
+ *
266
+ * Measured from the two bounding boxes rather than from `offsetLeft`, which is
267
+ * relative to the nearest *positioned* ancestor — the list only if a
268
+ * stylesheet happened to position it. The list's border is taken off and its
269
+ * scroll added back, so the numbers are in the padding box an absolutely
270
+ * positioned indicator is placed in, and a tab scrolled into view in a long
271
+ * list is still underlined where it is.
272
+ */
273
+ function placeIndicator(list: HTMLElement): void {
274
+ const tab = itemsOf(list, TAB, TAB_LIST).find(
275
+ (each) => each.getAttribute("aria-selected") === "true",
276
+ );
277
+ const style = list.style;
278
+ if (tab == null) {
279
+ for (const name of INDICATOR) {
280
+ style.removeProperty(name);
281
+ }
282
+ return;
283
+ }
284
+ const outer = list.getBoundingClientRect();
285
+ const inner = tab.getBoundingClientRect();
286
+ const values = [
287
+ inner.left - outer.left - list.clientLeft + list.scrollLeft,
288
+ inner.top - outer.top - list.clientTop + list.scrollTop,
289
+ inner.width,
290
+ inner.height,
291
+ ];
292
+ INDICATOR.forEach((name, index) => {
293
+ // Two decimal places: enough for a device pixel at any zoom, and a number
294
+ // that does not change in its fifteenth digit between two renders.
295
+ const value = String(Math.round(values[index] * 100) / 100);
296
+ if (style.getPropertyValue(name) !== value) {
297
+ style.setProperty(name, value);
298
+ }
299
+ });
300
+ }
301
+
181
302
  /**
182
303
  * One tab. Exactly one of them is in the page's tab order.
183
304
  *
package/toast.js CHANGED
@@ -88,6 +88,16 @@
88
88
  // A notification given `duration: null` never expires at all, which is what
89
89
  // anything carrying an action should be.
90
90
  //
91
+ // # Leaving
92
+ //
93
+ // A notification is taken out of the queue the moment it is dismissed, and it
94
+ // is not taken off the screen at that moment. The region remembers it and
95
+ // keeps rendering it where it was, with `data-state="closed"` and `inert`, for
96
+ // as long as its exit transition runs; `internal/presence.js` decides when
97
+ // that is. `inert` is what keeps this honest: a leaving notification is out of
98
+ // the accessibility tree and out of the tab order at once, so a reader is
99
+ // never told about, or tabbed into, something that has already gone.
100
+ //
91
101
  // # The queue lives outside React
92
102
  //
93
103
  // `toast("Saved")` is called from an event handler, from a `catch`, from a
@@ -141,6 +151,7 @@ import { useTimeout } from "@uniflowed/hooks/timing";
141
151
 
142
152
  import type { Rest } from "./internal/merge-props.js";
143
153
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
154
+ import { presenceProps, usePresence } from "./internal/presence.js";
144
155
 
145
156
  /** How loudly a notification interrupts. */
146
157
  export type Urgency = "polite" | "assertive";
@@ -319,6 +330,74 @@ function readQueue(): $ReadOnlyArray<Notification> {
319
330
 
320
331
  const NotificationContext: React.Context<Notification | null> = createContext(null);
321
332
 
333
+ /** A notification the region is rendering, and whether it is still in the queue. */
334
+ type Shown = {|
335
+ readonly notification: Notification,
336
+ /** False once it has been dismissed, while its exit plays. */
337
+ readonly open: boolean,
338
+ |};
339
+
340
+ /** What a `Toast.Root` is told about its own life by the region. */
341
+ type ToastLife = {|
342
+ readonly open: boolean,
343
+ /** Called once a dismissed notification's exit has finished. */
344
+ readonly gone: () => void,
345
+ |};
346
+
347
+ const ToastLifeContext: React.Context<ToastLife | null> = createContext(null);
348
+
349
+ /**
350
+ * What the region renders next, given what it rendered and what is queued now.
351
+ *
352
+ * Everything queued is open, in queue order. Everything the region was showing
353
+ * that is no longer queued stays, closed, where it was, so a notification
354
+ * leaving from the middle of the stack does not jump to the end of it first.
355
+ * A notification that arrives is placed after the last one before it in the
356
+ * queue, which for a queue that only ever appends is the end.
357
+ */
358
+ function nextShown(
359
+ previous: $ReadOnlyArray<Shown>,
360
+ queued: $ReadOnlyArray<Notification>,
361
+ ): $ReadOnlyArray<Shown> {
362
+ const byId = new Map<string, Notification>();
363
+ for (const notification of queued) {
364
+ byId.set(notification.id, notification);
365
+ }
366
+ const placed = new Set<string>();
367
+ const next: Array<Shown> = [];
368
+ let at = 0;
369
+ for (const entry of previous) {
370
+ const current = byId.get(entry.notification.id);
371
+ if (current == null) {
372
+ next.push(entry.open ? { notification: entry.notification, open: false } : entry);
373
+ continue;
374
+ }
375
+ // Whatever arrived in the queue before this one goes in front of it.
376
+ while (at < queued.length && queued[at].id !== current.id) {
377
+ const arrived = queued[at];
378
+ if (
379
+ !placed.has(arrived.id) &&
380
+ !previous.some((each) => each.notification.id === arrived.id)
381
+ ) {
382
+ next.push({ notification: arrived, open: true });
383
+ placed.add(arrived.id);
384
+ }
385
+ at += 1;
386
+ }
387
+ next.push({ notification: current, open: true });
388
+ placed.add(current.id);
389
+ at += 1;
390
+ }
391
+ for (; at < queued.length; at += 1) {
392
+ const arrived = queued[at];
393
+ if (!placed.has(arrived.id)) {
394
+ next.push({ notification: arrived, open: true });
395
+ placed.add(arrived.id);
396
+ }
397
+ }
398
+ return next;
399
+ }
400
+
322
401
  hook useNotification(part: string): Notification {
323
402
  const notification = useContext(NotificationContext);
324
403
  if (notification == null) {
@@ -387,12 +466,37 @@ component ToastRegion(
387
466
  // pile that hides what arrived first. What is over the limit is not rendered
388
467
  // at all, which is also what keeps its countdown from running before anyone
389
468
  // has seen it.
390
- const shown = queued.slice(0, limit);
469
+ const visible = queued.slice(0, limit);
470
+
471
+ // What was queued at the last render, and what the region is rendering: the
472
+ // visible notifications, and the dismissed ones still playing their exit.
473
+ // Brought up to date during render, the pattern React documents for
474
+ // "storing information from previous renders", so a dismissal never commits
475
+ // a frame without the notification that is leaving.
476
+ const [seen, setSeen] = useState<$ReadOnlyArray<Notification>>(visible);
477
+ const [shown, setShown] = useState<$ReadOnlyArray<Shown>>(() =>
478
+ visible.map((notification) => ({ notification, open: true })),
479
+ );
480
+ if (!sameNotifications(seen, visible)) {
481
+ setSeen(visible);
482
+ setShown(nextShown(shown, visible));
483
+ }
391
484
 
392
- const place = (notification: Notification) => (
393
- <NotificationContext.Provider key={notification.id} value={notification}>
394
- {children(notification)}
395
- </NotificationContext.Provider>
485
+ const place = (entry: Shown) => (
486
+ <ToastLifeContext.Provider
487
+ key={entry.notification.id}
488
+ value={{
489
+ open: entry.open,
490
+ gone: () =>
491
+ setShown((current) =>
492
+ current.filter((each) => each.open || each.notification.id !== entry.notification.id),
493
+ ),
494
+ }}
495
+ >
496
+ <NotificationContext.Provider value={entry.notification}>
497
+ {children(entry.notification)}
498
+ </NotificationContext.Provider>
499
+ </ToastLifeContext.Provider>
396
500
  );
397
501
 
398
502
  return (
@@ -408,15 +512,23 @@ component ToastRegion(
408
512
  tabIndex={-1}
409
513
  >
410
514
  <div aria-atomic="true" aria-live="polite" role="status">
411
- {shown.filter((each) => each.urgency === "polite").map(place)}
515
+ {shown.filter((each) => each.notification.urgency === "polite").map(place)}
412
516
  </div>
413
517
  <div aria-atomic="true" aria-live="assertive" role="alert">
414
- {shown.filter((each) => each.urgency === "assertive").map(place)}
518
+ {shown.filter((each) => each.notification.urgency === "assertive").map(place)}
415
519
  </div>
416
520
  </div>
417
521
  );
418
522
  }
419
523
 
524
+ /** Whether two lists hold the same notifications, in the same order. */
525
+ function sameNotifications(
526
+ left: $ReadOnlyArray<Notification>,
527
+ right: $ReadOnlyArray<Notification>,
528
+ ): boolean {
529
+ return left.length === right.length && left.every((each, index) => each === right[index]);
530
+ }
531
+
420
532
  /**
421
533
  * One notification, and the countdown that stops.
422
534
  *
@@ -434,6 +546,18 @@ component ToastRoot(children: React.Node, ...rest: Rest) {
434
546
  const [titled, setTitled] = useState(false);
435
547
  const [described, setDescribed] = useState(false);
436
548
  const passed = withoutComposed(rest, ["ref"]);
549
+ // Outside a `Toast.Region` — a notification rendered on its own, in a story
550
+ // or a test — there is no queue to leave, so it is simply open.
551
+ const life = useContext(ToastLifeContext);
552
+ const presence = usePresence(life?.open ?? true, elementRef);
553
+ const gone = life?.gone;
554
+ const present = presence.present;
555
+
556
+ useEffect(() => {
557
+ if (!present) {
558
+ gone?.();
559
+ }
560
+ }, [present, gone]);
437
561
 
438
562
  const hovered = useHover(elementRef);
439
563
  const focusInside = useFocusWithin(elementRef);
@@ -481,10 +605,15 @@ component ToastRoot(children: React.Node, ...rest: Rest) {
481
605
  [base],
482
606
  );
483
607
 
608
+ if (!present) {
609
+ return null;
610
+ }
611
+
484
612
  return (
485
613
  <ToastPartsContext.Provider value={parts}>
486
614
  <div
487
615
  {...passed}
616
+ {...presenceProps(presence)}
488
617
  aria-describedby={described ? parts.descriptionId : undefined}
489
618
  aria-labelledby={titled ? parts.titleId : undefined}
490
619
  ref={composeRefs(rest.ref, (node) => {
package/tooltip.js CHANGED
@@ -88,6 +88,7 @@ import {
88
88
  } from "./internal/hover-intent.js";
89
89
  import { useAnchor } from "./internal/anchor.js";
90
90
  import { useControlled } from "./internal/controlled-state.js";
91
+ import { presenceProps, usePresence } from "./internal/presence.js";
91
92
 
92
93
  export type { Align, LogicalSide, Side } from "./internal/anchor.js";
93
94
 
@@ -343,6 +344,9 @@ component TooltipBody(
343
344
  intent.cancel();
344
345
  tooltip.setOpen(false);
345
346
  });
347
+ // On the page while its exit runs. The trigger's `aria-describedby` and
348
+ // everything below stay keyed on `open`.
349
+ const presence = usePresence(open, bodyRef);
346
350
 
347
351
  const anchored = useAnchor({
348
352
  align,
@@ -350,7 +354,7 @@ component TooltipBody(
350
354
  anchorRef: triggerRef,
351
355
  avoidCollisions,
352
356
  collisionPadding,
353
- open,
357
+ open: presence.present,
354
358
  overlayRef: bodyRef,
355
359
  side,
356
360
  sideOffset,
@@ -377,7 +381,7 @@ component TooltipBody(
377
381
  };
378
382
  }, [open, intent, closeDelay]);
379
383
 
380
- if (!open) {
384
+ if (!presence.present) {
381
385
  return null;
382
386
  }
383
387
 
@@ -385,7 +389,7 @@ component TooltipBody(
385
389
  children,
386
390
  "data-align": anchored.align,
387
391
  "data-side": anchored.side,
388
- "data-state": "open",
392
+ ...presenceProps(presence),
389
393
  id: `${tooltip.base}-body`,
390
394
  // React calls callback refs during commit; placement effects read it later.
391
395
  // uf-lint-disable-next-line react-compiler/refs