@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.
Files changed (45) hide show
  1. package/dist/ScheduleBoard/ScheduleBoard.d.ts +33 -0
  2. package/dist/ScheduleBoard/ScheduleBoard.d.ts.map +1 -0
  3. package/dist/ScheduleBoard/ScheduleBoard.js +30 -0
  4. package/dist/ScheduleBoard/ScheduleBoard.js.map +1 -0
  5. package/dist/ScheduleBoard/ScheduleDayCards.d.ts +9 -0
  6. package/dist/ScheduleBoard/ScheduleDayCards.d.ts.map +1 -0
  7. package/dist/ScheduleBoard/ScheduleDayCards.js +8 -0
  8. package/dist/ScheduleBoard/ScheduleDayCards.js.map +1 -0
  9. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts +12 -0
  10. package/dist/ScheduleBoard/ScheduleHourGrid.d.ts.map +1 -0
  11. package/dist/ScheduleBoard/ScheduleHourGrid.js +40 -0
  12. package/dist/ScheduleBoard/ScheduleHourGrid.js.map +1 -0
  13. package/dist/ScheduleBoard/ScheduleItem.d.ts +20 -0
  14. package/dist/ScheduleBoard/ScheduleItem.d.ts.map +1 -0
  15. package/dist/ScheduleBoard/ScheduleItem.js +18 -0
  16. package/dist/ScheduleBoard/ScheduleItem.js.map +1 -0
  17. package/dist/ScheduleBoard/scheduleGeometry.d.ts +12 -0
  18. package/dist/ScheduleBoard/scheduleGeometry.d.ts.map +1 -0
  19. package/dist/ScheduleBoard/scheduleGeometry.js +35 -0
  20. package/dist/ScheduleBoard/scheduleGeometry.js.map +1 -0
  21. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts +12 -0
  22. package/dist/WeekdayIndicator/WeekdayIndicator.d.ts.map +1 -0
  23. package/dist/WeekdayIndicator/WeekdayIndicator.js +18 -0
  24. package/dist/WeekdayIndicator/WeekdayIndicator.js.map +1 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/ScheduleBoard/ScheduleBoard.mdx +29 -0
  31. package/src/ScheduleBoard/ScheduleBoard.stories.tsx +187 -0
  32. package/src/ScheduleBoard/ScheduleBoard.test.tsx +98 -0
  33. package/src/ScheduleBoard/ScheduleBoard.tsx +215 -0
  34. package/src/ScheduleBoard/ScheduleDayCards.tsx +44 -0
  35. package/src/ScheduleBoard/ScheduleHourGrid.tsx +146 -0
  36. package/src/ScheduleBoard/ScheduleItem.tsx +88 -0
  37. package/src/ScheduleBoard/scheduleGeometry.test.ts +23 -0
  38. package/src/ScheduleBoard/scheduleGeometry.ts +56 -0
  39. package/src/WeekdayIndicator/WeekdayIndicator.mdx +29 -0
  40. package/src/WeekdayIndicator/WeekdayIndicator.stories.tsx +71 -0
  41. package/src/WeekdayIndicator/WeekdayIndicator.test.tsx +34 -0
  42. package/src/WeekdayIndicator/WeekdayIndicator.tsx +57 -0
  43. package/src/index.ts +10 -0
  44. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +4 -0
  45. package/src/sourceRules.test.ts +2 -1
@@ -0,0 +1,88 @@
1
+ import type { CategoricalIndex } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+
4
+ import {
5
+ CATEGORICAL_APPEARANCE_CLASS,
6
+ CATEGORICAL_HOVER_CLASS,
7
+ } from "../categoricalStyles.ts"
8
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
9
+ import { toClassName } from "../toClassName.ts"
10
+
11
+ export type ScheduleEntry = {
12
+ key: string
13
+ dayKey: string
14
+ title: string
15
+ timeLabel: string
16
+ sourceLabel: string
17
+ sourceMarker?: string
18
+ categorical?: CategoricalIndex
19
+ /** Minutes since the start of this displayed day. Null is the untimed row. */
20
+ startMinute: number | null
21
+ endMinute: number | null
22
+ }
23
+
24
+ export const ScheduleItem = ({
25
+ item,
26
+ isClipped = false,
27
+ onSelect,
28
+ }: {
29
+ item: ScheduleEntry
30
+ isClipped?: boolean
31
+ onSelect?: (key: string) => void
32
+ }): ReactNode => {
33
+ const categorical = item.categorical ?? 1
34
+ const label = `${item.timeLabel}, ${item.title}, ${item.sourceLabel}`
35
+ const content = (
36
+ <>
37
+ <span className="flex items-baseline justify-between gap-1">
38
+ <strong className="min-w-0 wrap-anywhere text-sm leading-tight tabular-nums">
39
+ {item.timeLabel}
40
+ </strong>
41
+ {item.sourceMarker ? (
42
+ <span aria-hidden="true" className="text-xs">
43
+ {item.sourceMarker}
44
+ </span>
45
+ ) : null}
46
+ </span>
47
+ <span
48
+ className={
49
+ isClipped
50
+ ? "line-clamp-2 text-sm leading-tight"
51
+ : "wrap-anywhere text-sm leading-tight"
52
+ }
53
+ >
54
+ {item.title}
55
+ </span>
56
+ </>
57
+ )
58
+ const className = toClassName(
59
+ "flex w-full min-w-0 flex-col gap-1 overflow-hidden rounded-md border p-2 text-start",
60
+ isClipped ? "h-full" : undefined,
61
+ CATEGORICAL_APPEARANCE_CLASS[categorical].soft,
62
+ onSelect
63
+ ? CATEGORICAL_HOVER_CLASS[categorical].soft
64
+ : undefined,
65
+ FOCUS_RING_CLASS,
66
+ )
67
+ return onSelect ? (
68
+ <button
69
+ aria-label={label}
70
+ className={className}
71
+ onClick={() => {
72
+ onSelect(item.key)
73
+ }}
74
+ title={label}
75
+ type="button"
76
+ >
77
+ {content}
78
+ </button>
79
+ ) : (
80
+ <article
81
+ aria-label={label}
82
+ className={className}
83
+ title={label}
84
+ >
85
+ {content}
86
+ </article>
87
+ )
88
+ }
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "vitest"
2
+ import { placeScheduleIntervals } from "./scheduleGeometry.ts"
3
+
4
+ test("a chained overlap cluster shares its maximum width, then resets", () => {
5
+ const placed = placeScheduleIntervals([
6
+ { key: "a", startMinute: 540, endMinute: 660 },
7
+ { key: "b", startMinute: 600, endMinute: 720 },
8
+ { key: "c", startMinute: 660, endMinute: 780 },
9
+ { key: "d", startMinute: 780, endMinute: 840 },
10
+ ])
11
+ expect(
12
+ placed.map(({ key, column, columnCount }) => [
13
+ key,
14
+ column,
15
+ columnCount,
16
+ ]),
17
+ ).toEqual([
18
+ ["a", 0, 2],
19
+ ["b", 1, 2],
20
+ ["c", 0, 2],
21
+ ["d", 0, 1],
22
+ ])
23
+ })
@@ -0,0 +1,56 @@
1
+ export type ScheduleInterval = {
2
+ key: string
3
+ startMinute: number
4
+ endMinute: number
5
+ }
6
+
7
+ export type PlacedScheduleInterval = ScheduleInterval & {
8
+ column: number
9
+ columnCount: number
10
+ }
11
+
12
+ /** Pack each connected overlap cluster independently; adjacent intervals share first column. */
13
+ export const placeScheduleIntervals = (
14
+ items: readonly ScheduleInterval[],
15
+ ): PlacedScheduleInterval[] => {
16
+ const result: PlacedScheduleInterval[] = []
17
+ let cluster: PlacedScheduleInterval[] = []
18
+ let ends: number[] = []
19
+ let clusterEnd = -1
20
+ const flush = () => {
21
+ result.push(
22
+ ...cluster.map((item) => ({
23
+ ...item,
24
+ columnCount: ends.length,
25
+ })),
26
+ )
27
+ cluster = []
28
+ ends = []
29
+ }
30
+ for (const item of [...items].sort(
31
+ (first, second) =>
32
+ first.startMinute - second.startMinute ||
33
+ second.endMinute - first.endMinute ||
34
+ first.key.localeCompare(second.key),
35
+ )) {
36
+ if (item.startMinute >= clusterEnd) flush()
37
+ const free = ends.findIndex(
38
+ (end) => end <= item.startMinute,
39
+ )
40
+ const column = free < 0 ? ends.length : free
41
+ const endMinute = Math.max(
42
+ item.startMinute + 1,
43
+ item.endMinute,
44
+ )
45
+ ends[column] = endMinute
46
+ clusterEnd = Math.max(...ends)
47
+ cluster.push({
48
+ ...item,
49
+ endMinute,
50
+ column,
51
+ columnCount: 1,
52
+ })
53
+ }
54
+ flush()
55
+ return result
56
+ }
@@ -0,0 +1,29 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as Stories from "./WeekdayIndicator.stories";
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # WeekdayIndicator
7
+
8
+ Seven labeled circles in Monday-first order. Pass zero-based weekday offsets (Monday 0, Sunday 6). The accessible name spells out selected days. The app decides whether the pattern is a recurrence rule or only the weekdays observed in the displayed period, and supplies the label accordingly. This is a display, not a weekday picker.
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,71 @@
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 { ContainerBoard } from "../board.storyHelpers.tsx"
6
+ import { WeekdayIndicator } from "./WeekdayIndicator.tsx"
7
+
8
+ const meta = {
9
+ title: "Components/Data/WeekdayIndicator",
10
+ component: WeekdayIndicator,
11
+ args: { days: [0, 1, 2, 3], label: "Repeats" },
12
+ argTypes: {
13
+ categorical: {
14
+ control: "select",
15
+ options: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
16
+ },
17
+ },
18
+ } satisfies Meta<typeof WeekdayIndicator>
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+ export const Playground: Story = {
22
+ parameters: playgroundParameters,
23
+ }
24
+ export const AllVariants: Story = {
25
+ render: (args) => (
26
+ <div className="flex flex-col gap-4">
27
+ {([1, 3, 5, 7, 9] as const).map((categorical) => (
28
+ <WeekdayIndicator
29
+ {...args}
30
+ categorical={categorical}
31
+ key={categorical}
32
+ />
33
+ ))}
34
+ </div>
35
+ ),
36
+ }
37
+ export const AllStates: Story = {
38
+ render: () => (
39
+ <div className="flex flex-col gap-4">
40
+ <WeekdayIndicator days={[]} />
41
+ <WeekdayIndicator days={[0, 1, 2, 3, 4, 5, 6]} />
42
+ <WeekdayIndicator days={[5, 6]} />
43
+ </div>
44
+ ),
45
+ }
46
+ export const Responsive: Story = {
47
+ render: (args) => (
48
+ <ContainerBoard>
49
+ <WeekdayIndicator {...args} />
50
+ </ContainerBoard>
51
+ ),
52
+ }
53
+ export const Interactive: Story = {
54
+ render: () => {
55
+ const [isWeekend, setIsWeekend] = useState(false)
56
+ return (
57
+ <div className="flex flex-col items-start gap-3">
58
+ <Button
59
+ onClick={() => {
60
+ setIsWeekend(!isWeekend)
61
+ }}
62
+ >
63
+ Switch pattern
64
+ </Button>
65
+ <WeekdayIndicator
66
+ days={isWeekend ? [5, 6] : [0, 1, 2, 3, 4]}
67
+ />
68
+ </div>
69
+ )
70
+ },
71
+ }
@@ -0,0 +1,34 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } 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 "./WeekdayIndicator.stories.tsx"
9
+
10
+ const { Playground, Interactive } = composeStories(stories)
11
+ test("the pattern reads Monday first and exposes the filled days in words", async () => {
12
+ const { canvas, canvasElement } =
13
+ await mountStory(Playground)
14
+ const pattern = expectAgentDrivable(canvas, {
15
+ role: "img",
16
+ name: "Repeats: Monday, Tuesday, Wednesday, Thursday",
17
+ })
18
+ await expect(pattern.textContent).toBe("MTWTFSS")
19
+ await expectNoAxeViolations(canvasElement)
20
+ })
21
+ test("a changed pattern updates its accessible description", async () => {
22
+ const { canvas } = await mountStory(Interactive)
23
+ const button = expectAgentDrivable(canvas, {
24
+ role: "button",
25
+ name: "Switch pattern",
26
+ })
27
+ button.focus()
28
+ await userEvent.keyboard("{Enter}")
29
+ await expect(
30
+ canvas.getByRole("img", {
31
+ name: "Repeats: Saturday, Sunday",
32
+ }),
33
+ ).toBeInTheDocument()
34
+ })
@@ -0,0 +1,57 @@
1
+ import type { CategoricalIndex } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { CATEGORICAL_APPEARANCE_CLASS } from "../categoricalStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ export type WeekdayIndicatorProps = {
8
+ /** Monday is 0, Sunday is 6. These are observed weekdays, not an RRULE. */
9
+ days: readonly number[]
10
+ categorical?: CategoricalIndex
11
+ className?: string
12
+ label?: string
13
+ }
14
+
15
+ const DAYS = [
16
+ "Monday",
17
+ "Tuesday",
18
+ "Wednesday",
19
+ "Thursday",
20
+ "Friday",
21
+ "Saturday",
22
+ "Sunday",
23
+ ] as const
24
+
25
+ /** A named, noninteractive seven-day pattern, with letters as well as color. */
26
+ export const WeekdayIndicator = ({
27
+ days,
28
+ categorical = 1,
29
+ className,
30
+ label = "Repeats",
31
+ }: WeekdayIndicatorProps): ReactNode => (
32
+ <span
33
+ aria-label={`${label}: ${DAYS.filter((_day, index) => days.includes(index)).join(", ") || "no weekdays"}`}
34
+ className={toClassName(
35
+ "inline-flex flex-wrap gap-1",
36
+ className,
37
+ )}
38
+ role="img"
39
+ title={`${label}: ${DAYS.filter((_day, index) => days.includes(index)).join(", ") || "no weekdays"}`}
40
+ >
41
+ {DAYS.map((day, index) => (
42
+ <span
43
+ aria-hidden="true"
44
+ className={toClassName(
45
+ "flex size-6 items-center justify-center rounded-full border text-xs font-medium",
46
+ days.includes(index)
47
+ ? CATEGORICAL_APPEARANCE_CLASS[categorical]
48
+ .solid
49
+ : "border-border-subtle text-content-muted",
50
+ )}
51
+ key={day}
52
+ >
53
+ {day.slice(0, 1)}
54
+ </span>
55
+ ))}
56
+ </span>
57
+ )
package/src/index.ts CHANGED
@@ -417,6 +417,14 @@ export type {
417
417
  export { getIsRoutedHref } from "./RouterLink/routerLink.ts"
418
418
  export type { UnstyledLinkProps } from "./RouterLink/UnstyledLink.tsx"
419
419
  export { UnstyledLink } from "./RouterLink/UnstyledLink.tsx"
420
+ export type {
421
+ ScheduleBand,
422
+ ScheduleBoardProps,
423
+ ScheduleDay,
424
+ ScheduleLayout,
425
+ } from "./ScheduleBoard/ScheduleBoard.tsx"
426
+ export { ScheduleBoard } from "./ScheduleBoard/ScheduleBoard.tsx"
427
+ export type { ScheduleEntry } from "./ScheduleBoard/ScheduleItem.tsx"
420
428
  export type { SearchInputProps } from "./SearchInput/SearchInput.tsx"
421
429
  export { SearchInput } from "./SearchInput/SearchInput.tsx"
422
430
  export type {
@@ -542,3 +550,5 @@ export type { VirtualizedGridProps } from "./VirtualizedGrid/VirtualizedGrid.tsx
542
550
  export { VirtualizedGrid } from "./VirtualizedGrid/VirtualizedGrid.tsx"
543
551
  export type { VisuallyHiddenProps } from "./VisuallyHidden/VisuallyHidden.tsx"
544
552
  export { VisuallyHidden } from "./VisuallyHidden/VisuallyHidden.tsx"
553
+ export type { WeekdayIndicatorProps } from "./WeekdayIndicator/WeekdayIndicator.tsx"
554
+ export { WeekdayIndicator } from "./WeekdayIndicator/WeekdayIndicator.tsx"
@@ -157,6 +157,10 @@ test("it renders what the editor renders", async () => {
157
157
  test("a link is a real anchor in the tab order", async () => {
158
158
  const { canvas } = await mountStory(Interactive)
159
159
 
160
+ // Markdown decorations arrive after the editor's asynchronous parse.
161
+ // Wait for the link before checking its accessibility and keyboard behavior.
162
+ await canvas.findByRole("link", { name: "the runbook" })
163
+
160
164
  const link = expectAgentDrivable(canvas, {
161
165
  name: "the runbook",
162
166
  role: "link",
@@ -253,6 +253,7 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
253
253
  "PortraitTiles",
254
254
  // Metrics query the space remaining beside the media, not the entire card.
255
255
  "ProgressCard",
256
+ "ScheduleBoard",
256
257
  // `Stepper`'s container is the one that decides whether a
257
258
  // `horizontal` sequence is actually horizontal. Below `--cq-md`
258
259
  // it falls back to the vertical column, because four steps side
@@ -618,7 +619,7 @@ test("the barrel is the only place components are re-exported", async () => {
618
619
  // Breadcrumbs composes TextLink for the path back up a hierarchy.
619
620
  // ActionMenu composes IconButton and Menu for the overflow control.
620
621
  // ValuePairs owns compact label-over-value summaries.
621
- expect(componentNames.length).toBe(70)
622
+ expect(componentNames.length).toBe(72)
622
623
 
623
624
  for (const name of componentNames) {
624
625
  expect(barrel).toContain(`export { ${name} }`)