@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.
- package/dist/ComparisonChart/ComparisonChart.d.ts +12 -0
- package/dist/ComparisonChart/ComparisonChart.d.ts.map +1 -0
- package/dist/ComparisonChart/ComparisonChart.js +64 -0
- package/dist/ComparisonChart/ComparisonChart.js.map +1 -0
- package/dist/Lightbox/Lightbox.d.ts +10 -2
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +46 -3
- package/dist/Lightbox/Lightbox.js.map +1 -1
- package/dist/TableColumns/TableColumns.d.ts +14 -0
- package/dist/TableColumns/TableColumns.d.ts.map +1 -0
- package/dist/TableColumns/TableColumns.js +19 -0
- package/dist/TableColumns/TableColumns.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/ComparisonChart/ComparisonChart.mdx +14 -0
- package/src/ComparisonChart/ComparisonChart.stories.tsx +85 -0
- package/src/ComparisonChart/ComparisonChart.test.tsx +33 -0
- package/src/ComparisonChart/ComparisonChart.tsx +161 -0
- package/src/Dialog/Dialog.test.tsx +38 -8
- package/src/LaneTimeline/LaneTimelineBar.tsx +3 -3
- package/src/Lightbox/Lightbox.mdx +9 -0
- package/src/Lightbox/Lightbox.stories.tsx +26 -0
- package/src/Lightbox/Lightbox.test.tsx +53 -0
- package/src/Lightbox/Lightbox.tsx +109 -5
- package/src/PortraitTiles/PortraitTiles.test.tsx +17 -11
- package/src/TableColumns/TableColumns.mdx +14 -0
- package/src/TableColumns/TableColumns.stories.tsx +81 -0
- package/src/TableColumns/TableColumns.test.tsx +35 -0
- package/src/TableColumns/TableColumns.tsx +93 -0
- package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +21 -8
- package/src/board.storyHelpers.tsx +7 -1
- package/src/htmlEditor/HtmlEditor.test.tsx +32 -2
- package/src/index.ts +11 -1
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +12 -7
- package/src/sourceRules.test.ts +2 -1
- 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
|
-
|
|
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
|
-
|
|
84
|
+
return expectAgentDrivable(body, {
|
|
83
85
|
name: "Read error — lg",
|
|
84
86
|
role: "dialog",
|
|
85
87
|
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
})
|