@charcuterie/ui 4.13.1 → 4.15.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/README.md +2 -0
- package/dist/Chart/Chart.d.ts +10 -0
- package/dist/Chart/Chart.d.ts.map +1 -0
- package/dist/Chart/Chart.js +42 -0
- package/dist/Chart/Chart.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleBoard.d.ts +33 -0
- package/dist/ScheduleBoard/ScheduleBoard.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleBoard.js +30 -0
- package/dist/ScheduleBoard/ScheduleBoard.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleDayCards.d.ts +9 -0
- package/dist/ScheduleBoard/ScheduleDayCards.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleDayCards.js +8 -0
- package/dist/ScheduleBoard/ScheduleDayCards.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.d.ts +12 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.js +40 -0
- package/dist/ScheduleBoard/ScheduleHourGrid.js.map +1 -0
- package/dist/ScheduleBoard/ScheduleItem.d.ts +20 -0
- package/dist/ScheduleBoard/ScheduleItem.d.ts.map +1 -0
- package/dist/ScheduleBoard/ScheduleItem.js +18 -0
- package/dist/ScheduleBoard/ScheduleItem.js.map +1 -0
- package/dist/ScheduleBoard/scheduleGeometry.d.ts +12 -0
- package/dist/ScheduleBoard/scheduleGeometry.d.ts.map +1 -0
- package/dist/ScheduleBoard/scheduleGeometry.js +35 -0
- package/dist/ScheduleBoard/scheduleGeometry.js.map +1 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.d.ts +12 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.d.ts.map +1 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.js +18 -0
- package/dist/WeekdayIndicator/WeekdayIndicator.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Chart/Chart.mdx +16 -0
- package/src/Chart/Chart.stories.tsx +92 -0
- package/src/Chart/Chart.test.tsx +37 -0
- package/src/Chart/Chart.tsx +129 -0
- package/src/ScheduleBoard/ScheduleBoard.mdx +29 -0
- package/src/ScheduleBoard/ScheduleBoard.stories.tsx +187 -0
- package/src/ScheduleBoard/ScheduleBoard.test.tsx +98 -0
- package/src/ScheduleBoard/ScheduleBoard.tsx +215 -0
- package/src/ScheduleBoard/ScheduleDayCards.tsx +44 -0
- package/src/ScheduleBoard/ScheduleHourGrid.tsx +146 -0
- package/src/ScheduleBoard/ScheduleItem.tsx +88 -0
- package/src/ScheduleBoard/scheduleGeometry.test.ts +23 -0
- package/src/ScheduleBoard/scheduleGeometry.ts +56 -0
- package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +6 -0
- package/src/WeekdayIndicator/WeekdayIndicator.mdx +29 -0
- package/src/WeekdayIndicator/WeekdayIndicator.stories.tsx +71 -0
- package/src/WeekdayIndicator/WeekdayIndicator.test.tsx +34 -0
- package/src/WeekdayIndicator/WeekdayIndicator.tsx +57 -0
- package/src/index.ts +12 -0
- package/src/sourceRules.test.ts +4 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
import {
|
|
6
|
+
ContainerBoard,
|
|
7
|
+
StoryCell,
|
|
8
|
+
StoryGrid,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import { Chart } from "./Chart.tsx"
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Components/Data/Chart",
|
|
14
|
+
component: Chart,
|
|
15
|
+
args: {
|
|
16
|
+
title: "Daily progress",
|
|
17
|
+
labels: ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
|
18
|
+
series: [
|
|
19
|
+
{
|
|
20
|
+
id: "net",
|
|
21
|
+
label: "Net points",
|
|
22
|
+
values: [300, 520, -50, null, 600],
|
|
23
|
+
color: "var(--color-intent-accent-solid)",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
} satisfies Meta<typeof Chart>
|
|
28
|
+
export default meta
|
|
29
|
+
type Story = StoryObj<typeof meta>
|
|
30
|
+
export const Playground: Story = {
|
|
31
|
+
parameters: playgroundParameters,
|
|
32
|
+
}
|
|
33
|
+
export const AllVariants: Story = {
|
|
34
|
+
render: (props) => (
|
|
35
|
+
<StoryGrid columns={2}>
|
|
36
|
+
{(["bar", "line"] as const).map((kind) => (
|
|
37
|
+
<StoryCell align="stretch" key={kind} label={kind}>
|
|
38
|
+
<Chart {...props} kind={kind} />
|
|
39
|
+
</StoryCell>
|
|
40
|
+
))}
|
|
41
|
+
</StoryGrid>
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
export const AllStates: Story = {
|
|
45
|
+
render: (props) => (
|
|
46
|
+
<StoryGrid columns={2}>
|
|
47
|
+
{[
|
|
48
|
+
[0, 0],
|
|
49
|
+
[null, null],
|
|
50
|
+
[50, -50],
|
|
51
|
+
[20, 20],
|
|
52
|
+
].map((values, index) => (
|
|
53
|
+
<StoryCell
|
|
54
|
+
align="stretch"
|
|
55
|
+
key={JSON.stringify(values)}
|
|
56
|
+
label={`State ${index + 1}`}
|
|
57
|
+
>
|
|
58
|
+
<Chart
|
|
59
|
+
{...props}
|
|
60
|
+
labels={["Mon", "Tue"]}
|
|
61
|
+
series={[
|
|
62
|
+
{ id: "a", label: "Net points", values },
|
|
63
|
+
]}
|
|
64
|
+
/>
|
|
65
|
+
</StoryCell>
|
|
66
|
+
))}
|
|
67
|
+
</StoryGrid>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
export const Responsive: Story = {
|
|
71
|
+
render: (props) => (
|
|
72
|
+
<ContainerBoard>
|
|
73
|
+
{() => <Chart {...props} />}
|
|
74
|
+
</ContainerBoard>
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
export const Interactive: Story = {
|
|
78
|
+
render: (props) => {
|
|
79
|
+
const [isTableVisible, setIsTableVisible] =
|
|
80
|
+
useState(false)
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<Button
|
|
84
|
+
onClick={() => setIsTableVisible(!isTableVisible)}
|
|
85
|
+
>
|
|
86
|
+
{isTableVisible ? "Hide values" : "Show values"}
|
|
87
|
+
</Button>
|
|
88
|
+
<Chart {...props} isTableVisible={isTableVisible} />
|
|
89
|
+
</>
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, within } 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 "./Chart.stories.tsx"
|
|
7
|
+
|
|
8
|
+
const { Playground, Interactive } = composeStories(stories)
|
|
9
|
+
test("the plot has an accessible name and a named legend", async () => {
|
|
10
|
+
const { canvas } = await mountStory(Playground)
|
|
11
|
+
expectAgentDrivable(canvas, {
|
|
12
|
+
role: "img",
|
|
13
|
+
name: "Daily progress",
|
|
14
|
+
})
|
|
15
|
+
await expect(
|
|
16
|
+
canvas.getByRole("list", {
|
|
17
|
+
name: "Daily progress legend",
|
|
18
|
+
}),
|
|
19
|
+
).toBeVisible()
|
|
20
|
+
})
|
|
21
|
+
test("the keyboard exposes all values, including missing data, in a real table", async () => {
|
|
22
|
+
const { canvas } = await mountStory(Interactive)
|
|
23
|
+
const toggle = expectAgentDrivable(canvas, {
|
|
24
|
+
role: "button",
|
|
25
|
+
name: "Show values",
|
|
26
|
+
})
|
|
27
|
+
toggle.focus()
|
|
28
|
+
await userEvent.keyboard("{Enter}")
|
|
29
|
+
const table = canvas.getByRole("table", {
|
|
30
|
+
name: "Daily progress values",
|
|
31
|
+
})
|
|
32
|
+
await expect(table).toBeVisible()
|
|
33
|
+
await expect(
|
|
34
|
+
within(table).getByText("Unavailable"),
|
|
35
|
+
).toBeVisible()
|
|
36
|
+
await expect(within(table).getByText("-50")).toBeVisible()
|
|
37
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// biome-ignore-all lint/security/noDangerouslySetInnerHtml: The portable renderer escapes text and restricts color references; security tests cover this boundary.
|
|
2
|
+
import {
|
|
3
|
+
type ChartOptions,
|
|
4
|
+
renderChartSvg,
|
|
5
|
+
} from "@charcuterie/logic/core"
|
|
6
|
+
import {
|
|
7
|
+
type ReactNode,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
|
|
14
|
+
export type ChartProps = ChartOptions & {
|
|
15
|
+
className?: string
|
|
16
|
+
/** A text table exposes every value without relying on color, hover, or SVG. */
|
|
17
|
+
isTableVisible?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The same escaped SVG renderer is available without React for kiosk/image consumers. */
|
|
21
|
+
export const Chart = ({
|
|
22
|
+
className,
|
|
23
|
+
isTableVisible = false,
|
|
24
|
+
...options
|
|
25
|
+
}: ChartProps): ReactNode => {
|
|
26
|
+
const container = useRef<HTMLDivElement>(null)
|
|
27
|
+
const [measuredWidth, setMeasuredWidth] = useState(
|
|
28
|
+
options.width ?? 640,
|
|
29
|
+
)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const element = container.current
|
|
32
|
+
if (!element || options.width !== undefined) return
|
|
33
|
+
const frame = { id: 0 }
|
|
34
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
35
|
+
cancelAnimationFrame(frame.id)
|
|
36
|
+
frame.id = requestAnimationFrame(() => {
|
|
37
|
+
if (entry.contentRect.width > 0) {
|
|
38
|
+
setMeasuredWidth(entry.contentRect.width)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
observer.observe(element)
|
|
43
|
+
return () => {
|
|
44
|
+
observer.disconnect()
|
|
45
|
+
cancelAnimationFrame(frame.id)
|
|
46
|
+
}
|
|
47
|
+
}, [options.width])
|
|
48
|
+
return (
|
|
49
|
+
<figure
|
|
50
|
+
className={toClassName(
|
|
51
|
+
"min-w-0 text-content-primary",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<figcaption className="mb-2 text-sm font-semibold">
|
|
56
|
+
{options.title}
|
|
57
|
+
</figcaption>
|
|
58
|
+
<div
|
|
59
|
+
ref={container}
|
|
60
|
+
style={{ height: options.height ?? 240 }}
|
|
61
|
+
dangerouslySetInnerHTML={{
|
|
62
|
+
__html: renderChartSvg({
|
|
63
|
+
...options,
|
|
64
|
+
width: options.width ?? measuredWidth,
|
|
65
|
+
}),
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
<ul
|
|
69
|
+
aria-label={`${options.title} legend`}
|
|
70
|
+
className="mt-2 flex flex-wrap gap-3 text-xs"
|
|
71
|
+
>
|
|
72
|
+
{options.series.map((entry, index) => (
|
|
73
|
+
<li
|
|
74
|
+
className="min-w-0 wrap-anywhere"
|
|
75
|
+
key={entry.id}
|
|
76
|
+
>
|
|
77
|
+
<span
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
style={{
|
|
80
|
+
color: entry.color ?? "currentColor",
|
|
81
|
+
borderTop: `3px ${index % 3 === 0 ? "solid" : index % 3 === 1 ? "dashed" : "dotted"} currentColor`,
|
|
82
|
+
display: "inline-block",
|
|
83
|
+
width: "1rem",
|
|
84
|
+
marginRight: "0.4rem",
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
{entry.label}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
{options.description && (
|
|
92
|
+
<p className="mt-2 text-xs text-content-muted">
|
|
93
|
+
{options.description}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
{isTableVisible && (
|
|
97
|
+
<div className="mt-3 overflow-auto">
|
|
98
|
+
<table className="w-full text-start text-xs">
|
|
99
|
+
<caption className="sr-only">
|
|
100
|
+
{options.title} values
|
|
101
|
+
</caption>
|
|
102
|
+
<thead>
|
|
103
|
+
<tr>
|
|
104
|
+
<th scope="col">Period</th>
|
|
105
|
+
{options.series.map((entry) => (
|
|
106
|
+
<th scope="col" key={entry.id}>
|
|
107
|
+
{entry.label}
|
|
108
|
+
</th>
|
|
109
|
+
))}
|
|
110
|
+
</tr>
|
|
111
|
+
</thead>
|
|
112
|
+
<tbody>
|
|
113
|
+
{options.labels.map((label, index) => (
|
|
114
|
+
<tr key={label}>
|
|
115
|
+
<th scope="row">{label}</th>
|
|
116
|
+
{options.series.map((entry) => (
|
|
117
|
+
<td key={entry.id}>
|
|
118
|
+
{entry.values[index] ?? "Unavailable"}
|
|
119
|
+
</td>
|
|
120
|
+
))}
|
|
121
|
+
</tr>
|
|
122
|
+
))}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</figure>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
import * as Stories from "./ScheduleBoard.stories";
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# ScheduleBoard
|
|
7
|
+
|
|
8
|
+
A schedule with aligned time bands, an exact hour axis, and day cards. The app supplies day labels, minute offsets, sources, and formatted times. Bands group by start time; their height never claims duration. The hour layout shows duration and packs overlapping entries side by side. Short items keep a minimum visible height. Full labels remain available through the accessible name and the selection callback. Multi-day entries must be split into day segments by the app. A measured minimum column width switches to day cards, without horizontal scrolling.
|
|
9
|
+
|
|
10
|
+
<Canvas of={Stories.Playground} />
|
|
11
|
+
|
|
12
|
+
## Props
|
|
13
|
+
|
|
14
|
+
<Controls />
|
|
15
|
+
|
|
16
|
+
## Layouts and states
|
|
17
|
+
|
|
18
|
+
<Canvas of={Stories.AllVariants} />
|
|
19
|
+
<Canvas of={Stories.AllStates} />
|
|
20
|
+
|
|
21
|
+
## Narrow containers
|
|
22
|
+
|
|
23
|
+
<Canvas of={Stories.Responsive} />
|
|
24
|
+
|
|
25
|
+
## Keyboard interaction
|
|
26
|
+
|
|
27
|
+
Tab reaches each action. Enter or Space activates it. The display itself adds no tab stops.
|
|
28
|
+
|
|
29
|
+
<Canvas of={Stories.Interactive} />
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { ContainerBoard } from "../board.storyHelpers.tsx"
|
|
6
|
+
import { Dialog } from "../Dialog/Dialog.tsx"
|
|
7
|
+
import { ScheduleBoard } from "./ScheduleBoard.tsx"
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: "Components/Data/ScheduleBoard",
|
|
11
|
+
component: ScheduleBoard,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
args: {
|
|
14
|
+
label: "Studio schedule",
|
|
15
|
+
days: [
|
|
16
|
+
{
|
|
17
|
+
key: "mon",
|
|
18
|
+
label: "Mon",
|
|
19
|
+
dateLabel: "6 Apr",
|
|
20
|
+
isCurrent: true,
|
|
21
|
+
},
|
|
22
|
+
{ key: "tue", label: "Tue", dateLabel: "7 Apr" },
|
|
23
|
+
{ key: "wed", label: "Wed", dateLabel: "8 Apr" },
|
|
24
|
+
],
|
|
25
|
+
bands: [
|
|
26
|
+
{
|
|
27
|
+
key: "morning",
|
|
28
|
+
label: "Morning",
|
|
29
|
+
detail: "Before noon",
|
|
30
|
+
startMinute: 0,
|
|
31
|
+
endMinute: 720,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "afternoon",
|
|
35
|
+
label: "Afternoon",
|
|
36
|
+
detail: "Noon–4p",
|
|
37
|
+
startMinute: 720,
|
|
38
|
+
endMinute: 960,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "evening",
|
|
42
|
+
label: "Evening",
|
|
43
|
+
detail: "After 4p",
|
|
44
|
+
startMinute: 960,
|
|
45
|
+
endMinute: 1440,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
items: [
|
|
49
|
+
{
|
|
50
|
+
key: "mix",
|
|
51
|
+
dayKey: "mon",
|
|
52
|
+
title: "Mixing session",
|
|
53
|
+
timeLabel: "9a–11a",
|
|
54
|
+
sourceLabel: "Studio A",
|
|
55
|
+
sourceMarker: "A",
|
|
56
|
+
startMinute: 540,
|
|
57
|
+
endMinute: 660,
|
|
58
|
+
categorical: 1,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "review",
|
|
62
|
+
dayKey: "mon",
|
|
63
|
+
title: "Client review",
|
|
64
|
+
timeLabel: "10a–12p",
|
|
65
|
+
sourceLabel: "Studio B",
|
|
66
|
+
sourceMarker: "B",
|
|
67
|
+
startMinute: 600,
|
|
68
|
+
endMinute: 720,
|
|
69
|
+
categorical: 3,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "delivery",
|
|
73
|
+
dayKey: "tue",
|
|
74
|
+
title: "Microphone delivery",
|
|
75
|
+
timeLabel: "All day",
|
|
76
|
+
sourceLabel: "Studio A",
|
|
77
|
+
startMinute: null,
|
|
78
|
+
endMinute: null,
|
|
79
|
+
categorical: 1,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "record",
|
|
83
|
+
dayKey: "tue",
|
|
84
|
+
title:
|
|
85
|
+
"Location recording and equipment preparation",
|
|
86
|
+
timeLabel: "2p–4p",
|
|
87
|
+
sourceLabel: "Studio B",
|
|
88
|
+
startMinute: 840,
|
|
89
|
+
endMinute: 960,
|
|
90
|
+
categorical: 3,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
layout: "bands",
|
|
94
|
+
},
|
|
95
|
+
} satisfies Meta<typeof ScheduleBoard>
|
|
96
|
+
export default meta
|
|
97
|
+
type Story = StoryObj<typeof meta>
|
|
98
|
+
|
|
99
|
+
export const Playground: Story = {
|
|
100
|
+
parameters: playgroundParameters,
|
|
101
|
+
render: (args) => (
|
|
102
|
+
<div style={{ inlineSize: "56rem" }}>
|
|
103
|
+
<ScheduleBoard {...args} />
|
|
104
|
+
</div>
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
export const AllVariants: Story = {
|
|
108
|
+
render: (args) => (
|
|
109
|
+
<div className="flex flex-col gap-8">
|
|
110
|
+
{(["bands", "hours", "cards"] as const).map(
|
|
111
|
+
(layout) => (
|
|
112
|
+
<ScheduleBoard
|
|
113
|
+
{...args}
|
|
114
|
+
key={layout}
|
|
115
|
+
label={`Schedule ${layout}`}
|
|
116
|
+
layout={layout}
|
|
117
|
+
/>
|
|
118
|
+
),
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
export const AllStates: Story = {
|
|
124
|
+
render: (args) => (
|
|
125
|
+
<div className="flex flex-col gap-8">
|
|
126
|
+
<ScheduleBoard
|
|
127
|
+
{...args}
|
|
128
|
+
items={[]}
|
|
129
|
+
label="Empty schedule"
|
|
130
|
+
/>
|
|
131
|
+
<ScheduleBoard
|
|
132
|
+
{...args}
|
|
133
|
+
label="Unknown schedule"
|
|
134
|
+
days={args.days.map((day) => ({
|
|
135
|
+
...day,
|
|
136
|
+
emptyLabel: "Not read yet",
|
|
137
|
+
}))}
|
|
138
|
+
items={[]}
|
|
139
|
+
layout="cards"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
export const Responsive: Story = {
|
|
145
|
+
render: (args) => (
|
|
146
|
+
<ContainerBoard>
|
|
147
|
+
{(width) => (
|
|
148
|
+
<ScheduleBoard
|
|
149
|
+
{...args}
|
|
150
|
+
label={`Schedule at ${width}`}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</ContainerBoard>
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
export const Interactive: Story = {
|
|
157
|
+
render: (args) => {
|
|
158
|
+
const [selected, setSelected] = useState<string | null>(
|
|
159
|
+
null,
|
|
160
|
+
)
|
|
161
|
+
const item = args.items.find(
|
|
162
|
+
(entry) => entry.key === selected,
|
|
163
|
+
)
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<ScheduleBoard {...args} onSelect={setSelected} />
|
|
167
|
+
<Dialog
|
|
168
|
+
heading={item?.title ?? "Event"}
|
|
169
|
+
isVisible={selected !== null}
|
|
170
|
+
onClose={() => {
|
|
171
|
+
setSelected(null)
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{item?.timeLabel} · {item?.sourceLabel}
|
|
175
|
+
</Dialog>
|
|
176
|
+
</>
|
|
177
|
+
)
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
export const HourGrid: Story = {
|
|
181
|
+
args: { layout: "hours" },
|
|
182
|
+
render: (args) => (
|
|
183
|
+
<div style={{ inlineSize: "56rem" }}>
|
|
184
|
+
<ScheduleBoard {...args} />
|
|
185
|
+
</div>
|
|
186
|
+
),
|
|
187
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./ScheduleBoard.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { Playground, Responsive, Interactive, HourGrid } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("bands align periods while retaining exact times and complete titles", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Playground)
|
|
16
|
+
expectAgentDrivable(canvas, {
|
|
17
|
+
role: "region",
|
|
18
|
+
name: "Studio schedule",
|
|
19
|
+
})
|
|
20
|
+
const morning: HTMLElement = canvas.getByRole("group", {
|
|
21
|
+
name: "Morning",
|
|
22
|
+
})
|
|
23
|
+
await expect(
|
|
24
|
+
within(morning).getByText("9a–11a"),
|
|
25
|
+
).toBeInTheDocument()
|
|
26
|
+
await expect(
|
|
27
|
+
within(morning).getByText("Client review"),
|
|
28
|
+
).toBeInTheDocument()
|
|
29
|
+
await expect(
|
|
30
|
+
canvas.getByText(
|
|
31
|
+
"Location recording and equipment preparation",
|
|
32
|
+
),
|
|
33
|
+
).toBeInTheDocument()
|
|
34
|
+
const cards = Array.from(
|
|
35
|
+
morning.querySelectorAll("article"),
|
|
36
|
+
)
|
|
37
|
+
await expect(
|
|
38
|
+
cards.every(
|
|
39
|
+
(card) => card.scrollHeight <= card.clientHeight + 1,
|
|
40
|
+
),
|
|
41
|
+
).toBe(true)
|
|
42
|
+
await expectNoAxeViolations(canvasElement)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("the narrow layout keeps all events inside the container", async () => {
|
|
46
|
+
const { canvas, canvasElement } =
|
|
47
|
+
await mountStory(Responsive)
|
|
48
|
+
const region = expectAgentDrivable(canvas, {
|
|
49
|
+
role: "region",
|
|
50
|
+
name: "Schedule at 15rem",
|
|
51
|
+
})
|
|
52
|
+
await expect(
|
|
53
|
+
within(region).getByText(
|
|
54
|
+
"Location recording and equipment preparation",
|
|
55
|
+
),
|
|
56
|
+
).toBeInTheDocument()
|
|
57
|
+
await expect(region.scrollWidth).toBeLessThanOrEqual(
|
|
58
|
+
region.clientWidth + 1,
|
|
59
|
+
)
|
|
60
|
+
await expectNoAxeViolations(canvasElement)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("the hour axis places overlapping items beside one another", async () => {
|
|
64
|
+
const { canvas } = await mountStory(HourGrid)
|
|
65
|
+
expectAgentDrivable(canvas, {
|
|
66
|
+
role: "region",
|
|
67
|
+
name: "Studio schedule",
|
|
68
|
+
})
|
|
69
|
+
const first = canvas
|
|
70
|
+
.getByText("Mixing session")
|
|
71
|
+
.parentElement?.getBoundingClientRect()
|
|
72
|
+
const second = canvas
|
|
73
|
+
.getByText("Client review")
|
|
74
|
+
.parentElement?.getBoundingClientRect()
|
|
75
|
+
if (!first || !second)
|
|
76
|
+
throw new Error("Both events must render")
|
|
77
|
+
await expect(second.top).toBeGreaterThan(first.top)
|
|
78
|
+
await expect(second.left).toBeGreaterThanOrEqual(
|
|
79
|
+
first.right,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("keyboard selection opens the detail and returns focus on Escape", async () => {
|
|
84
|
+
const { canvas } = await mountStory(Interactive)
|
|
85
|
+
const button = expectAgentDrivable(canvas, {
|
|
86
|
+
role: "button",
|
|
87
|
+
name: "9a–11a, Mixing session, Studio A",
|
|
88
|
+
})
|
|
89
|
+
button.focus()
|
|
90
|
+
await userEvent.keyboard("{Enter}")
|
|
91
|
+
await expect(
|
|
92
|
+
within(document.body).getByRole("dialog", {
|
|
93
|
+
name: "Mixing session",
|
|
94
|
+
}),
|
|
95
|
+
).toBeInTheDocument()
|
|
96
|
+
await userEvent.keyboard("{Escape}")
|
|
97
|
+
await expect(button).toHaveFocus()
|
|
98
|
+
})
|