@charcuterie/ui 0.1.0 → 0.2.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 (106) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +3 -3
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
  106. package/CHANGELOG.md +0 -13
@@ -0,0 +1,146 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { Badge } from "../Badge/Badge.tsx"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import type { AccordionItem } from "./Accordion.tsx"
9
+ import { Accordion } from "./Accordion.tsx"
10
+
11
+ const JOB_SECTIONS: AccordionItem[] = [
12
+ {
13
+ content: (
14
+ <p>Blade Runner (1982) · 9 titles · MakeMKV 1.17.8</p>
15
+ ),
16
+ key: "disc",
17
+ label: "Disc",
18
+ },
19
+ {
20
+ content: (
21
+ <p className="font-mono text-xs">
22
+ 14:02:11 AACS handshake complete, reading…
23
+ </p>
24
+ ),
25
+ key: "log",
26
+ label: "Log",
27
+ },
28
+ {
29
+ content: (
30
+ <div className="flex gap-2">
31
+ <Badge intent="warning" size="sm">
32
+ 1 retry
33
+ </Badge>
34
+
35
+ <Badge intent="info" size="sm">
36
+ AACS
37
+ </Badge>
38
+ </div>
39
+ ),
40
+ key: "flags",
41
+ label: "Flags",
42
+ },
43
+ ]
44
+
45
+ const STATE_SECTIONS: AccordionItem[] = [
46
+ ...JOB_SECTIONS,
47
+ {
48
+ content: <p>Unreachable — no verdict yet.</p>,
49
+ isDisabled: true,
50
+ key: "verdict",
51
+ label: "Verdict",
52
+ },
53
+ ]
54
+
55
+ const meta = {
56
+ title: "Components/Accordion",
57
+ component: Accordion,
58
+ parameters: { layout: "padded" },
59
+ args: {
60
+ headingLevel: 3,
61
+ isMultiple: false,
62
+ items: JOB_SECTIONS,
63
+ },
64
+ } satisfies Meta<typeof Accordion>
65
+
66
+ export default meta
67
+
68
+ type Story = StoryObj<typeof meta>
69
+
70
+ export const Default: Story = {
71
+ args: { expandedKeys: ["disc"], items: JOB_SECTIONS },
72
+ }
73
+
74
+ /**
75
+ * Exclusive is `VisibilityGroup`; `isMultiple` is
76
+ * `MultiplePicker`. Both hooks are created and one is consulted,
77
+ * because hooks cannot be conditional — two empty stores and no DOM,
78
+ * which is cheaper than reimplementing exclusivity on top of the
79
+ * multiple kind.
80
+ */
81
+ export const AllVariants: Story = {
82
+ args: { items: JOB_SECTIONS },
83
+ render: () => (
84
+ <StoryGrid columns={2}>
85
+ <StoryCell label="exclusive (default)">
86
+ <Accordion
87
+ expandedKeys={["disc"]}
88
+ items={JOB_SECTIONS}
89
+ />
90
+ </StoryCell>
91
+
92
+ <StoryCell label="multiple">
93
+ <Accordion
94
+ expandedKeys={["disc", "flags"]}
95
+ isMultiple
96
+ items={JOB_SECTIONS}
97
+ />
98
+ </StoryCell>
99
+
100
+ <StoryCell label="all collapsed">
101
+ <Accordion items={JOB_SECTIONS} />
102
+ </StoryCell>
103
+
104
+ {/*
105
+ Its own item set, with names that appear nowhere else on the
106
+ board. Four accordions sharing three labels means
107
+ `getByRole("heading", { name: "Log" })` matches four
108
+ elements, and `expectAgentDrivable` treats ambiguity as a
109
+ failure — correctly, since an agent would be picking one at
110
+ random.
111
+ */}
112
+ <StoryCell label="heading level 2">
113
+ <Accordion
114
+ expandedKeys={["steps"]}
115
+ headingLevel={2}
116
+ items={[
117
+ {
118
+ content: <p>4 of 9 titles complete.</p>,
119
+ key: "steps",
120
+ label: "Steps",
121
+ },
122
+ {
123
+ content: <p>No errors.</p>,
124
+ key: "errors",
125
+ label: "Errors",
126
+ },
127
+ ]}
128
+ />
129
+ </StoryCell>
130
+ </StoryGrid>
131
+ ),
132
+ }
133
+
134
+ /**
135
+ * A disabled section is **not a member of the group**. Registration
136
+ * is membership, so nothing can expand it — including a consumer
137
+ * passing its key in `expandedKeys`. That is the behaviour a
138
+ * `<summary>` cannot have at all, which is one of the two reasons
139
+ * this is not built on `<details>`.
140
+ */
141
+ export const AllStates: Story = {
142
+ args: {
143
+ expandedKeys: ["verdict"],
144
+ items: STATE_SECTIONS,
145
+ },
146
+ }
@@ -0,0 +1,163 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } 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 "./Accordion.stories.tsx"
9
+
10
+ const { AllStates, AllVariants, Default } =
11
+ composeStories(stories)
12
+
13
+ test("a trigger says what it controls and whether it is open", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ const disc = expectAgentDrivable(canvas, {
18
+ name: "Disc",
19
+ role: "button",
20
+ })
21
+
22
+ await expect(disc).toHaveAttribute(
23
+ "aria-expanded",
24
+ "true",
25
+ )
26
+
27
+ // The panel is queried by id rather than by role, because it is
28
+ // deliberately **not** a landmark — the APG's own caveat about
29
+ // "landmark region proliferation", which a board of four
30
+ // accordions turned into a failing `landmark-unique` immediately.
31
+ const panel = document.getElementById(
32
+ disc.getAttribute("aria-controls") ?? "",
33
+ )
34
+
35
+ await expect(panel).not.toBeNull()
36
+
37
+ // The link still reaches in both directions — the button's
38
+ // `aria-controls` at the panel, the panel's `aria-labelledby` back
39
+ // at the button. Both ids come from one `useUniqueId`, and this is
40
+ // the relationship a screen reader actually follows; the landmark
41
+ // only ever added a second route to the same node.
42
+ await expect(panel).toHaveAttribute(
43
+ "aria-labelledby",
44
+ disc.id,
45
+ )
46
+
47
+ await expect(panel).toHaveTextContent("Blade Runner")
48
+
49
+ await expectNoAxeViolations(canvasElement)
50
+ })
51
+
52
+ /**
53
+ * The first-paint hole `Tabs` found, in the other kind. Members
54
+ * register from an effect, so on the very first paint the intent
55
+ * still lives in `pendingKey` — an accordion reading only
56
+ * `visibleKey` renders every section collapsed and corrects itself a
57
+ * frame later.
58
+ */
59
+ test("the initially expanded section is expanded on the first paint", async () => {
60
+ const { canvas } = await mountStory(Default)
61
+
62
+ const expanded = canvas
63
+ .queryAllByRole("button")
64
+ .filter(
65
+ (one: HTMLElement) =>
66
+ one.getAttribute("aria-expanded") === "true",
67
+ )
68
+
69
+ await expect(expanded).toHaveLength(1)
70
+
71
+ await expect(expanded[0]).toHaveTextContent("Disc")
72
+ })
73
+
74
+ test("exclusive means opening one closes the other", async () => {
75
+ const { canvas } = await mountStory(Default)
76
+
77
+ const log = expectAgentDrivable(canvas, {
78
+ name: "Log",
79
+ role: "button",
80
+ })
81
+
82
+ await userEvent.click(log)
83
+
84
+ await waitFor(() => {
85
+ expect(log).toHaveAttribute("aria-expanded", "true")
86
+ })
87
+
88
+ await expect(
89
+ expectAgentDrivable(canvas, {
90
+ name: "Disc",
91
+ role: "button",
92
+ }),
93
+ ).toHaveAttribute("aria-expanded", "false")
94
+ })
95
+
96
+ test("multiple keeps both open", async () => {
97
+ const { canvas, canvasElement } =
98
+ await mountStory(AllVariants)
99
+
100
+ // Two sections start expanded in the `multiple` cell, which the
101
+ // exclusive kind cannot represent at all.
102
+ const expanded = canvas
103
+ .queryAllByRole("button")
104
+ .filter(
105
+ (one: HTMLElement) =>
106
+ one.getAttribute("aria-expanded") === "true",
107
+ )
108
+ .map((one: HTMLElement) => one.textContent)
109
+
110
+ await expect(expanded).toEqual([
111
+ "Disc",
112
+ "Disc",
113
+ "Flags",
114
+ "Steps",
115
+ ])
116
+
117
+ await expectNoAxeViolations(canvasElement)
118
+ })
119
+
120
+ test("the heading level is the caller's, not the component's", async () => {
121
+ const { canvas } = await mountStory(AllVariants)
122
+
123
+ const level2 = expectAgentDrivable(canvas, {
124
+ name: "Steps",
125
+ role: "heading",
126
+ })
127
+
128
+ // An accordion inside a `Card` that already has an `<h2>` needs
129
+ // `3`, and getting it wrong is a document-outline break no gate
130
+ // here can see — it depends entirely on the page.
131
+ await expect(level2.tagName).toBe("H2")
132
+ })
133
+
134
+ /**
135
+ * Registration is membership. A disabled section never registers, so
136
+ * nothing can expand it — including this story, which passes its key
137
+ * in `expandedKeys` on purpose.
138
+ */
139
+ test("a disabled section cannot be expanded, even by the caller", async () => {
140
+ const { canvas, canvasElement } =
141
+ await mountStory(AllStates)
142
+
143
+ const verdict = expectAgentDrivable(canvas, {
144
+ name: "Verdict",
145
+ role: "button",
146
+ })
147
+
148
+ await expect(verdict).toBeDisabled()
149
+
150
+ await expect(verdict).toHaveAttribute(
151
+ "aria-expanded",
152
+ "false",
153
+ )
154
+
155
+ await userEvent.click(verdict, { pointerEventsCheck: 0 })
156
+
157
+ await expect(verdict).toHaveAttribute(
158
+ "aria-expanded",
159
+ "false",
160
+ )
161
+
162
+ await expectNoAxeViolations(canvasElement)
163
+ })
@@ -0,0 +1,182 @@
1
+ import {
2
+ useMultiplePicker,
3
+ useUniqueId,
4
+ useVisibilityGroup,
5
+ } from "@charcuterie/logic"
6
+ import type { ReactNode } from "react"
7
+
8
+ import { toClassName } from "../toClassName.ts"
9
+ import { AccordionSection } from "./AccordionSection.tsx"
10
+
11
+ export type AccordionHeadingLevel = 2 | 3 | 4 | 5 | 6
12
+
13
+ export type AccordionItem = {
14
+ content: ReactNode
15
+ isDisabled?: boolean
16
+ key: string
17
+ label: ReactNode
18
+ }
19
+
20
+ export type AccordionProps = {
21
+ className?: string
22
+ /** **Initial** only. Charcuterie owns it from then on. */
23
+ expandedKeys?: string[]
24
+ /**
25
+ * The heading level the triggers sit at. An accordion inside a
26
+ * `Card` that already has an `<h2>` needs `3`, and getting this
27
+ * wrong is a document-outline break that no gate here can see —
28
+ * it depends entirely on the page.
29
+ */
30
+ headingLevel?: AccordionHeadingLevel
31
+ /**
32
+ * Several sections open at once. The default is **exclusive**,
33
+ * because that is what every one of the fleet's disclosure groups
34
+ * does today.
35
+ */
36
+ isMultiple?: boolean
37
+ items: AccordionItem[]
38
+ onChange?: (expandedKeys: string[]) => void
39
+ }
40
+
41
+ /**
42
+ * Eleven sites in the fleet, and the plan called it "nearly free
43
+ * once `Tabs` exists". It is — the exclusive mode is
44
+ * `VisibilityGroup` and nothing else — but the reason it is worth
45
+ * shipping is not the saving.
46
+ *
47
+ * ### The two-owners bug, live, in the app this milestone migrates
48
+ *
49
+ * mux-magic's `JobStepsDisclosure` is built on `<details>`, and
50
+ * `<details>` **owns `open`**. It also wants the state in a Jotai
51
+ * atom so a job can be expanded from elsewhere. Reconciling the two
52
+ * takes three separate mechanisms:
53
+ *
54
+ * ```tsx
55
+ * const detailsRef = useRef<HTMLDetailsElement>(null)
56
+ * const skipNextToggleRef = useRef(isOpen)
57
+ *
58
+ * useEffect(() => {
59
+ * if (detailsRef.current) detailsRef.current.open = isOpen
60
+ * }, [isOpen])
61
+ *
62
+ * const handleToggle = (event) => {
63
+ * if (skipNextToggleRef.current) {
64
+ * skipNextToggleRef.current = false
65
+ * return // ← swallow the toggle our own write just fired
66
+ * }
67
+ * setStepsOpen(…)
68
+ * }
69
+ * ```
70
+ *
71
+ * That is the Radix argument from `Popover` pointed at the platform:
72
+ * a ref to reach past React, an effect to push state into the DOM,
73
+ * and a guard to stop the DOM's echo coming back. Every one of the
74
+ * three exists only because two things hold one fact.
75
+ *
76
+ * So this is a `<button aria-expanded>` and a `role="region"` — the
77
+ * ARIA Authoring Practices accordion — with `VisibilityGroup`
78
+ * holding the state alone. `<details name="…">` is the platform's
79
+ * own exclusive accordion and was rejected for the same reason, plus
80
+ * one it cannot fix: **a `<summary>` cannot be disabled**, and four
81
+ * of the fleet's eleven sites have a section that is unreachable
82
+ * until a job produces it.
83
+ *
84
+ * ### Two kinds, one component, and both hooks always run
85
+ *
86
+ * `isMultiple` picks between `VisibilityGroup` and `MultiplePicker`,
87
+ * and hooks cannot be conditional — so both are created and one is
88
+ * consulted. That is two empty stores and no DOM, which is cheaper
89
+ * than the alternative: reimplementing exclusivity on top of
90
+ * `MultiplePicker` by deselecting the others, which is precisely the
91
+ * logic `VisibilityGroup` exists to own once.
92
+ */
93
+ export const Accordion = ({
94
+ className,
95
+ expandedKeys,
96
+ headingLevel = 3,
97
+ isMultiple = false,
98
+ items,
99
+ onChange,
100
+ }: AccordionProps): ReactNode => {
101
+ const baseId = useUniqueId()
102
+
103
+ const [initialKey] = expandedKeys ?? []
104
+
105
+ const group = useVisibilityGroup<string>({
106
+ onChange: (visibleKey) => {
107
+ if (!isMultiple) {
108
+ onChange?.(visibleKey === null ? [] : [visibleKey])
109
+ }
110
+ },
111
+ visibleKey: initialKey ?? null,
112
+ })
113
+
114
+ const picker = useMultiplePicker<string>({
115
+ onChange: (selectedValues) => {
116
+ if (isMultiple) {
117
+ onChange?.([...selectedValues])
118
+ }
119
+ },
120
+ selectedValues: expandedKeys,
121
+ })
122
+
123
+ /**
124
+ * Visible **or** pending, the same load-bearing fallback `Tabs`
125
+ * needs and for the same reason: members register from an effect,
126
+ * so on the first paint the intent still lives in the pending
127
+ * field. Reading only the settled one renders every section
128
+ * collapsed and corrects itself a frame later — a flash on a
129
+ * desktop and a visible jump on the kiosk Pi.
130
+ *
131
+ * **And only until something registers**, which `Tabs` never had
132
+ * to say because it seeds itself from the first *enabled* tab. A
133
+ * key that can never register — a disabled section's — stays
134
+ * pending forever, so an unqualified fallback renders it expanded
135
+ * permanently and `isDisabled` quietly stops meaning anything.
136
+ * Found by the story that passes a disabled key on purpose.
137
+ *
138
+ * Every member registers in one commit, so this really is "before
139
+ * the first effects ran" rather than a race between them.
140
+ */
141
+ const hasRegistered = isMultiple
142
+ ? picker.registeredValues.length > 0
143
+ : group.registeredKeys.length > 0
144
+
145
+ const expanded = isMultiple
146
+ ? new Set([
147
+ ...picker.selectedValues,
148
+ ...(hasRegistered ? [] : picker.pendingValues),
149
+ ])
150
+ : new Set(
151
+ [
152
+ group.visibleKey ??
153
+ (hasRegistered ? null : group.pendingKey),
154
+ ].filter((key) => key !== null),
155
+ )
156
+
157
+ return (
158
+ <div
159
+ className={toClassName(
160
+ "flex flex-col divide-y divide-border-subtle overflow-hidden rounded-md border border-border-subtle",
161
+ className,
162
+ )}
163
+ >
164
+ {items.map((item, index) => (
165
+ <AccordionSection
166
+ headingLevel={headingLevel}
167
+ id={`${baseId}-trigger-${index}`}
168
+ isExpanded={expanded.has(item.key)}
169
+ item={item}
170
+ key={item.key}
171
+ onToggle={
172
+ isMultiple ? picker.toggle : group.toggle
173
+ }
174
+ panelId={`${baseId}-panel-${index}`}
175
+ register={
176
+ isMultiple ? picker.register : group.register
177
+ }
178
+ />
179
+ ))}
180
+ </div>
181
+ )
182
+ }
@@ -0,0 +1,163 @@
1
+ import type { ReactNode } from "react"
2
+ import { useEffect } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import type {
7
+ AccordionHeadingLevel,
8
+ AccordionItem,
9
+ } from "./Accordion.tsx"
10
+
11
+ /**
12
+ * Written out rather than interpolated into a tag name. React
13
+ * accepts `` `h${level}` `` perfectly well; a reader and a grep do
14
+ * not, and "which heading levels does this component emit" should be
15
+ * answerable by looking.
16
+ */
17
+ const HEADING_TAG: Record<
18
+ AccordionHeadingLevel,
19
+ "h2" | "h3" | "h4" | "h5" | "h6"
20
+ > = {
21
+ 2: "h2",
22
+ 3: "h3",
23
+ 4: "h4",
24
+ 5: "h5",
25
+ 6: "h6",
26
+ }
27
+
28
+ export type AccordionSectionProps = {
29
+ headingLevel: AccordionHeadingLevel
30
+ id: string
31
+ isExpanded: boolean
32
+ item: AccordionItem
33
+ onToggle: (key: string) => void
34
+ panelId: string
35
+ /**
36
+ * `VisibilityGroup.register` or `MultiplePicker.register`,
37
+ * whichever kind the accordion is running. Both have this shape,
38
+ * which is the point of the kinds being separate rather than one
39
+ * configurable one.
40
+ */
41
+ register: (key: string) => () => void
42
+ }
43
+
44
+ /**
45
+ * Its own file for the same reason `TabTrigger` is: **registration
46
+ * is an effect**, and an effect cannot run in a loop.
47
+ *
48
+ * A disabled section stays out of the group entirely — it is not a
49
+ * member, so nothing can expand it, including a consumer passing its
50
+ * key in `expandedKeys`. That is registration-as-membership again,
51
+ * and it is the behaviour a `<summary>` cannot have at all.
52
+ */
53
+ export const AccordionSection = ({
54
+ headingLevel,
55
+ id,
56
+ isExpanded,
57
+ item,
58
+ onToggle,
59
+ panelId,
60
+ register,
61
+ }: AccordionSectionProps): ReactNode => {
62
+ const { isDisabled = false, key, label } = item
63
+
64
+ const Heading = HEADING_TAG[headingLevel]
65
+
66
+ useEffect(() => {
67
+ if (isDisabled) {
68
+ return
69
+ }
70
+
71
+ return register(key)
72
+ }, [isDisabled, key, register])
73
+
74
+ return (
75
+ <div className="bg-surface-raised">
76
+ <Heading className="m-0 text-md">
77
+ <button
78
+ aria-controls={panelId}
79
+ aria-expanded={isExpanded}
80
+ className={toClassName(
81
+ "flex w-full cursor-pointer items-center justify-between gap-3 bg-transparent px-4 py-3 text-start font-medium text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
82
+ "hover:bg-intent-neutral-surface",
83
+ isDisabled &&
84
+ "cursor-not-allowed text-content-disabled hover:bg-transparent",
85
+ FOCUS_RING_CLASS,
86
+ )}
87
+ disabled={isDisabled}
88
+ id={id}
89
+ onClick={() => {
90
+ onToggle(key)
91
+ }}
92
+ type="button"
93
+ >
94
+ {label}
95
+
96
+ <svg
97
+ // Decoration. The button already announces
98
+ // `aria-expanded`, which is the same fact said properly.
99
+ aria-hidden="true"
100
+ className={toClassName(
101
+ "size-4 shrink-0 text-content-secondary transition-transform duration-(--duration-fast) ease-standard",
102
+ isExpanded && "rotate-180",
103
+ )}
104
+ fill="none"
105
+ focusable={false}
106
+ stroke="currentColor"
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ strokeWidth={1.75}
110
+ viewBox="0 0 24 24"
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ >
113
+ <path d="m6 9 6 6 6-6" />
114
+ </svg>
115
+ </button>
116
+ </Heading>
117
+
118
+ {/*
119
+ `group`, not `region`, and the difference is a landmark.
120
+
121
+ The APG's accordion pattern says a panel *may* take
122
+ `role="region"` and adds a caveat in the same breath: avoid
123
+ it "in circumstances that create landmark region
124
+ proliferation". A component library has to assume the worst
125
+ case, and the worst case arrived on the first board — four
126
+ accordions produced four landmarks named "Disc", and axe's
127
+ `landmark-unique` failed the story. A real page with a job
128
+ list does exactly that.
129
+
130
+ Dropping the role entirely was the first fix and it was
131
+ wrong: `aria-labelledby` on a roleless `<div>` is **inert**,
132
+ so the panel would have lost its name rather than just its
133
+ landmark — caught by `useAriaPropsSupportedByRole`, which is
134
+ the one linter rule in this file that was right.
135
+
136
+ `group` is the role the caveat is reaching for: "a set of UI
137
+ objects **not** intended to be included in a page summary".
138
+ It keeps `aria-labelledby` meaningful and stays out of the
139
+ landmark list.
140
+ */}
141
+ {/* biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics and form-reset behaviour onto an accordion panel that contains prose. */}
142
+ <div
143
+ aria-labelledby={id}
144
+ className="px-4 pt-1 pb-4 text-content-secondary text-sm"
145
+ // `hidden` rather than unmounting, matching `Tabs`. A panel
146
+ // that unmounts loses a scroll position, a partially typed
147
+ // form, and any subscription its content opened — and the
148
+ // fleet's log panes are exactly that.
149
+ hidden={!isExpanded}
150
+ id={panelId}
151
+ // `group`, not `region`. A `group` is explicitly "a set of
152
+ // UI objects **not** intended to be included in a page
153
+ // summary", so it carries `aria-labelledby` and the
154
+ // trigger↔panel relationship without joining the landmark
155
+ // list — which is exactly the distinction the APG's caveat
156
+ // is reaching for and does not have a role for.
157
+ role="group"
158
+ >
159
+ {item.content}
160
+ </div>
161
+ </div>
162
+ )
163
+ }