@charcuterie/ui 0.1.1 → 1.0.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 (115) 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 +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as FieldStories from "./Field.stories";
4
+
5
+ <Meta of={FieldStories} />
6
+
7
+ # Field
8
+
9
+ Sixteen files in the fleet spell a label, a control, and a hint, and no two spell the
10
+ wiring the same way.
11
+
12
+ An earlier version of this page said mux-magic's `FieldLabel` renders a `<label>` with
13
+ **no `htmlFor`**. That is false, and worth correcting rather than quietly deleting: it
14
+ renders ``htmlFor={`${stepId}-${field.name}`}``, and has since it was written. The defect
15
+ is at the *other* end of the pair — **8 of its 16 call sites never render that id on
16
+ anything**. Measured on `mux-magic@master`, the eight that do are `BooleanField`,
17
+ `ChapterSplitsField`, `EnumField`, `NumberField`, `NumberWithLookupField`, `PathField`,
18
+ `StringArrayField` and `StringField`. Of the rest, `RegexWithFlagsField` mints its own
19
+ `patternId` / `flagsId` / `sampleId` and `SubtitleRulesField` spells
20
+ ``${step.command}-hasDefaultRules`` where the label says ``${step.id}-…``.
21
+
22
+ The outcome is the same unnamed textbox by a worse route: a `<label>` pointing at an id
23
+ that is nowhere in the document reads as correct in the label's own file, and is only
24
+ wrong in the *other* file. Half of them are broken and nothing says which half. That is
25
+ the argument for a component rather than a convention — here the id is minted and consumed
26
+ in one place and cannot be spelled twice.
27
+
28
+ <Canvas of={FieldStories.Default} />
29
+
30
+ ## Props
31
+
32
+ <Controls />
33
+
34
+ ## The control is a slot
35
+
36
+ Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`, a
37
+ `<textarea>`, a `Select`, or an app's own control all go in the same hole, and the `id`,
38
+ `aria-describedby`, `aria-invalid` and `required` are injected onto whatever arrives.
39
+
40
+ <Canvas of={FieldStories.AllVariants} />
41
+
42
+ ## A slot is a pass-through
43
+
44
+ Which is a rule this component needed and did not have. `Field` and `Tooltip` both clone
45
+ onto their single child, so the obvious thing to write —
46
+
47
+ ```tsx
48
+ <Field label="Rename pattern" isRequired>
49
+ <Tooltip label="A JavaScript regular expression.">
50
+ <input />
51
+ </Tooltip>
52
+ </Field>
53
+ ```
54
+
55
+ — handed `id`, `aria-describedby`, `aria-invalid` and `required` to the **`Tooltip`
56
+ component**, which declares none of them. `cloneElement` does not care. React drops all
57
+ four without a warning, TypeScript never sees it (`Children.only` returns a `ReactElement`
58
+ whose props are `any`), every test still passed, and the render was pixel-identical. The
59
+ only symptom was a `<label htmlFor>` pointing at an id nowhere in the document — the exact
60
+ defect above, reproduced by the component built to prevent it, and this time by the
61
+ library rather than by a consumer.
62
+
63
+ **Silent prop-dropping is the worst version of this**, so anything a slot receives from
64
+ above is forwarded to the child it clones onto, and a chain of slots behaves like the one
65
+ control at the bottom of it. `aria-describedby` — the one prop that is a *list* — is
66
+ merged rather than overwritten, outer first.
67
+
68
+ It works in both orders, and the second is not symmetrical: a `Tooltip` around a `Field`
69
+ hands down not one attribute but a working component — floating-ui's hover, focus and
70
+ dismiss handlers and `refs.setReference`. Those reach the control too, or the tip is a
71
+ floating node with no trigger and no anchor.
72
+
73
+ <Canvas of={FieldStories.Nested} />
74
+
75
+ ## Several controls are a `FieldGroup`, not a `Field`
76
+
77
+ `Field` mints one id and clones it onto one child. An `id` names one element and a
78
+ `<label htmlFor>` points at one, so the moment there are two controls the label names one
79
+ of them and leaves the rest anonymous. Six of mux-magic's sixteen field components are in
80
+ that position — `RegexWithFlagsField` (pattern, flags, sample, and a checkbox),
81
+ `NumberWithLookupField`, `LanguageCodeField`, `LanguageCodesField`, `SubtitleTypesField`
82
+ and `RenameRegexField`.
83
+
84
+ `FieldGroup` renders `<fieldset>` + `<legend>` and does **not** clone. This is the one
85
+ place in the library where `<fieldset>` is right: `AccordionSection` rejected it for its
86
+ panel precisely because that element means *form-control grouping*, and here the content
87
+ really is one. `<legend>` names the group natively, so no `role="group"` and no
88
+ `aria-labelledby`.
89
+
90
+ What a group cannot carry is `aria-invalid`, which has no group form — it belongs on the
91
+ control that is actually invalid, and a `FieldGroup` does not know which one that is. So
92
+ `error` here is **described, not asserted**: it joins the `<fieldset>`'s
93
+ `aria-describedby`. Cloning `aria-invalid` onto every child would mark the valid ones
94
+ invalid. A control that is itself invalid says so in its own `Field`.
95
+
96
+ <Canvas of={FieldStories.Group} />
97
+
98
+ ## `createLinkedIds` is *not* here, and that is the finding
99
+
100
+ The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
101
+ did not fit `Tabs`: that kind exists for a **dynamic** pairing, where the registration
102
+ multiset is what stops an `aria-describedby` naming a node React has already removed.
103
+
104
+ A field renders its own description and its own error, so it knows *at render* which exist.
105
+ The ids come from one `useUniqueId` and a conditional join, and they cannot get out of step
106
+ at all.
107
+
108
+ That leaves `createLinkedIds` with **no consumer in this package across twenty-five
109
+ components**. It is the one state kind the component layer has never needed, and saying so
110
+ is more useful than manufacturing a use for it.
111
+
112
+ ## One source of invalidity
113
+
114
+ There is no `isInvalid` prop. `error` being present *is* the invalidity:
115
+
116
+ | `error` | `aria-invalid` | `aria-describedby` |
117
+ | --- | --- | --- |
118
+ | absent | not set | description only, if any |
119
+ | present | `"true"` | description then error |
120
+
121
+ Two props for one fact is how a control ends up `aria-invalid="true"` with nothing saying
122
+ why — which is what `RegexWithFlagsField` does in mux-magic today.
123
+
124
+ <Canvas of={FieldStories.AllStates} />
125
+
126
+ ## The error is *described*, not announced
127
+
128
+ No `role="alert"` and no live region, deliberately. An error bound through
129
+ `aria-describedby` is read when the control takes focus — which is when the user can act on
130
+ it. A live region on the same text announces it a second time, from nowhere, while the caret
131
+ is somewhere else.
132
+
133
+ A form that fails on submit should say so **once, at the top**, with the `Alert` this
134
+ library already ships. Then the user tabs into the field and hears the specific reason.
135
+ `ToastRegion` draws the same line for the same reason.
136
+
137
+ ## Order is behaviour
138
+
139
+ `aria-describedby` is read in sequence, so description comes before error:
140
+
141
+ > "Absolute paths only. That path is not writable."
142
+
143
+ reads correctly; its reverse does not. Nothing enforces this but the order of one array in
144
+ the component, which is why that array is not built from an object.