@charcuterie/ui 4.20.1 → 4.21.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.
Files changed (39) hide show
  1. package/dist/ComparisonChart/ComparisonChart.d.ts +12 -0
  2. package/dist/ComparisonChart/ComparisonChart.d.ts.map +1 -0
  3. package/dist/ComparisonChart/ComparisonChart.js +64 -0
  4. package/dist/ComparisonChart/ComparisonChart.js.map +1 -0
  5. package/dist/Lightbox/Lightbox.d.ts +10 -2
  6. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  7. package/dist/Lightbox/Lightbox.js +46 -3
  8. package/dist/Lightbox/Lightbox.js.map +1 -1
  9. package/dist/TableColumns/TableColumns.d.ts +14 -0
  10. package/dist/TableColumns/TableColumns.d.ts.map +1 -0
  11. package/dist/TableColumns/TableColumns.js +19 -0
  12. package/dist/TableColumns/TableColumns.js.map +1 -0
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/package.json +3 -2
  18. package/src/ComparisonChart/ComparisonChart.mdx +14 -0
  19. package/src/ComparisonChart/ComparisonChart.stories.tsx +85 -0
  20. package/src/ComparisonChart/ComparisonChart.test.tsx +33 -0
  21. package/src/ComparisonChart/ComparisonChart.tsx +161 -0
  22. package/src/Dialog/Dialog.test.tsx +38 -8
  23. package/src/LaneTimeline/LaneTimelineBar.tsx +3 -3
  24. package/src/Lightbox/Lightbox.mdx +9 -0
  25. package/src/Lightbox/Lightbox.stories.tsx +26 -0
  26. package/src/Lightbox/Lightbox.test.tsx +53 -0
  27. package/src/Lightbox/Lightbox.tsx +109 -5
  28. package/src/PortraitTiles/PortraitTiles.test.tsx +17 -11
  29. package/src/TableColumns/TableColumns.mdx +14 -0
  30. package/src/TableColumns/TableColumns.stories.tsx +81 -0
  31. package/src/TableColumns/TableColumns.test.tsx +35 -0
  32. package/src/TableColumns/TableColumns.tsx +93 -0
  33. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +21 -8
  34. package/src/board.storyHelpers.tsx +7 -1
  35. package/src/htmlEditor/HtmlEditor.test.tsx +32 -2
  36. package/src/index.ts +11 -1
  37. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +12 -7
  38. package/src/sourceRules.test.ts +2 -1
  39. package/src/vitestProvidedContext.d.ts +6 -0
@@ -0,0 +1,161 @@
1
+ import { ComparisonPlot } from "@charcuterie/logic/charts/react"
2
+ import type { ChartSeries } from "@charcuterie/logic/core"
3
+ import { getReadableTextColour } from "@charcuterie/tokens"
4
+ import {
5
+ type CSSProperties,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react"
10
+ import { DataTable } from "../DataTable/DataTable.tsx"
11
+
12
+ export type ComparisonChartProps = {
13
+ title: string
14
+ labels: readonly string[]
15
+ series: readonly ChartSeries[]
16
+ layout?: "nested" | "grouped"
17
+ formatValue?: (value: number) => string
18
+ isTableVisible?: boolean
19
+ }
20
+
21
+ /** Horizontal comparisons with one legend, a common scale, and original-value labels. */
22
+ export const ComparisonChart = ({
23
+ title,
24
+ labels,
25
+ series,
26
+ layout = "nested",
27
+ formatValue = String,
28
+ isTableVisible = false,
29
+ }: ComparisonChartProps) => {
30
+ const container = useRef<HTMLDivElement>(null)
31
+ const [width, setWidth] = useState(640)
32
+ useEffect(() => {
33
+ if (!container.current) return
34
+ const observer = new ResizeObserver(([entry]) => {
35
+ if (entry.contentRect.width > 0)
36
+ setWidth(entry.contentRect.width)
37
+ })
38
+ observer.observe(container.current)
39
+ return () => observer.disconnect()
40
+ }, [])
41
+ const maximum = Math.max(
42
+ 1,
43
+ ...series.flatMap((entry) =>
44
+ entry.values.filter(
45
+ (value): value is number =>
46
+ value !== null && Number.isFinite(value),
47
+ ),
48
+ ),
49
+ )
50
+ const minimum = Math.min(
51
+ 0,
52
+ ...series.flatMap((entry) =>
53
+ entry.values.filter(
54
+ (value): value is number =>
55
+ value !== null && Number.isFinite(value),
56
+ ),
57
+ ),
58
+ )
59
+ return (
60
+ <figure
61
+ className="min-w-0 text-content-primary"
62
+ style={
63
+ {
64
+ "--ts-chart-tooltip-background":
65
+ "var(--color-surface-raised)",
66
+ "--ts-chart-tooltip-color":
67
+ "var(--color-content-primary)",
68
+ } as CSSProperties
69
+ }
70
+ >
71
+ <figcaption className="sr-only">{title}</figcaption>
72
+ <ul
73
+ aria-label={`${title} legend`}
74
+ className="mb-3 flex flex-wrap gap-3 text-xs"
75
+ >
76
+ {series.map((entry) => (
77
+ <li
78
+ key={entry.id}
79
+ className="min-w-0 wrap-anywhere"
80
+ >
81
+ <span
82
+ aria-hidden="true"
83
+ className="me-1 inline-block size-2 rounded-full"
84
+ style={{ backgroundColor: entry.color }}
85
+ />
86
+ {entry.label}
87
+ </li>
88
+ ))}
89
+ </ul>
90
+ <div ref={container} className="space-y-3">
91
+ {labels.map((label, index) => {
92
+ const values = series.map((entry) => ({
93
+ id: entry.id,
94
+ label: entry.label,
95
+ value: entry.values[index] ?? null,
96
+ colour: entry.color ?? "currentColor",
97
+ textColour:
98
+ entry.color &&
99
+ /^#[0-9a-f]{6}$/i.test(entry.color)
100
+ ? getReadableTextColour(entry.color)
101
+ : "var(--color-surface-base)",
102
+ }))
103
+ const shown = values.filter(
104
+ (entry) => entry.value !== null,
105
+ )
106
+ return (
107
+ <div
108
+ key={JSON.stringify([
109
+ label,
110
+ values
111
+ .filter((entry) => entry.value !== null)
112
+ .map((entry) => entry.id),
113
+ ])}
114
+ >
115
+ <p className="text-sm wrap-anywhere">
116
+ {label}
117
+ </p>
118
+ <ComparisonPlot
119
+ title={`${title}: ${label}`}
120
+ values={values}
121
+ maximum={maximum}
122
+ minimum={minimum}
123
+ layout={layout}
124
+ formatValue={formatValue}
125
+ width={width}
126
+ height={shown.length > 1 ? 60 : 34}
127
+ />
128
+ </div>
129
+ )
130
+ })}
131
+ </div>
132
+ {isTableVisible && (
133
+ <DataTable
134
+ label={`${title} values`}
135
+ rows={labels.map((label, index) => ({
136
+ label,
137
+ index,
138
+ }))}
139
+ getRowKey={(row) => String(row.index)}
140
+ columns={[
141
+ {
142
+ key: "label",
143
+ header: "Card",
144
+ renderCell: (row) => row.label,
145
+ },
146
+ ...series.map((entry) => ({
147
+ key: entry.id,
148
+ header: entry.label,
149
+ renderCell: (row: { index: number }) => {
150
+ const value = entry.values[row.index]
151
+ return value == null
152
+ ? "-"
153
+ : formatValue(value)
154
+ },
155
+ })),
156
+ ]}
157
+ />
158
+ )}
159
+ </figure>
160
+ )
161
+ }
@@ -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 { inject, test } from "vitest"
4
4
 
5
5
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
6
  import { mountStory } from "../mountStory.testHelpers.ts"
@@ -69,7 +69,9 @@ test("the dialog is reachable from a body-scoped query", async () => {
69
69
  await expectNoAxeViolations(dialog)
70
70
  })
71
71
 
72
- test("size is a real width, not a class that happens to be there", async () => {
72
+ const isNarrow = inject("viewport") === "narrow"
73
+
74
+ const openLargeDialog = async () => {
73
75
  const { body, canvas } = await mountStory(AllVariants)
74
76
 
75
77
  await userEvent.click(
@@ -79,15 +81,43 @@ test("size is a real width, not a class that happens to be there", async () => {
79
81
  }),
80
82
  )
81
83
 
82
- const dialog = expectAgentDrivable(body, {
84
+ return expectAgentDrivable(body, {
83
85
  name: "Read error — lg",
84
86
  role: "dialog",
85
87
  })
86
-
87
- await expect(
88
- dialog.getBoundingClientRect().width,
89
- ).toBeGreaterThan(400)
90
- })
88
+ }
89
+
90
+ test.skipIf(isNarrow)(
91
+ "size is a real width, not a class that happens to be there",
92
+ async () => {
93
+ const dialog = await openLargeDialog()
94
+
95
+ await expect(
96
+ dialog.getBoundingClientRect().width,
97
+ ).toBeGreaterThan(400)
98
+ },
99
+ )
100
+
101
+ /*
102
+ * On the phone every size is `w-full` against a 384px window, so `lg`
103
+ * is no wider than `sm` and the test above has nothing to measure.
104
+ * What the Narrow View owes instead is that the largest dialog still
105
+ * fits: a dialog wider than the window is one whose close button is
106
+ * off the screen.
107
+ */
108
+ test.runIf(isNarrow)(
109
+ "a large dialog still fits inside a phone's window",
110
+ async () => {
111
+ const { left, right } = (
112
+ await openLargeDialog()
113
+ ).getBoundingClientRect()
114
+
115
+ await expect(left).toBeGreaterThanOrEqual(0)
116
+ await expect(right).toBeLessThanOrEqual(
117
+ window.innerWidth,
118
+ )
119
+ },
120
+ )
91
121
 
92
122
  test("a non-dismissable dialog ignores Escape rather than closing", async () => {
93
123
  const { body, canvas } = await mountStory(AllStates)
@@ -166,9 +166,9 @@ export const LaneTimelineBar = ({
166
166
  *
167
167
  * ⚠️ It does NOT reach a CAPITALIZED word. Chromium and
168
168
  * Firefox both skip a word whose first letter is upper case,
169
- * as a proper-noun heuristic, and a 60px probe box confirmed
170
- * it in both: `taekwondo` broke as `tae-` / `kwondo` and
171
- * `Taekwondo` as `Taekwond` / `o`. Most calendar titles start
169
+ * as a proper-noun heuristic. A 60px probe box confirmed
170
+ * that the lower-case variant used dictionary hyphenation,
171
+ * while the capitalized variant broke at the edge. Titles start
172
172
  * with a capital, so this helps the second word of a title
173
173
  * more than the first. A capitalized word needs a soft
174
174
  * hyphen (U+00AD) placed in the DATA by the app; `auto`
@@ -61,3 +61,12 @@ that the contract `Dialog` guarantees is not quietly dropped by the wrapper —
61
61
  `onClose`, and focus back to the thumbnail.
62
62
 
63
63
  <Canvas of={LightboxStories.Interactive} />
64
+
65
+ ## An ordered gallery
66
+
67
+ Pass `images` and `initialIndex` to open a collection at the selected thumbnail.
68
+ Previous/Next controls and Left/Right arrow keys wrap through the collection. Escape
69
+ closes it and restores focus to the trigger. `heading` stays stable while the image,
70
+ caption, and announced position change. Omit `images` for the existing single-image view.
71
+
72
+ <Canvas of={LightboxStories.Gallery} />
@@ -133,3 +133,29 @@ export const Interactive: Story = {
133
133
  thumbnail: <PosterThumb />,
134
134
  },
135
135
  }
136
+
137
+ /** Any thumbnail can start the same ordered gallery at its own image. */
138
+ export const Gallery: Story = {
139
+ args: {
140
+ heading: "Poster gallery",
141
+ initialIndex: 1,
142
+ images: [
143
+ {
144
+ alt: "First poster",
145
+ src: toPosterSrc("FIRST"),
146
+ caption: "First view",
147
+ },
148
+ {
149
+ alt: "Second poster",
150
+ src: toPosterSrc("SECOND"),
151
+ caption: "Second view",
152
+ },
153
+ {
154
+ alt: "Third poster",
155
+ src: toPosterSrc("THIRD"),
156
+ caption: "Third view",
157
+ },
158
+ ],
159
+ thumbnail: <PosterThumb />,
160
+ },
161
+ }
@@ -122,3 +122,56 @@ test("a controlled lightbox opens from an outside button", async () => {
122
122
 
123
123
  await expect(dialog).not.toBeInTheDocument()
124
124
  })
125
+
126
+ test("a gallery opens at its thumbnail and navigates with controls and arrow keys", async () => {
127
+ const { Gallery } = composeStories(stories)
128
+ const { body, canvas } = await mountStory(Gallery)
129
+ const trigger = expectAgentDrivable(canvas, {
130
+ name: "Enlarge Poster gallery",
131
+ role: "button",
132
+ })
133
+ await userEvent.click(trigger)
134
+ const dialog = expectAgentDrivable(body, {
135
+ name: "Poster gallery",
136
+ role: "dialog",
137
+ })
138
+ expectAgentDrivable(body, {
139
+ name: "Second poster",
140
+ role: "img",
141
+ })
142
+ await userEvent.keyboard("{ArrowRight}")
143
+ expectAgentDrivable(body, {
144
+ name: "Third poster",
145
+ role: "img",
146
+ })
147
+ await userEvent.click(
148
+ body.getByRole("button", { name: "Next image" }),
149
+ )
150
+ expectAgentDrivable(body, {
151
+ name: "First poster",
152
+ role: "img",
153
+ })
154
+ await userEvent.keyboard("{ArrowLeft}")
155
+ expectAgentDrivable(body, {
156
+ name: "Third poster",
157
+ role: "img",
158
+ })
159
+ await userEvent.click(
160
+ body.getByRole("button", { name: "Previous image" }),
161
+ )
162
+ expectAgentDrivable(body, {
163
+ name: "Second poster",
164
+ role: "img",
165
+ })
166
+ await expectNoAxeViolations(dialog)
167
+ await userEvent.keyboard("{Escape}")
168
+ await waitFor(() =>
169
+ expect(body.queryByRole("dialog")).toBeNull(),
170
+ )
171
+ await expect(document.activeElement).toBe(trigger)
172
+ await userEvent.click(trigger)
173
+ expectAgentDrivable(body, {
174
+ name: "Second poster",
175
+ role: "img",
176
+ })
177
+ })
@@ -1,10 +1,20 @@
1
1
  import { useVisibility } from "@charcuterie/logic"
2
- import type { ReactNode } from "react"
2
+ import { type ReactNode, useEffect, useState } from "react"
3
3
 
4
+ import { Button } from "../Button/Button.tsx"
4
5
  import { Dialog } from "../Dialog/Dialog.tsx"
5
6
  import { toClassName } from "../toClassName.ts"
6
7
 
8
+ export type LightboxImage = {
9
+ readonly alt: string
10
+ readonly src: string
11
+ readonly caption?: ReactNode
12
+ }
13
+
7
14
  export type LightboxProps = {
15
+ /** Optional ordered gallery. The trigger opens at initialIndex. */
16
+ images?: readonly LightboxImage[]
17
+ initialIndex?: number
8
18
  /**
9
19
  * Required, and the whole reason this is not a bare `<img>` in a
10
20
  * dialog. It names both the enlarged image and — via `heading`'s
@@ -64,6 +74,8 @@ export const Lightbox = ({
64
74
  caption,
65
75
  className,
66
76
  heading,
77
+ images,
78
+ initialIndex = 0,
67
79
  isOpen,
68
80
  onOpenChange,
69
81
  src,
@@ -72,10 +84,66 @@ export const Lightbox = ({
72
84
  const isControlled = isOpen !== undefined
73
85
 
74
86
  const { hide, isVisible, show } = useVisibility()
87
+ const [index, setIndex] = useState(initialIndex)
75
88
 
76
89
  const isShown = isControlled ? isOpen : isVisible
90
+ const gallery = images?.length
91
+ ? images
92
+ : [{ alt, caption, src }]
93
+ const activeIndex = Math.max(
94
+ 0,
95
+ Math.min(index, gallery.length - 1),
96
+ )
97
+ const active = gallery[activeIndex] ?? {
98
+ alt,
99
+ caption,
100
+ src,
101
+ }
102
+ const hasMultiple = gallery.length > 1
103
+
104
+ useEffect(() => {
105
+ if (!isShown || !hasMultiple) return
106
+ const navigate = (event: KeyboardEvent) => {
107
+ if (
108
+ event.altKey ||
109
+ event.ctrlKey ||
110
+ event.metaKey ||
111
+ event.shiftKey
112
+ )
113
+ return
114
+ if (
115
+ event.target instanceof HTMLElement &&
116
+ event.target.closest(
117
+ "input, textarea, [contenteditable=true]",
118
+ )
119
+ )
120
+ return
121
+ const delta =
122
+ event.key === "ArrowLeft"
123
+ ? -1
124
+ : event.key === "ArrowRight"
125
+ ? 1
126
+ : 0
127
+ if (delta === 0) return
128
+ event.preventDefault()
129
+ setIndex(
130
+ (current) =>
131
+ (Math.max(
132
+ 0,
133
+ Math.min(current, gallery.length - 1),
134
+ ) +
135
+ delta +
136
+ gallery.length) %
137
+ gallery.length,
138
+ )
139
+ }
140
+ document.addEventListener("keydown", navigate)
141
+ return () =>
142
+ document.removeEventListener("keydown", navigate)
143
+ }, [isShown, hasMultiple, gallery.length])
77
144
 
78
145
  const requestOpen = () => {
146
+ setIndex(initialIndex)
79
147
  if (!isControlled) {
80
148
  show()
81
149
  }
@@ -120,20 +188,56 @@ export const Lightbox = ({
120
188
  isVisible={isShown}
121
189
  onClose={requestClose}
122
190
  size="xl"
191
+ footer={
192
+ hasMultiple ? (
193
+ <div className="flex w-full items-center justify-between gap-3">
194
+ <Button
195
+ appearance="ghost"
196
+ size="sm"
197
+ onClick={() =>
198
+ setIndex(
199
+ (activeIndex + gallery.length - 1) %
200
+ gallery.length,
201
+ )
202
+ }
203
+ >
204
+ Previous image
205
+ </Button>
206
+ <span
207
+ className="text-content-secondary text-sm"
208
+ role="status"
209
+ aria-live="polite"
210
+ >
211
+ {activeIndex + 1} of {gallery.length}
212
+ </span>
213
+ <Button
214
+ appearance="ghost"
215
+ size="sm"
216
+ onClick={() =>
217
+ setIndex(
218
+ (activeIndex + 1) % gallery.length,
219
+ )
220
+ }
221
+ >
222
+ Next image
223
+ </Button>
224
+ </div>
225
+ ) : undefined
226
+ }
123
227
  >
124
228
  <figure className="flex flex-col items-center gap-3">
125
229
  <img
126
- alt={alt}
230
+ alt={active.alt}
127
231
  // `object-contain`, not `cover`: the enlarged view must
128
232
  // show the whole image, and the clamp is `dvh` for the
129
233
  // same address-bar reason `Modal`'s is.
130
234
  className="max-h-[75dvh] w-auto max-w-full rounded-md object-contain"
131
- src={src}
235
+ src={active.src}
132
236
  />
133
237
 
134
- {caption ? (
238
+ {active.caption ? (
135
239
  <figcaption className="text-center text-content-muted text-sm">
136
- {caption}
240
+ {active.caption}
137
241
  </figcaption>
138
242
  ) : null}
139
243
  </figure>
@@ -104,17 +104,23 @@ test("a picture that fails to load falls back to the initials", async () => {
104
104
  role: "group",
105
105
  })
106
106
 
107
- await waitFor(async () => {
108
- const broken = Array.from(
109
- group.querySelectorAll<HTMLImageElement>("img"),
110
- ).find((one) =>
111
- one.src.includes("charcuterie-missing-portrait"),
112
- )
113
-
114
- // Gone from the DOM entirely, replaced by the letter — not left
115
- // in place with a broken-image glyph in it.
116
- await expect(broken).toBeUndefined()
117
- })
107
+ // A real 404 over the network, not a mocked error event, so it gets
108
+ // longer than `waitFor`'s 1 s default: with four browser windows
109
+ // sharing a CI runner the failure arrived after it.
110
+ await waitFor(
111
+ async () => {
112
+ const broken = Array.from(
113
+ group.querySelectorAll<HTMLImageElement>("img"),
114
+ ).find((one) =>
115
+ one.src.includes("charcuterie-missing-portrait"),
116
+ )
117
+
118
+ // Gone from the DOM entirely, replaced by the letter — not left
119
+ // in place with a broken-image glyph in it.
120
+ await expect(broken).toBeUndefined()
121
+ },
122
+ { timeout: 5000 },
123
+ )
118
124
 
119
125
  const bailey = expectAgentDrivable(canvas, {
120
126
  name: "Bailey 860 points",
@@ -0,0 +1,14 @@
1
+ import { Canvas, Meta } from "@storybook/addon-docs/blocks"
2
+ import * as stories from "./TableColumns.stories.tsx"
3
+
4
+ <Meta of={stories} />
5
+
6
+ # TableColumns
7
+
8
+ A reusable column visibility and ordering popover. The app supplies the ordered column choices and persists them if needed. Required columns cannot be hidden. Earlier and Later buttons make ordering keyboard accessible.
9
+
10
+ <Canvas of={stories.Playground} />
11
+ <Canvas of={stories.AllVariants} />
12
+ <Canvas of={stories.AllStates} />
13
+ <Canvas of={stories.Responsive} />
14
+ <Canvas of={stories.Interactive} />
@@ -0,0 +1,81 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+ import { ContainerBoard } from "../board.storyHelpers.tsx"
5
+ import { TableColumns } from "./TableColumns.tsx"
6
+
7
+ const meta = {
8
+ title: "Components/Data/TableColumns",
9
+ component: TableColumns,
10
+ args: {
11
+ columns: [
12
+ {
13
+ key: "name",
14
+ label: "Name",
15
+ isVisible: true,
16
+ isRequired: true,
17
+ },
18
+ {
19
+ key: "duration",
20
+ label: "Duration",
21
+ isVisible: true,
22
+ },
23
+ { key: "median", label: "Median", isVisible: false },
24
+ ],
25
+ onChange: () => {},
26
+ },
27
+ } satisfies Meta<typeof TableColumns>
28
+ export default meta
29
+ type Story = StoryObj<typeof meta>
30
+ const Controlled = (
31
+ props: React.ComponentProps<typeof TableColumns>,
32
+ ) => {
33
+ const [columns, setColumns] = useState([...props.columns])
34
+ return (
35
+ <>
36
+ <TableColumns
37
+ {...props}
38
+ columns={columns}
39
+ onChange={setColumns}
40
+ />
41
+ <p>
42
+ {columns
43
+ .filter((column) => column.isVisible)
44
+ .map((column) => column.label)
45
+ .join(", ")}
46
+ </p>
47
+ </>
48
+ )
49
+ }
50
+ export const Playground: Story = {
51
+ parameters: playgroundParameters,
52
+ render: (props) => <Controlled {...props} />,
53
+ }
54
+ export const AllVariants: Story = {
55
+ render: (props) => (
56
+ <Controlled {...props} label="Choose columns" />
57
+ ),
58
+ }
59
+ export const AllStates: Story = {
60
+ args: {
61
+ columns: [
62
+ {
63
+ key: "name",
64
+ label: "Name",
65
+ isVisible: true,
66
+ isRequired: true,
67
+ },
68
+ ],
69
+ },
70
+ render: (props) => <Controlled {...props} />,
71
+ }
72
+ export const Responsive: Story = {
73
+ render: (props) => (
74
+ <ContainerBoard>
75
+ {() => <Controlled {...props} />}
76
+ </ContainerBoard>
77
+ ),
78
+ }
79
+ export const Interactive: Story = {
80
+ render: (props) => <Controlled {...props} />,
81
+ }
@@ -0,0 +1,35 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+ import { mountStory } from "../mountStory.testHelpers.ts"
5
+ import { expectAgentDrivable } from "../testing/index.ts"
6
+ import * as stories from "./TableColumns.stories.tsx"
7
+
8
+ const { Interactive } = composeStories(stories)
9
+ test("column visibility and order can be changed with named controls", async () => {
10
+ const { canvas, body } = await mountStory(Interactive)
11
+ await userEvent.click(
12
+ expectAgentDrivable(canvas, {
13
+ role: "button",
14
+ name: "Columns",
15
+ }),
16
+ )
17
+ await userEvent.click(
18
+ expectAgentDrivable(body, {
19
+ role: "checkbox",
20
+ name: "Median",
21
+ }),
22
+ )
23
+ await expect(
24
+ canvas.getByText("Name, Duration, Median"),
25
+ ).toBeVisible()
26
+ await userEvent.click(
27
+ expectAgentDrivable(body, {
28
+ role: "button",
29
+ name: "Move Median earlier",
30
+ }),
31
+ )
32
+ await expect(
33
+ canvas.getByText("Name, Median, Duration"),
34
+ ).toBeVisible()
35
+ })