@charcuterie/ui 4.13.0 → 4.14.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/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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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/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 +10 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +4 -0
- package/src/sourceRules.test.ts +2 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { AdaptiveGrid } from "../AdaptiveGrid/AdaptiveGrid.tsx"
|
|
4
|
+
import { useTrackMeasurements } from "../LaneTimeline/useTrackMeasurements.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import { ScheduleDayCards } from "./ScheduleDayCards.tsx"
|
|
7
|
+
import { ScheduleHourGrid } from "./ScheduleHourGrid.tsx"
|
|
8
|
+
import {
|
|
9
|
+
type ScheduleEntry,
|
|
10
|
+
ScheduleItem,
|
|
11
|
+
} from "./ScheduleItem.tsx"
|
|
12
|
+
|
|
13
|
+
export type ScheduleDay = {
|
|
14
|
+
key: string
|
|
15
|
+
label: string
|
|
16
|
+
dateLabel: string
|
|
17
|
+
isCurrent?: boolean
|
|
18
|
+
isQuiet?: boolean
|
|
19
|
+
emptyLabel?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ScheduleBand = {
|
|
23
|
+
key: string
|
|
24
|
+
label: string
|
|
25
|
+
detail?: string
|
|
26
|
+
startMinute: number
|
|
27
|
+
endMinute: number
|
|
28
|
+
}
|
|
29
|
+
export type ScheduleLayout = "bands" | "hours" | "cards"
|
|
30
|
+
export type ScheduleBoardProps = {
|
|
31
|
+
label: string
|
|
32
|
+
days: readonly ScheduleDay[]
|
|
33
|
+
items: readonly ScheduleEntry[]
|
|
34
|
+
bands: readonly ScheduleBand[]
|
|
35
|
+
layout?: ScheduleLayout
|
|
36
|
+
className?: string
|
|
37
|
+
onSelect?: (key: string) => void
|
|
38
|
+
formatHour?: (minute: number) => string
|
|
39
|
+
/** Axis height, in pixels. A minimum preserves short appointments. */
|
|
40
|
+
hourGridBlockSize?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Aligned time bands or an exact hour axis; folds to day cards on measured width. */
|
|
44
|
+
export const ScheduleBoard = ({
|
|
45
|
+
label,
|
|
46
|
+
days,
|
|
47
|
+
items,
|
|
48
|
+
bands,
|
|
49
|
+
layout = "bands",
|
|
50
|
+
className,
|
|
51
|
+
onSelect,
|
|
52
|
+
formatHour = (minute) => `${Math.floor(minute / 60)}:00`,
|
|
53
|
+
hourGridBlockSize = 480,
|
|
54
|
+
}: ScheduleBoardProps): ReactNode => {
|
|
55
|
+
const { trackRef, inlineSize, fontSize, isMeasured } =
|
|
56
|
+
useTrackMeasurements()
|
|
57
|
+
const isFolded =
|
|
58
|
+
isMeasured &&
|
|
59
|
+
inlineSize < (days.length * 8 + 4) * fontSize
|
|
60
|
+
const template = `5.5rem ${days.map((day) => (day.isQuiet ? "minmax(0, .65fr)" : "minmax(0, 1fr)")).join(" ")}`
|
|
61
|
+
const dayHeader = (
|
|
62
|
+
<div
|
|
63
|
+
className="grid gap-2 border-b border-border-subtle pb-2"
|
|
64
|
+
style={{ gridTemplateColumns: template }}
|
|
65
|
+
>
|
|
66
|
+
<span />
|
|
67
|
+
{days.map((day) => (
|
|
68
|
+
<div
|
|
69
|
+
className={toClassName(
|
|
70
|
+
"min-w-0 wrap-anywhere",
|
|
71
|
+
day.isCurrent
|
|
72
|
+
? "text-intent-accent-content"
|
|
73
|
+
: "text-content-secondary",
|
|
74
|
+
)}
|
|
75
|
+
key={day.key}
|
|
76
|
+
>
|
|
77
|
+
<span className="block text-sm">
|
|
78
|
+
{day.label}
|
|
79
|
+
{day.isCurrent ? " · Today" : ""}
|
|
80
|
+
</span>
|
|
81
|
+
<strong className="text-2xl leading-tight">
|
|
82
|
+
{day.dateLabel}
|
|
83
|
+
</strong>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
const untimed = items.filter(
|
|
89
|
+
(item) => item.startMinute === null,
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
<section
|
|
93
|
+
aria-label={label}
|
|
94
|
+
className={toClassName(
|
|
95
|
+
"@container flex min-w-0 flex-col gap-2",
|
|
96
|
+
className,
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<div
|
|
100
|
+
aria-hidden="true"
|
|
101
|
+
className="h-0"
|
|
102
|
+
ref={trackRef}
|
|
103
|
+
/>
|
|
104
|
+
{layout === "cards" || isFolded ? (
|
|
105
|
+
<AdaptiveGrid
|
|
106
|
+
itemBlockSize={320}
|
|
107
|
+
itemCount={days.length}
|
|
108
|
+
maxColumns={7}
|
|
109
|
+
minColumnInlineSize={230}
|
|
110
|
+
>
|
|
111
|
+
{days.map((day) => (
|
|
112
|
+
<ScheduleDayCards
|
|
113
|
+
day={day}
|
|
114
|
+
items={items.filter(
|
|
115
|
+
(item) => item.dayKey === day.key,
|
|
116
|
+
)}
|
|
117
|
+
key={day.key}
|
|
118
|
+
onSelect={onSelect}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</AdaptiveGrid>
|
|
122
|
+
) : (
|
|
123
|
+
<>
|
|
124
|
+
{dayHeader}
|
|
125
|
+
{untimed.length > 0 ? (
|
|
126
|
+
<div
|
|
127
|
+
className="grid gap-2 border-b border-border-subtle pb-2"
|
|
128
|
+
style={{ gridTemplateColumns: template }}
|
|
129
|
+
>
|
|
130
|
+
<span className="text-xs text-content-muted">
|
|
131
|
+
All day / due
|
|
132
|
+
</span>
|
|
133
|
+
{days.map((day) => (
|
|
134
|
+
<div
|
|
135
|
+
className="flex min-w-0 flex-col gap-2"
|
|
136
|
+
key={day.key}
|
|
137
|
+
>
|
|
138
|
+
{untimed
|
|
139
|
+
.filter(
|
|
140
|
+
(item) => item.dayKey === day.key,
|
|
141
|
+
)
|
|
142
|
+
.map((item) => (
|
|
143
|
+
<ScheduleItem
|
|
144
|
+
item={item}
|
|
145
|
+
key={item.key}
|
|
146
|
+
onSelect={onSelect}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
) : null}
|
|
153
|
+
{layout === "hours" ? (
|
|
154
|
+
<ScheduleHourGrid
|
|
155
|
+
blockSize={hourGridBlockSize}
|
|
156
|
+
days={days}
|
|
157
|
+
formatHour={formatHour}
|
|
158
|
+
items={items}
|
|
159
|
+
onSelect={onSelect}
|
|
160
|
+
template={template}
|
|
161
|
+
/>
|
|
162
|
+
) : (
|
|
163
|
+
bands.map((band) => (
|
|
164
|
+
// biome-ignore lint/a11y/useSemanticElements: this names schedule entries, not form controls.
|
|
165
|
+
<div
|
|
166
|
+
aria-label={band.label}
|
|
167
|
+
className="grid gap-2 border-b border-border-subtle pb-2"
|
|
168
|
+
key={band.key}
|
|
169
|
+
role="group"
|
|
170
|
+
style={{ gridTemplateColumns: template }}
|
|
171
|
+
>
|
|
172
|
+
<div className="min-w-0 wrap-anywhere text-xs text-content-secondary">
|
|
173
|
+
<strong className="block">
|
|
174
|
+
{band.label}
|
|
175
|
+
</strong>
|
|
176
|
+
<span className="text-content-muted">
|
|
177
|
+
{band.detail}
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
{days.map((day) => (
|
|
181
|
+
<div
|
|
182
|
+
className="flex min-w-0 flex-col gap-2"
|
|
183
|
+
key={day.key}
|
|
184
|
+
>
|
|
185
|
+
{items
|
|
186
|
+
.filter(
|
|
187
|
+
(item) =>
|
|
188
|
+
item.dayKey === day.key &&
|
|
189
|
+
item.startMinute !== null &&
|
|
190
|
+
item.startMinute >=
|
|
191
|
+
band.startMinute &&
|
|
192
|
+
item.startMinute < band.endMinute,
|
|
193
|
+
)
|
|
194
|
+
.sort(
|
|
195
|
+
(first, second) =>
|
|
196
|
+
(first.startMinute ?? 0) -
|
|
197
|
+
(second.startMinute ?? 0),
|
|
198
|
+
)
|
|
199
|
+
.map((item) => (
|
|
200
|
+
<ScheduleItem
|
|
201
|
+
item={item}
|
|
202
|
+
key={item.key}
|
|
203
|
+
onSelect={onSelect}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
))
|
|
210
|
+
)}
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</section>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { Card } from "../Card/Card.tsx"
|
|
4
|
+
import type { ScheduleDay } from "./ScheduleBoard.tsx"
|
|
5
|
+
import {
|
|
6
|
+
type ScheduleEntry,
|
|
7
|
+
ScheduleItem,
|
|
8
|
+
} from "./ScheduleItem.tsx"
|
|
9
|
+
|
|
10
|
+
export const ScheduleDayCards = ({
|
|
11
|
+
day,
|
|
12
|
+
items,
|
|
13
|
+
onSelect,
|
|
14
|
+
}: {
|
|
15
|
+
day: ScheduleDay
|
|
16
|
+
items: readonly ScheduleEntry[]
|
|
17
|
+
onSelect?: (key: string) => void
|
|
18
|
+
}): ReactNode => (
|
|
19
|
+
<Card padding="sm">
|
|
20
|
+
<h3 className="font-semibold text-md">
|
|
21
|
+
{day.label}, {day.dateLabel}
|
|
22
|
+
{day.isCurrent ? " · Today" : ""}
|
|
23
|
+
</h3>
|
|
24
|
+
{items.length === 0 ? (
|
|
25
|
+
<p className="text-sm text-content-muted">
|
|
26
|
+
{day.emptyLabel ?? "Nothing scheduled"}
|
|
27
|
+
</p>
|
|
28
|
+
) : (
|
|
29
|
+
[...items]
|
|
30
|
+
.sort(
|
|
31
|
+
(first, second) =>
|
|
32
|
+
(first.startMinute ?? -1) -
|
|
33
|
+
(second.startMinute ?? -1),
|
|
34
|
+
)
|
|
35
|
+
.map((item) => (
|
|
36
|
+
<ScheduleItem
|
|
37
|
+
item={item}
|
|
38
|
+
key={item.key}
|
|
39
|
+
onSelect={onSelect}
|
|
40
|
+
/>
|
|
41
|
+
))
|
|
42
|
+
)}
|
|
43
|
+
</Card>
|
|
44
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { ScheduleDay } from "./ScheduleBoard.tsx"
|
|
4
|
+
import {
|
|
5
|
+
type ScheduleEntry,
|
|
6
|
+
ScheduleItem,
|
|
7
|
+
} from "./ScheduleItem.tsx"
|
|
8
|
+
import { placeScheduleIntervals } from "./scheduleGeometry.ts"
|
|
9
|
+
|
|
10
|
+
export const ScheduleHourGrid = ({
|
|
11
|
+
blockSize,
|
|
12
|
+
days,
|
|
13
|
+
formatHour,
|
|
14
|
+
items,
|
|
15
|
+
onSelect,
|
|
16
|
+
template,
|
|
17
|
+
}: {
|
|
18
|
+
blockSize: number
|
|
19
|
+
days: readonly ScheduleDay[]
|
|
20
|
+
formatHour: (minute: number) => string
|
|
21
|
+
items: readonly ScheduleEntry[]
|
|
22
|
+
onSelect?: (key: string) => void
|
|
23
|
+
template: string
|
|
24
|
+
}): ReactNode => {
|
|
25
|
+
const timed = items.filter(
|
|
26
|
+
(item) => item.startMinute !== null,
|
|
27
|
+
)
|
|
28
|
+
const start =
|
|
29
|
+
Math.floor(
|
|
30
|
+
Math.min(
|
|
31
|
+
480,
|
|
32
|
+
...timed.map((item) => item.startMinute ?? 480),
|
|
33
|
+
) / 60,
|
|
34
|
+
) * 60
|
|
35
|
+
const end = Math.min(
|
|
36
|
+
1440,
|
|
37
|
+
Math.ceil(
|
|
38
|
+
Math.max(
|
|
39
|
+
1080,
|
|
40
|
+
...timed.map(
|
|
41
|
+
(item) =>
|
|
42
|
+
item.endMinute ??
|
|
43
|
+
(item.startMinute ?? 1080) + 30,
|
|
44
|
+
),
|
|
45
|
+
) / 60,
|
|
46
|
+
) * 60,
|
|
47
|
+
)
|
|
48
|
+
const height = Math.max(360, blockSize)
|
|
49
|
+
const scale = height / Math.max(60, end - start)
|
|
50
|
+
const hours = Array.from(
|
|
51
|
+
{ length: (end - start) / 60 + 1 },
|
|
52
|
+
(_unused, index) => start + index * 60,
|
|
53
|
+
)
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className="grid gap-2 pb-6"
|
|
57
|
+
style={{ gridTemplateColumns: template }}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
className="relative text-xs text-content-muted tabular-nums"
|
|
61
|
+
style={{ blockSize: height }}
|
|
62
|
+
>
|
|
63
|
+
{hours.map((minute) => (
|
|
64
|
+
<span
|
|
65
|
+
className="absolute start-0"
|
|
66
|
+
key={minute}
|
|
67
|
+
style={{
|
|
68
|
+
insetBlockStart: (minute - start) * scale,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{formatHour(minute)}
|
|
72
|
+
</span>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
{days.map((day) => {
|
|
76
|
+
const entries = timed.filter(
|
|
77
|
+
(item) => item.dayKey === day.key,
|
|
78
|
+
)
|
|
79
|
+
// Minimum painted height participates in packing, so short adjacent items cannot hide each other.
|
|
80
|
+
const placed = placeScheduleIntervals(
|
|
81
|
+
entries.map((item) => ({
|
|
82
|
+
key: item.key,
|
|
83
|
+
startMinute: item.startMinute ?? 0,
|
|
84
|
+
endMinute: Math.max(
|
|
85
|
+
item.endMinute ??
|
|
86
|
+
(item.startMinute ?? 0) + 30,
|
|
87
|
+
(item.startMinute ?? 0) + 30 / scale,
|
|
88
|
+
),
|
|
89
|
+
})),
|
|
90
|
+
)
|
|
91
|
+
return (
|
|
92
|
+
// biome-ignore lint/a11y/useSemanticElements: this names schedule entries, not form controls.
|
|
93
|
+
<div
|
|
94
|
+
aria-label={`${day.label}, ${day.dateLabel}`}
|
|
95
|
+
className="relative min-w-0 border-s border-border-subtle"
|
|
96
|
+
key={day.key}
|
|
97
|
+
role="group"
|
|
98
|
+
style={{ blockSize: height }}
|
|
99
|
+
>
|
|
100
|
+
{hours.map((minute) => (
|
|
101
|
+
<div
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
className="absolute w-full border-t border-border-subtle"
|
|
104
|
+
key={minute}
|
|
105
|
+
style={{
|
|
106
|
+
insetBlockStart: (minute - start) * scale,
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
))}
|
|
110
|
+
{placed.map((position) => {
|
|
111
|
+
const item = entries.find(
|
|
112
|
+
(entry) => entry.key === position.key,
|
|
113
|
+
)
|
|
114
|
+
return item ? (
|
|
115
|
+
<div
|
|
116
|
+
className="absolute min-w-0 pe-1"
|
|
117
|
+
key={item.key}
|
|
118
|
+
style={{
|
|
119
|
+
insetBlockStart:
|
|
120
|
+
(position.startMinute - start) *
|
|
121
|
+
scale,
|
|
122
|
+
blockSize: Math.max(
|
|
123
|
+
30,
|
|
124
|
+
((item.endMinute ??
|
|
125
|
+
position.startMinute + 30) -
|
|
126
|
+
position.startMinute) *
|
|
127
|
+
scale,
|
|
128
|
+
),
|
|
129
|
+
insetInlineStart: `${(position.column / position.columnCount) * 100}%`,
|
|
130
|
+
inlineSize: `${100 / position.columnCount}%`,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<ScheduleItem
|
|
134
|
+
isClipped
|
|
135
|
+
item={item}
|
|
136
|
+
onSelect={onSelect}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
) : null
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|