@charcuterie/ui 4.20.1 → 4.22.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/LICENSE +21 -0
- package/README.md +6 -6
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +4 -1
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -1
- package/dist/AdaptiveGrid/AdaptiveGrid.js +3 -1
- package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -1
- package/dist/AdaptiveGrid/chooseColumns.d.ts +10 -2
- package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -1
- package/dist/AdaptiveGrid/chooseColumns.js +10 -3
- package/dist/AdaptiveGrid/chooseColumns.js.map +1 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +6 -2
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +4 -1
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
- 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/Overlay/useAnchoredOverlay.d.ts +2 -2
- 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/VirtualizedGrid/VirtualizedGrid.d.ts +4 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js +3 -1
- package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
- 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 +12 -9
- package/src/AdaptiveGrid/AdaptiveGrid.mdx +10 -0
- package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +17 -0
- package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +36 -2
- package/src/AdaptiveGrid/AdaptiveGrid.tsx +6 -0
- package/src/AdaptiveGrid/chooseColumns.ts +29 -1
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +13 -1
- 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/CopyButton/CopyButton.test.tsx +25 -12
- 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/VirtualizedGrid/VirtualizedGrid.tsx +6 -0
- 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 +28 -10
- package/src/mdxReferences.test.ts +1 -1
- 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
|
+
}
|
|
@@ -46,8 +46,12 @@ test("copies the value and confirms in place", async () => {
|
|
|
46
46
|
await expect(copy).toHaveBeenCalledWith("335590")
|
|
47
47
|
// The press and its proof are the same object — no toast, no
|
|
48
48
|
// second place to look.
|
|
49
|
+
//
|
|
50
|
+
// `findBy`: `copy` is async, so the label changes a render after
|
|
51
|
+
// the click resolves. A synchronous read lost that race in CI's
|
|
52
|
+
// ultrawide window (master run 37298376242).
|
|
49
53
|
await expect(
|
|
50
|
-
canvas.
|
|
54
|
+
await canvas.findByRole("button", { name: "Copied" }),
|
|
51
55
|
).toBeVisible()
|
|
52
56
|
})
|
|
53
57
|
|
|
@@ -73,7 +77,9 @@ test("says so when the clipboard refuses", async () => {
|
|
|
73
77
|
)
|
|
74
78
|
|
|
75
79
|
await expect(
|
|
76
|
-
canvas.
|
|
80
|
+
await canvas.findByRole("button", {
|
|
81
|
+
name: "Copy failed",
|
|
82
|
+
}),
|
|
77
83
|
).toBeVisible()
|
|
78
84
|
await expect(onCopy).toHaveBeenCalledWith(false, "335590")
|
|
79
85
|
})
|
|
@@ -103,26 +109,30 @@ test("tells the caller what happened, both ways", async () => {
|
|
|
103
109
|
test("announces the outcome in a status region", async () => {
|
|
104
110
|
const { canvas } = await mountStory(Confirmed)
|
|
105
111
|
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
await waitFor(async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
canvas.getByRole("status"),
|
|
115
|
+
).toHaveTextContent("Copied to the clipboard.")
|
|
116
|
+
})
|
|
109
117
|
})
|
|
110
118
|
|
|
111
119
|
test("the failure says what to do instead", async () => {
|
|
112
120
|
const { canvas } = await mountStory(Refused)
|
|
113
121
|
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
await waitFor(async () => {
|
|
123
|
+
await expect(
|
|
124
|
+
canvas.getByRole("status"),
|
|
125
|
+
).toHaveTextContent(
|
|
126
|
+
"The clipboard refused. Select the value and press Control C.",
|
|
127
|
+
)
|
|
128
|
+
})
|
|
119
129
|
})
|
|
120
130
|
|
|
121
131
|
/** The confirmation goes back on its own. */
|
|
122
132
|
test("returns to the resting label", async () => {
|
|
123
133
|
const { canvas } = await mountStory(
|
|
124
134
|
composeStory(
|
|
125
|
-
{ args: { confirmDuration:
|
|
135
|
+
{ args: { confirmDuration: 500, copy: () => true } },
|
|
126
136
|
meta,
|
|
127
137
|
),
|
|
128
138
|
)
|
|
@@ -130,8 +140,11 @@ test("returns to the resting label", async () => {
|
|
|
130
140
|
await userEvent.click(
|
|
131
141
|
canvas.getByRole("button", { name: "Copy" }),
|
|
132
142
|
)
|
|
143
|
+
// 500ms, not 20: on a loaded runner a 20ms confirmation can come
|
|
144
|
+
// and go before the test looks, which fails the read below for the
|
|
145
|
+
// opposite reason to the race above.
|
|
133
146
|
await expect(
|
|
134
|
-
canvas.
|
|
147
|
+
await canvas.findByRole("button", { name: "Copied" }),
|
|
135
148
|
).toBeVisible()
|
|
136
149
|
|
|
137
150
|
await waitFor(async () => {
|
|
@@ -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} />
|