@charcuterie/ui 4.26.0 → 4.26.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"TableViewport.d.ts","sourceRoot":"","sources":["../../src/TableViewport/TableViewport.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAA;AAMrD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,2EAA2E;IAC3E,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,2FAA2F;AAC3F,eAAO,MAAM,aAAa,GAAI,8CAK3B,kBAAkB,KAAG,SAiEvB,CAAA"}
1
+ {"version":3,"file":"TableViewport.d.ts","sourceRoot":"","sources":["../../src/TableViewport/TableViewport.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,SAAS,EAAE,MAAM,OAAO,CAAA;AAWrD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,2EAA2E;IAC3E,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,2FAA2F;AAC3F,eAAO,MAAM,aAAa,GAAI,8CAK3B,kBAAkB,KAAG,SA6IvB,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRef, useState } from "react";
2
+ import { useCallback, useLayoutEffect, useRef, useState, } from "react";
3
3
  import { Button } from "../Button/Button.js";
4
4
  import { Dialog } from "../Dialog/Dialog.js";
5
5
  import { toClassName } from "../toClassName.js";
@@ -9,10 +9,78 @@ export const TableViewport = ({ children, className, columnCount, label, }) => {
9
9
  const [inlineHeight, setInlineHeight] = useState(0);
10
10
  const scrollRef = useRef(null);
11
11
  const scrollPosition = useRef(0);
12
+ const expansion = useRef(null);
13
+ const isClosing = useRef(false);
14
+ useLayoutEffect(() => {
15
+ // Restore after React has actually returned the table to this pane.
16
+ // A frame scheduled from the animation's promise can run before that commit
17
+ // on a busy renderer, when the empty pane clamps scrollLeft back to zero.
18
+ if (!isExpanded && scrollRef.current)
19
+ scrollRef.current.scrollLeft = scrollPosition.current;
20
+ }, [isExpanded]);
21
+ const animateExpansion = useCallback((node) => {
22
+ const dialog = node?.closest('[role="dialog"]');
23
+ if (!dialog ||
24
+ matchMedia("(prefers-reduced-motion: reduce)")
25
+ .matches)
26
+ return;
27
+ const style = getComputedStyle(dialog);
28
+ const durationToken = style
29
+ .getPropertyValue("--duration-slow")
30
+ .trim();
31
+ // CSS minifiers can rewrite 320ms to .32s; WAAPI always takes ms.
32
+ const duration = Number.parseFloat(durationToken) *
33
+ (durationToken.endsWith("ms")
34
+ ? 1
35
+ : durationToken.endsWith("s")
36
+ ? 1000
37
+ : 0);
38
+ // Zero-duration profiles (including ePaper) remove motion entirely.
39
+ if (!(duration > 0))
40
+ return;
41
+ const animation = dialog.animate([
42
+ {
43
+ opacity: 0,
44
+ transform: "translateY(0.75rem) scale(0.98)",
45
+ },
46
+ { opacity: 1, transform: "none" },
47
+ ], {
48
+ duration,
49
+ easing: style
50
+ .getPropertyValue("--easing-standard")
51
+ .trim() || "ease-out",
52
+ fill: "both",
53
+ });
54
+ expansion.current = animation;
55
+ // Unmounting a document can cancel an unfinished enter or exit.
56
+ void animation.finished.catch(() => { });
57
+ return () => {
58
+ animation.cancel();
59
+ expansion.current = null;
60
+ };
61
+ }, []);
62
+ const close = () => {
63
+ if (isClosing.current)
64
+ return;
65
+ isClosing.current = true;
66
+ const finish = () => {
67
+ setIsExpanded(false);
68
+ isClosing.current = false;
69
+ };
70
+ const animation = expansion.current;
71
+ if (!animation ||
72
+ matchMedia("(prefers-reduced-motion: reduce)").matches) {
73
+ finish();
74
+ return;
75
+ }
76
+ // Reversing also handles Escape during the opening animation without a jump.
77
+ animation.reverse();
78
+ void animation.finished.then(finish, () => { });
79
+ };
12
80
  const contentStyle = {
13
81
  "--table-min-inline-size": `${Math.max(1, columnCount) * 14}rem`,
14
82
  };
15
- const content = (_jsx("div", { className: "charcuterie-table-content", style: contentStyle, children: children }));
83
+ const content = (_jsx("div", { className: "charcuterie-table-content", ref: isExpanded ? animateExpansion : undefined, style: contentStyle, children: children }));
16
84
  return (_jsxs("div", { className: toClassName("min-w-0", className), children: [_jsx("div", { className: "flex items-center justify-end border border-border-subtle bg-surface-raised p-2", children: _jsx(Button, { appearance: "ghost", intent: "neutral", size: "sm", onClick: () => {
17
85
  const pane = scrollRef.current;
18
86
  setInlineHeight(pane?.getBoundingClientRect().height ?? 0);
@@ -20,13 +88,6 @@ export const TableViewport = ({ children, className, columnCount, label, }) => {
20
88
  setIsExpanded(true);
21
89
  }, children: "Expand table" }) }), _jsx("section", { "aria-label": label, className: "charcuterie-scrollbar charcuterie-table-scroll border border-border-subtle border-t-0", ref: scrollRef,
22
90
  // biome-ignore lint/a11y/noNoninteractiveTabindex: horizontal scroll must be reachable by keyboard
23
- tabIndex: 0, style: isExpanded ? { height: inlineHeight } : undefined, children: isExpanded ? null : content }), _jsx(Dialog, { heading: label, isVisible: isExpanded, size: "full", onClose: () => {
24
- setIsExpanded(false);
25
- requestAnimationFrame(() => {
26
- if (scrollRef.current)
27
- scrollRef.current.scrollLeft =
28
- scrollPosition.current;
29
- });
30
- }, children: isExpanded ? content : null })] }));
91
+ tabIndex: 0, style: isExpanded ? { height: inlineHeight } : undefined, children: isExpanded ? null : content }), _jsx(Dialog, { heading: label, isVisible: isExpanded, size: "full", onClose: close, children: isExpanded ? content : null })] }));
31
92
  };
32
93
  //# sourceMappingURL=TableViewport.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"TableViewport.js","sourceRoot":"","sources":["../../src/TableViewport/TableViewport.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAU/C,2FAA2F;AAC3F,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,EAC5B,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,GACc,EAAa,EAAE;IAClC,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACnD,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IACnD,MAAM,SAAS,GAAG,MAAM,CAAc,IAAI,CAAC,CAAA;IAC3C,MAAM,cAAc,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAChC,MAAM,YAAY,GAAG;QACnB,yBAAyB,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,WAAW,CAAC,GAAG,EAAE,KAAK;KAChD,CAAA;IAClB,MAAM,OAAO,GAAG,CACd,cACE,SAAS,EAAC,2BAA2B,EACrC,KAAK,EAAE,YAAY,YAElB,QAAQ,GACL,CACP,CAAA;IAED,OAAO,CACL,eAAK,SAAS,EAAE,WAAW,CAAC,SAAS,EAAE,SAAS,CAAC,aAC/C,cAAK,SAAS,EAAC,iFAAiF,YAC9F,KAAC,MAAM,IACL,UAAU,EAAC,OAAO,EAClB,MAAM,EAAC,SAAS,EAChB,IAAI,EAAC,IAAI,EACT,OAAO,EAAE,GAAG,EAAE;wBACZ,MAAM,IAAI,GAAG,SAAS,CAAC,OAAO,CAAA;wBAC9B,eAAe,CACb,IAAI,EAAE,qBAAqB,EAAE,CAAC,MAAM,IAAI,CAAC,CAC1C,CAAA;wBACD,cAAc,CAAC,OAAO,GAAG,IAAI,EAAE,UAAU,IAAI,CAAC,CAAA;wBAC9C,aAAa,CAAC,IAAI,CAAC,CAAA;oBACrB,CAAC,6BAGM,GACL,EACN,gCACc,KAAK,EACjB,SAAS,EAAC,uFAAuF,EACjG,GAAG,EAAE,SAAS;gBACd,mGAAmG;gBACnG,QAAQ,EAAE,CAAC,EACX,KAAK,EACH,UAAU,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,SAAS,YAGlD,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,GACpB,EACV,KAAC,MAAM,IACL,OAAO,EAAE,KAAK,EACd,SAAS,EAAE,UAAU,EACrB,IAAI,EAAC,MAAM,EACX,OAAO,EAAE,GAAG,EAAE;oBACZ,aAAa,CAAC,KAAK,CAAC,CAAA;oBACpB,qBAAqB,CAAC,GAAG,EAAE;wBACzB,IAAI,SAAS,CAAC,OAAO;4BACnB,SAAS,CAAC,OAAO,CAAC,UAAU;gCAC1B,cAAc,CAAC,OAAO,CAAA;oBAC5B,CAAC,CAAC,CAAA;gBACJ,CAAC,YAEA,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,GACrB,IACL,CACP,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"TableViewport.js","sourceRoot":"","sources":["../../src/TableViewport/TableViewport.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,WAAW,EACX,eAAe,EACf,MAAM,EACN,QAAQ,GACT,MAAM,OAAO,CAAA;AACd,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAsB,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAU/C,2FAA2F;AAC3F,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,EAC5B,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,GACc,EAAa,EAAE;IAClC,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACnD,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IACnD,MAAM,SAAS,GAAG,MAAM,CAAc,IAAI,CAAC,CAAA;IAC3C,MAAM,cAAc,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAChC,MAAM,SAAS,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAA;IAChD,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC/B,eAAe,CAAC,GAAG,EAAE;QACnB,oEAAoE;QACpE,4EAA4E;QAC5E,0EAA0E;QAC1E,IAAI,CAAC,UAAU,IAAI,SAAS,CAAC,OAAO;YAClC,SAAS,CAAC,OAAO,CAAC,UAAU,GAAG,cAAc,CAAC,OAAO,CAAA;IACzD,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAA;IAChB,MAAM,gBAAgB,GAAG,WAAW,CAClC,CAAC,IAA2B,EAAE,EAAE;QAC9B,MAAM,MAAM,GAAG,IAAI,EAAE,OAAO,CAC1B,iBAAiB,CAClB,CAAA;QACD,IACE,CAAC,MAAM;YACP,UAAU,CAAC,kCAAkC,CAAC;iBAC3C,OAAO;YAEV,OAAM;QAER,MAAM,KAAK,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAA;QACtC,MAAM,aAAa,GAAG,KAAK;aACxB,gBAAgB,CAAC,iBAAiB,CAAC;aACnC,IAAI,EAAE,CAAA;QACT,kEAAkE;QAClE,MAAM,QAAQ,GACZ,MAAM,CAAC,UAAU,CAAC,aAAa,CAAC;YAChC,CAAC,aAAa,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAC3B,CAAC,CAAC,CAAC;gBACH,CAAC,CAAC,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;oBAC3B,CAAC,CAAC,IAAI;oBACN,CAAC,CAAC,CAAC,CAAC,CAAA;QACV,oEAAoE;QACpE,IAAI,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC;YAAE,OAAM;QAE3B,MAAM,SAAS,GAAG,MAAM,CAAC,OAAO,CAC9B;YACE;gBACE,OAAO,EAAE,CAAC;gBACV,SAAS,EAAE,iCAAiC;aAC7C;YACD,EAAE,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE;SAClC,EACD;YACE,QAAQ;YACR,MAAM,EACJ,KAAK;iBACF,gBAAgB,CAAC,mBAAmB,CAAC;iBACrC,IAAI,EAAE,IAAI,UAAU;YACzB,IAAI,EAAE,MAAM;SACb,CACF,CAAA;QACD,SAAS,CAAC,OAAO,GAAG,SAAS,CAAA;QAC7B,gEAAgE;QAChE,KAAK,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACvC,OAAO,GAAG,EAAE;YACV,SAAS,CAAC,MAAM,EAAE,CAAA;YAClB,SAAS,CAAC,OAAO,GAAG,IAAI,CAAA;QAC1B,CAAC,CAAA;IACH,CAAC,EACD,EAAE,CACH,CAAA;IACD,MAAM,KAAK,GAAG,GAAG,EAAE;QACjB,IAAI,SAAS,CAAC,OAAO;YAAE,OAAM;QAC7B,SAAS,CAAC,OAAO,GAAG,IAAI,CAAA;QACxB,MAAM,MAAM,GAAG,GAAG,EAAE;YAClB,aAAa,CAAC,KAAK,CAAC,CAAA;YACpB,SAAS,CAAC,OAAO,GAAG,KAAK,CAAA;QAC3B,CAAC,CAAA;QACD,MAAM,SAAS,GAAG,SAAS,CAAC,OAAO,CAAA;QACnC,IACE,CAAC,SAAS;YACV,UAAU,CAAC,kCAAkC,CAAC,CAAC,OAAO,EACtD,CAAC;YACD,MAAM,EAAE,CAAA;YACR,OAAM;QACR,CAAC;QACD,6EAA6E;QAC7E,SAAS,CAAC,OAAO,EAAE,CAAA;QACnB,KAAK,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAChD,CAAC,CAAA;IACD,MAAM,YAAY,GAAG;QACnB,yBAAyB,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,WAAW,CAAC,GAAG,EAAE,KAAK;KAChD,CAAA;IAClB,MAAM,OAAO,GAAG,CACd,cACE,SAAS,EAAC,2BAA2B,EACrC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,SAAS,EAC9C,KAAK,EAAE,YAAY,YAElB,QAAQ,GACL,CACP,CAAA;IAED,OAAO,CACL,eAAK,SAAS,EAAE,WAAW,CAAC,SAAS,EAAE,SAAS,CAAC,aAC/C,cAAK,SAAS,EAAC,iFAAiF,YAC9F,KAAC,MAAM,IACL,UAAU,EAAC,OAAO,EAClB,MAAM,EAAC,SAAS,EAChB,IAAI,EAAC,IAAI,EACT,OAAO,EAAE,GAAG,EAAE;wBACZ,MAAM,IAAI,GAAG,SAAS,CAAC,OAAO,CAAA;wBAC9B,eAAe,CACb,IAAI,EAAE,qBAAqB,EAAE,CAAC,MAAM,IAAI,CAAC,CAC1C,CAAA;wBACD,cAAc,CAAC,OAAO,GAAG,IAAI,EAAE,UAAU,IAAI,CAAC,CAAA;wBAC9C,aAAa,CAAC,IAAI,CAAC,CAAA;oBACrB,CAAC,6BAGM,GACL,EACN,gCACc,KAAK,EACjB,SAAS,EAAC,uFAAuF,EACjG,GAAG,EAAE,SAAS;gBACd,mGAAmG;gBACnG,QAAQ,EAAE,CAAC,EACX,KAAK,EACH,UAAU,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,SAAS,YAGlD,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,GACpB,EACV,KAAC,MAAM,IACL,OAAO,EAAE,KAAK,EACd,SAAS,EAAE,UAAU,EACrB,IAAI,EAAC,MAAM,EACX,OAAO,EAAE,KAAK,YAEb,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,GACrB,IACL,CACP,CAAA;AACH,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "4.26.0",
3
+ "version": "4.26.2",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,8 @@ its rows and uses the page's vertical scroll. Expand table opens a full-window `
10
10
  The dialog body is the only scroll surface; headers and the first column stay visible.
11
11
  Escape and Close return focus to the trigger. Only one table is mounted, so links and IDs
12
12
  are never duplicated. The inline height and horizontal position survive expansion.
13
+ Expansion fades and zooms in; dismissal reverses the animation before returning to the
14
+ document. Reduced-motion and zero-duration profiles skip the animation.
13
15
 
14
16
  Apps provide the semantic table, its column count, and its accessible label. Sanitizing
15
17
  Markdown HTML and delegating document links remain the app's responsibilities. Use
@@ -1,6 +1,6 @@
1
1
  import { composeStories } from "@storybook/react"
2
2
  import { expect, userEvent, waitFor } from "storybook/test"
3
- import { test } from "vitest"
3
+ import { test, vi } from "vitest"
4
4
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
5
5
  import { mountStory } from "../mountStory.testHelpers.ts"
6
6
  import { expectAgentDrivable } from "../testing/index.ts"
@@ -8,6 +8,53 @@ import * as stories from "./TableViewport.stories.tsx"
8
8
 
9
9
  const { Interactive } = composeStories(stories)
10
10
 
11
+ test.each([
12
+ ["320ms", 320],
13
+ ["0.32s", 320],
14
+ ["0ms", 0],
15
+ ["0s", 0],
16
+ ] as const)(
17
+ "uses the CSS time unit in %s",
18
+ async (token, duration) => {
19
+ const style = document.documentElement.style
20
+ const previous = style.getPropertyValue(
21
+ "--duration-slow",
22
+ )
23
+ style.setProperty("--duration-slow", token)
24
+ try {
25
+ const { body, canvas } = await mountStory(Interactive)
26
+ await userEvent.click(
27
+ canvas.getByRole("button", {
28
+ name: "Expand table",
29
+ }),
30
+ )
31
+ const dialog = body.getByRole("dialog", {
32
+ name: "Printer comparison",
33
+ })
34
+ const [animation] = dialog.getAnimations()
35
+ if (duration > 0) {
36
+ await expect(
37
+ animation.effect?.getTiming().duration,
38
+ ).toBe(duration)
39
+ animation.finish()
40
+ } else {
41
+ await expect(animation).toBeUndefined()
42
+ }
43
+ await userEvent.click(
44
+ body.getByRole("button", { name: "Close" }),
45
+ )
46
+ if (animation) animation.finish()
47
+ await waitFor(() =>
48
+ expect(body.queryByRole("dialog")).toBeNull(),
49
+ )
50
+ } finally {
51
+ if (previous)
52
+ style.setProperty("--duration-slow", previous)
53
+ else style.removeProperty("--duration-slow")
54
+ }
55
+ },
56
+ )
57
+
11
58
  test("long inline comparisons grow with the page and expand without duplicate tables", async () => {
12
59
  const { body, canvas } = await mountStory(Interactive)
13
60
  const pane = expectAgentDrivable(canvas, {
@@ -26,6 +73,8 @@ test("long inline comparisons grow with the page and expand without duplicate ta
26
73
  role: "dialog",
27
74
  name: "Printer comparison",
28
75
  })
76
+ for (const animation of dialog.getAnimations())
77
+ animation.finish()
29
78
  await expect(
30
79
  document.querySelectorAll("table").length,
31
80
  ).toBe(1)
@@ -56,3 +105,89 @@ test("long inline comparisons grow with the page and expand without duplicate ta
56
105
  canvas.getByRole("table"),
57
106
  ).toBeInTheDocument()
58
107
  })
108
+
109
+ test("keeps the table and focus trap until the reverse animation completes", async () => {
110
+ const { body, canvas } = await mountStory(Interactive)
111
+ const trigger = canvas.getByRole("button", {
112
+ name: "Expand table",
113
+ })
114
+ const pane = canvas.getByRole("region", {
115
+ name: "Printer comparison",
116
+ })
117
+ pane.scrollLeft = 120
118
+ const scrollLeft = pane.scrollLeft
119
+ const height = pane.getBoundingClientRect().height
120
+ await userEvent.click(trigger)
121
+ const dialog = body.getByRole("dialog", {
122
+ name: "Printer comparison",
123
+ })
124
+ const [animation] = dialog.getAnimations()
125
+ await expect(animation).toBeDefined()
126
+ animation.pause()
127
+ animation.currentTime =
128
+ Number(animation.effect?.getTiming().duration) / 2
129
+ await expect(
130
+ Number(getComputedStyle(dialog).opacity),
131
+ ).toBeGreaterThan(0)
132
+ await expect(
133
+ Number(getComputedStyle(dialog).opacity),
134
+ ).toBeLessThan(1)
135
+ await expect(pane.getBoundingClientRect().height).toBe(
136
+ height,
137
+ )
138
+ await userEvent.keyboard("{Escape}")
139
+ await animation.ready
140
+ animation.pause()
141
+ await expect(animation.playbackRate).toBe(-1)
142
+ await expect(body.getByRole("dialog")).toBe(dialog)
143
+ await expect(
144
+ document.querySelectorAll("table"),
145
+ ).toHaveLength(1)
146
+ // A second dismiss during exit must not reverse back into an opening.
147
+ await userEvent.click(
148
+ body.getByRole("button", { name: "Close" }),
149
+ )
150
+ await expect(animation.playbackRate).toBe(-1)
151
+ animation.finish()
152
+ await waitFor(() =>
153
+ expect(body.queryByRole("dialog")).toBeNull(),
154
+ )
155
+ await waitFor(() =>
156
+ expect(document.activeElement).toBe(trigger),
157
+ )
158
+ await waitFor(() =>
159
+ expect(pane.scrollLeft).toBe(scrollLeft),
160
+ )
161
+ await expect(
162
+ canvas.getByRole("table"),
163
+ ).toBeInTheDocument()
164
+ })
165
+
166
+ test("reduced motion opens and closes immediately without a table animation", async () => {
167
+ const { body, canvas } = await mountStory(Interactive)
168
+ const matchMedia = window.matchMedia.bind(window)
169
+ const media = vi
170
+ .spyOn(window, "matchMedia")
171
+ .mockImplementation((query) =>
172
+ query === "(prefers-reduced-motion: reduce)"
173
+ ? { ...matchMedia(query), matches: true }
174
+ : matchMedia(query),
175
+ )
176
+ try {
177
+ await userEvent.click(
178
+ canvas.getByRole("button", { name: "Expand table" }),
179
+ )
180
+ const dialog = body.getByRole("dialog", {
181
+ name: "Printer comparison",
182
+ })
183
+ await expect(dialog.getAnimations()).toHaveLength(0)
184
+ await userEvent.click(
185
+ body.getByRole("button", { name: "Close" }),
186
+ )
187
+ await waitFor(() =>
188
+ expect(body.queryByRole("dialog")).toBeNull(),
189
+ )
190
+ } finally {
191
+ media.mockRestore()
192
+ }
193
+ })
@@ -1,5 +1,10 @@
1
1
  import type { CSSProperties, ReactNode } from "react"
2
- import { useRef, useState } from "react"
2
+ import {
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react"
3
8
  import { Button } from "../Button/Button.tsx"
4
9
  import { Dialog } from "../Dialog/Dialog.tsx"
5
10
  import { toClassName } from "../toClassName.ts"
@@ -23,12 +28,95 @@ export const TableViewport = ({
23
28
  const [inlineHeight, setInlineHeight] = useState(0)
24
29
  const scrollRef = useRef<HTMLElement>(null)
25
30
  const scrollPosition = useRef(0)
31
+ const expansion = useRef<Animation | null>(null)
32
+ const isClosing = useRef(false)
33
+ useLayoutEffect(() => {
34
+ // Restore after React has actually returned the table to this pane.
35
+ // A frame scheduled from the animation's promise can run before that commit
36
+ // on a busy renderer, when the empty pane clamps scrollLeft back to zero.
37
+ if (!isExpanded && scrollRef.current)
38
+ scrollRef.current.scrollLeft = scrollPosition.current
39
+ }, [isExpanded])
40
+ const animateExpansion = useCallback(
41
+ (node: HTMLDivElement | null) => {
42
+ const dialog = node?.closest<HTMLElement>(
43
+ '[role="dialog"]',
44
+ )
45
+ if (
46
+ !dialog ||
47
+ matchMedia("(prefers-reduced-motion: reduce)")
48
+ .matches
49
+ )
50
+ return
51
+
52
+ const style = getComputedStyle(dialog)
53
+ const durationToken = style
54
+ .getPropertyValue("--duration-slow")
55
+ .trim()
56
+ // CSS minifiers can rewrite 320ms to .32s; WAAPI always takes ms.
57
+ const duration =
58
+ Number.parseFloat(durationToken) *
59
+ (durationToken.endsWith("ms")
60
+ ? 1
61
+ : durationToken.endsWith("s")
62
+ ? 1000
63
+ : 0)
64
+ // Zero-duration profiles (including ePaper) remove motion entirely.
65
+ if (!(duration > 0)) return
66
+
67
+ const animation = dialog.animate(
68
+ [
69
+ {
70
+ opacity: 0,
71
+ transform: "translateY(0.75rem) scale(0.98)",
72
+ },
73
+ { opacity: 1, transform: "none" },
74
+ ],
75
+ {
76
+ duration,
77
+ easing:
78
+ style
79
+ .getPropertyValue("--easing-standard")
80
+ .trim() || "ease-out",
81
+ fill: "both",
82
+ },
83
+ )
84
+ expansion.current = animation
85
+ // Unmounting a document can cancel an unfinished enter or exit.
86
+ void animation.finished.catch(() => {})
87
+ return () => {
88
+ animation.cancel()
89
+ expansion.current = null
90
+ }
91
+ },
92
+ [],
93
+ )
94
+ const close = () => {
95
+ if (isClosing.current) return
96
+ isClosing.current = true
97
+ const finish = () => {
98
+ setIsExpanded(false)
99
+ isClosing.current = false
100
+ }
101
+ const animation = expansion.current
102
+ if (
103
+ !animation ||
104
+ matchMedia("(prefers-reduced-motion: reduce)").matches
105
+ ) {
106
+ finish()
107
+ return
108
+ }
109
+ // Reversing also handles Escape during the opening animation without a jump.
110
+ animation.reverse()
111
+ void animation.finished.then(finish, () => {})
112
+ }
26
113
  const contentStyle = {
27
114
  "--table-min-inline-size": `${Math.max(1, columnCount) * 14}rem`,
28
115
  } as CSSProperties
29
116
  const content = (
30
117
  <div
31
118
  className="charcuterie-table-content"
119
+ ref={isExpanded ? animateExpansion : undefined}
32
120
  style={contentStyle}
33
121
  >
34
122
  {children}
@@ -70,14 +158,7 @@ export const TableViewport = ({
70
158
  heading={label}
71
159
  isVisible={isExpanded}
72
160
  size="full"
73
- onClose={() => {
74
- setIsExpanded(false)
75
- requestAnimationFrame(() => {
76
- if (scrollRef.current)
77
- scrollRef.current.scrollLeft =
78
- scrollPosition.current
79
- })
80
- }}
161
+ onClose={close}
81
162
  >
82
163
  {isExpanded ? content : null}
83
164
  </Dialog>