@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,230 @@
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 * as stories from "./FileDropZone.stories.tsx"
8
+
9
+ const { AllStates, AllVariants, Default, Interactive } =
10
+ composeStories(stories)
11
+
12
+ /**
13
+ * There is **no keyboard gesture for drag-and-drop**, and WCAG 2.5.7
14
+ * (AA since 2.2) requires a single-pointer alternative for any
15
+ * dragging movement. The honest one has existed since 1995: a real
16
+ * `<input type="file">`.
17
+ *
18
+ * So the zone is a `<label>` around an input, and the drop handlers
19
+ * are an enhancement on a control that already works without them.
20
+ * The fleet's one drag target — gallery-downloader's page — has no
21
+ * keyboard path at all.
22
+ */
23
+ /**
24
+ * **The one component in this library that cannot use
25
+ * `expectAgentDrivable`, and the reason is worth the space.**
26
+ *
27
+ * `<input type="file">` — the accessible, keyboard-operable answer,
28
+ * the whole point of the design above — has **no ARIA role**.
29
+ * HTML-AAM defines none, so testing-library's role computation
30
+ * matches it to nothing and `getByRole("button")` returns zero
31
+ * elements.
32
+ *
33
+ * Browsers disagree with the specification and with each other:
34
+ * Chrome exposes it as a button in its accessibility tree, and
35
+ * **Playwright's own role engine maps it to `button`** — so
36
+ * `page.getByRole("button", { name })` finds it in a real agent run
37
+ * while `canvas.getByRole` cannot find it here.
38
+ *
39
+ * That is a genuine hole in this library's central claim, and it is
40
+ * a hole in the platform rather than in the component: the *most*
41
+ * accessible file control is the one the role model has no name for.
42
+ * `getByLabelText` is the honest query — and it is also what
43
+ * `page.getByLabel(…).setInputFiles(…)` does, which is how an agent
44
+ * drives this in practice.
45
+ */
46
+ test("the zone is a real file input, reachable and named", async () => {
47
+ const { canvas, canvasElement } =
48
+ await mountStory(Default)
49
+
50
+ const input = canvas.getByLabelText(
51
+ "Drop a disc image here",
52
+ )
53
+
54
+ await expect(input.tagName).toBe("INPUT")
55
+
56
+ await expect(input).toHaveAttribute("type", "file")
57
+
58
+ // Reachable with Tab. `sr-only` is the clip-rect technique, which
59
+ // keeps an element focusable — `display: none` would not.
60
+ await expect(input.tabIndex).toBeGreaterThanOrEqual(0)
61
+
62
+ await expectNoAxeViolations(canvasElement)
63
+ })
64
+
65
+ test("`accept` and `multiple` reach the input", async () => {
66
+ const { canvas, canvasElement } =
67
+ await mountStory(AllVariants)
68
+
69
+ await expect(
70
+ canvas.getByLabelText("Drop subtitle files"),
71
+ ).toHaveAttribute("multiple")
72
+
73
+ await expect(
74
+ canvas.getByLabelText("Drop a subtitle track"),
75
+ ).toHaveAttribute("accept", ".srt,.ass")
76
+
77
+ await expectNoAxeViolations(canvasElement)
78
+ })
79
+
80
+ test("a disabled zone disables its input", async () => {
81
+ const { canvas, canvasElement } =
82
+ await mountStory(AllStates)
83
+
84
+ await expect(
85
+ canvas.getByLabelText("Drop a disabled image"),
86
+ ).toBeDisabled()
87
+
88
+ await expectNoAxeViolations(canvasElement)
89
+ })
90
+
91
+ test("choosing a file through the picker reports it", async () => {
92
+ const { canvas } = await mountStory(Interactive)
93
+
94
+ const input = canvas.getByLabelText(
95
+ "Drop files or a link",
96
+ ) as HTMLInputElement
97
+
98
+ await userEvent.upload(
99
+ input,
100
+ new File(["disc"], "blade-runner.iso", {
101
+ type: "application/octet-stream",
102
+ }),
103
+ )
104
+
105
+ await waitFor(() => {
106
+ expect(
107
+ canvas.getByText("blade-runner.iso"),
108
+ ).toBeInTheDocument()
109
+ })
110
+ })
111
+
112
+ /**
113
+ * A file input keeps its value, so picking the *same* file twice is
114
+ * not a change and the handler never runs — which reads as "it
115
+ * ignored me" and is the oldest bug in this control. The input is
116
+ * cleared after every pick.
117
+ */
118
+ test("the same file can be chosen twice", async () => {
119
+ const { canvas } = await mountStory(Interactive)
120
+
121
+ const input = canvas.getByLabelText(
122
+ "Drop files or a link",
123
+ ) as HTMLInputElement
124
+
125
+ const file = new File(["disc"], "alien.iso", {
126
+ type: "application/octet-stream",
127
+ })
128
+
129
+ await userEvent.upload(input, file)
130
+
131
+ await waitFor(() => {
132
+ expect(
133
+ canvas.getByText("alien.iso"),
134
+ ).toBeInTheDocument()
135
+ })
136
+
137
+ await expect(input.value).toBe("")
138
+
139
+ await userEvent.upload(input, file)
140
+
141
+ await waitFor(() => {
142
+ expect(
143
+ canvas.getByText("alien.iso"),
144
+ ).toBeInTheDocument()
145
+ })
146
+ })
147
+
148
+ /**
149
+ * Dragging a link gives `text/plain` and an **empty** `files` list.
150
+ * A zone that only reads `.files` silently ignores the single most
151
+ * common thing a user drags into a downloader.
152
+ */
153
+ test("a dropped link reports as text", async () => {
154
+ const { canvas } = await mountStory(Interactive)
155
+
156
+ const zone = canvas
157
+ .getByLabelText("Drop files or a link")
158
+ .closest("label")
159
+
160
+ const dataTransfer = new DataTransfer()
161
+
162
+ dataTransfer.setData(
163
+ "text",
164
+ "https://example.test/gallery/42",
165
+ )
166
+
167
+ // `dragover` first, and its default action is to **reject** the
168
+ // drop — without `preventDefault` the `drop` below never fires at
169
+ // all. Nothing errors; the zone just looks broken.
170
+ zone?.dispatchEvent(
171
+ new DragEvent("dragover", {
172
+ bubbles: true,
173
+ cancelable: true,
174
+ dataTransfer,
175
+ }),
176
+ )
177
+
178
+ zone?.dispatchEvent(
179
+ new DragEvent("drop", {
180
+ bubbles: true,
181
+ cancelable: true,
182
+ dataTransfer,
183
+ }),
184
+ )
185
+
186
+ await waitFor(() => {
187
+ expect(
188
+ canvas.getByText("https://example.test/gallery/42"),
189
+ ).toBeInTheDocument()
190
+ })
191
+ })
192
+
193
+ /**
194
+ * `dragenter` and `dragleave` fire once per **element** the pointer
195
+ * crosses, not once per zone — so a plain boolean turns the
196
+ * highlight off halfway across and it strobes.
197
+ * gallery-downloader's page keeps a `dragDepth` counter, which is
198
+ * the correct fix and is reproduced here.
199
+ */
200
+ test("the highlight survives crossing a child element", async () => {
201
+ const { canvas } = await mountStory(Default)
202
+
203
+ const zone = canvas
204
+ .getByLabelText("Drop a disc image here")
205
+ .closest("label") as HTMLLabelElement
206
+
207
+ const [child] = Array.from(zone.querySelectorAll("span"))
208
+
209
+ const dispatch = (type: string, target: Element) => {
210
+ target.dispatchEvent(
211
+ new DragEvent(type, {
212
+ bubbles: true,
213
+ cancelable: true,
214
+ dataTransfer: new DataTransfer(),
215
+ }),
216
+ )
217
+ }
218
+
219
+ dispatch("dragenter", zone)
220
+
221
+ const activeClass = zone.className
222
+
223
+ // Entering a child fires `dragenter` again *and* `dragleave` on
224
+ // the zone. Depth goes 1 → 2 → 1, so the highlight stays on.
225
+ dispatch("dragenter", child ?? zone)
226
+
227
+ dispatch("dragleave", zone)
228
+
229
+ await expect(zone.className).toBe(activeClass)
230
+ })
@@ -0,0 +1,234 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { DragEvent, ReactNode } from "react"
3
+ import { useRef, useState } from "react"
4
+
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ export type FileDropZoneProps = {
8
+ /** An `accept` list, passed straight to the input. */
9
+ accept?: string
10
+ className?: string
11
+ /** Secondary line — the formats taken, a size cap. */
12
+ description?: ReactNode
13
+ isDisabled?: boolean
14
+ isMultiple?: boolean
15
+ /** The control's accessible name, and its visible prompt. */
16
+ label: string
17
+ onDropFiles: (files: File[]) => void
18
+ /**
19
+ * Dropped text, when there is any. A browser hands over a dragged
20
+ * link as `text/plain` and no files at all, which is what
21
+ * gallery-downloader's page is built entirely around — it takes
22
+ * `dataTransfer.getData("text")` and never looks at `.files`.
23
+ */
24
+ onDropText?: (text: string) => void
25
+ }
26
+
27
+ /**
28
+ * The one drag target in the fleet is gallery-downloader's, and it
29
+ * gets the hard part right: `dragenter`/`dragleave` fire once per
30
+ * *element* the pointer crosses, not once per zone, so a naive
31
+ * implementation flickers over every child — and that page keeps a
32
+ * `dragDepth` counter, which is the correct fix. It is reproduced
33
+ * below with credit.
34
+ *
35
+ * What it has no answer for is the part that cannot be fixed by
36
+ * counting:
37
+ *
38
+ * ### Dragging is pointer-only, so the zone is a label around an input
39
+ *
40
+ * There is **no keyboard gesture for drag-and-drop**. WCAG 2.5.7
41
+ * (AA since 2.2) requires a single-pointer alternative for any
42
+ * dragging movement, and the honest one has existed since 1995: a
43
+ * real `<input type="file">`. So this component is a `<label>`
44
+ * wrapping a visually-hidden input — the whole zone is the label's
45
+ * click target, Tab reaches the input, Space and Enter open the
46
+ * picker, and the drop handlers are an *enhancement* on top of a
47
+ * control that already works without them.
48
+ *
49
+ * That ordering is the entire design. Building the drop zone first
50
+ * and bolting on a "or browse" link is how the fleet's version ended
51
+ * up with an `alert()` as its error channel and no keyboard path at
52
+ * all.
53
+ *
54
+ * ### `dragover` must be prevented, and the reason is a footgun
55
+ *
56
+ * A browser's default for `dragover` is *reject the drop*. Not
57
+ * preventing it means `drop` never fires — nothing errors, the
58
+ * cursor shows a "no entry" sign, and the zone looks like it has a
59
+ * broken handler when it in fact has no handler running at all.
60
+ *
61
+ * ### It reports text as well as files
62
+ *
63
+ * Dragging a link gives you `text/plain` and an empty `files` list.
64
+ * A zone that only reads `.files` silently ignores the single most
65
+ * common thing a user drags into a downloader, which is why
66
+ * `onDropText` is here rather than being someone's follow-up.
67
+ */
68
+ export const FileDropZone = ({
69
+ accept,
70
+ className,
71
+ description,
72
+ isDisabled = false,
73
+ isMultiple = false,
74
+ label,
75
+ onDropFiles,
76
+ onDropText,
77
+ }: FileDropZoneProps): ReactNode => {
78
+ const baseId = useUniqueId()
79
+
80
+ const inputId = `${baseId}-input`
81
+
82
+ const labelId = `${baseId}-label`
83
+
84
+ const descriptionId = `${baseId}-description`
85
+
86
+ const [isDragActive, setIsDragActive] = useState(false)
87
+
88
+ /**
89
+ * The counter gallery-downloader's page already keeps, and the
90
+ * reason it has to exist: `dragenter` fires again for every child
91
+ * element the pointer crosses, and `dragleave` fires for the one
92
+ * it left — so a plain boolean turns off halfway across the zone
93
+ * and the highlight strobes.
94
+ *
95
+ * A ref rather than state: nothing renders from the depth itself,
96
+ * only from whether it is above zero, and a re-render per crossed
97
+ * child during a drag is exactly the jank this is avoiding.
98
+ */
99
+ const dragDepth = useRef(0)
100
+
101
+ const endDrag = () => {
102
+ dragDepth.current = 0
103
+
104
+ setIsDragActive(false)
105
+ }
106
+
107
+ const handleDrop = (
108
+ dropEvent: DragEvent<HTMLElement>,
109
+ ) => {
110
+ dropEvent.preventDefault()
111
+
112
+ endDrag()
113
+
114
+ if (isDisabled) {
115
+ return
116
+ }
117
+
118
+ const files = Array.from(dropEvent.dataTransfer.files)
119
+
120
+ if (files.length > 0) {
121
+ onDropFiles(isMultiple ? files : files.slice(0, 1))
122
+
123
+ return
124
+ }
125
+
126
+ const text = dropEvent.dataTransfer.getData("text")
127
+
128
+ if (text !== "") {
129
+ onDropText?.(text)
130
+ }
131
+ }
132
+
133
+ return (
134
+ <label
135
+ className={toClassName(
136
+ "flex cursor-pointer flex-col items-center gap-1 rounded-md border-2 border-border-default border-dashed p-6 text-center transition-colors duration-(--duration-fast) ease-standard",
137
+ isDragActive
138
+ ? "border-intent-accent-border bg-intent-accent-surface"
139
+ : "bg-surface-sunken hover:border-border-strong",
140
+ isDisabled &&
141
+ "cursor-not-allowed border-border-subtle text-content-disabled hover:border-border-subtle",
142
+ // The ring is on the label, driven by the input inside it —
143
+ // the input is what actually takes focus, and it is
144
+ // visually hidden, so a ring on the input paints nothing.
145
+ "focus-within:outline-solid focus-within:outline-(length:--focus-ring-width) focus-within:outline-offset-(--focus-ring-offset) focus-within:outline-focus-ring",
146
+ className,
147
+ )}
148
+ htmlFor={inputId}
149
+ onDragEnter={(dragEvent) => {
150
+ dragEvent.preventDefault()
151
+
152
+ dragDepth.current += 1
153
+
154
+ if (dragDepth.current === 1) {
155
+ setIsDragActive(true)
156
+ }
157
+ }}
158
+ onDragLeave={(dragEvent) => {
159
+ dragEvent.preventDefault()
160
+
161
+ dragDepth.current -= 1
162
+
163
+ if (dragDepth.current === 0) {
164
+ setIsDragActive(false)
165
+ }
166
+ }}
167
+ onDragOver={(dragEvent) => {
168
+ // Not optional. The default action for `dragover` is to
169
+ // reject the drop, so without this `onDrop` below never runs
170
+ // and the zone looks broken rather than unhandled.
171
+ dragEvent.preventDefault()
172
+ }}
173
+ onDrop={handleDrop}
174
+ >
175
+ <input
176
+ accept={accept}
177
+ // Named by the prompt **only**, and described by the rest.
178
+ //
179
+ // The whole zone is the `<label>` — that is what makes the
180
+ // six-rem target clickable — so its text content is the
181
+ // prompt *and* the description, and a `<label for>` names
182
+ // its control with all of it. The input's accessible name
183
+ // came out as "Drop a disc image hereISO or MKV, up to 50
184
+ // GB.", which is not a name, and which `getByLabelText` and
185
+ // an agent both fail to match.
186
+ //
187
+ // `aria-labelledby` overrides the implicit `<label>` naming
188
+ // without giving up the click target.
189
+ aria-describedby={
190
+ description === undefined
191
+ ? undefined
192
+ : descriptionId
193
+ }
194
+ aria-labelledby={labelId}
195
+ className="sr-only"
196
+ disabled={isDisabled}
197
+ id={inputId}
198
+ multiple={isMultiple}
199
+ onChange={(changeEvent) => {
200
+ const files = Array.from(
201
+ changeEvent.currentTarget.files ?? [],
202
+ )
203
+
204
+ if (files.length > 0) {
205
+ onDropFiles(files)
206
+ }
207
+
208
+ // Cleared so choosing the *same* file twice fires again.
209
+ // A file input keeps its value, so the second pick is not
210
+ // a change and the handler never runs — which reads as
211
+ // "it ignored me" and is the oldest bug in this control.
212
+ changeEvent.currentTarget.value = ""
213
+ }}
214
+ type="file"
215
+ />
216
+
217
+ <span
218
+ className="font-medium text-content-primary text-sm"
219
+ id={labelId}
220
+ >
221
+ {label}
222
+ </span>
223
+
224
+ {description === undefined ? null : (
225
+ <span
226
+ className="text-content-secondary text-xs"
227
+ id={descriptionId}
228
+ >
229
+ {description}
230
+ </span>
231
+ )}
232
+ </label>
233
+ )
234
+ }
@@ -0,0 +1,123 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as LogViewerStories from "./LogViewer.stories";
4
+
5
+ <Meta of={LogViewerStories} />
6
+
7
+ # LogViewer
8
+
9
+ Three repos render a scrolling log pane — mux-magic's `JobLogsDisclosure` and `StepLogs`,
10
+ rip-deck's `LogModal` — and between them they have four defects this component exists not to
11
+ have. The fifth below is not theirs: it is defect 1 coming back through the front door, in
12
+ this component, found by the first app to consume it.
13
+
14
+ <Canvas of={LogViewerStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## 1. The auto-scroll that follows nothing
21
+
22
+ ```tsx
23
+ useEffect(() => {
24
+ const pane = paneRef.current
25
+ if (pane) pane.scrollTop = pane.scrollHeight
26
+ }, []) // ← empty deps
27
+ ```
28
+
29
+ That is mux-magic's, and it runs **once**, on mount, when the pane is empty and
30
+ `scrollHeight` *is* `clientHeight`. Every line after it arrives below the fold.
31
+
32
+ It reads as correct, it is the shape everyone writes, and the log simply never follows.
33
+
34
+ ## 2. Following that cannot be escaped
35
+
36
+ The naive fix — scroll to the bottom on every render — is worse: a user scrolling back to
37
+ read the line that mentioned an error is yanked to the end twice a second.
38
+
39
+ Following has to be **pinned to the user's own position**. It turns off the moment they
40
+ scroll away and back on when they return to the bottom, and there is a **Jump to latest**
41
+ button for when they do not want to scroll all that way.
42
+
43
+ The pin test allows a few pixels of slack, because a fractional device pixel ratio means
44
+ `scrollTop + clientHeight` never lands exactly on `scrollHeight`. An equality test here
45
+ un-pins the pane on a 125% display and on nothing else.
46
+
47
+ <Canvas of={LogViewerStories.Interactive} />
48
+
49
+ ## 3. `data-log-id`
50
+
51
+ mux-magic's pane carries one. It is a `data-testid` under a different name — a handle only
52
+ the test suite can see — and `sourceRules.test.ts` bans the **practice**, not the spelling.
53
+ The pane here is found by its `label`, which is the query Playwright makes.
54
+
55
+ ## 4. Announcing an hour of build output
56
+
57
+ `role="log"` is the correct role and `aria-live` is **off** unless asked for. A live log
58
+ emitting a line every 300 ms is a screen reader that cannot be interrupted and a user who
59
+ cannot read anything else on the page.
60
+
61
+ The accessible answer for a long-running job is a `LiveStatusIndicator` announcing *state*
62
+ and a log that is there to be read when wanted. `isAnnounced` exists for the short case: a
63
+ five-line result panel where the lines **are** the outcome.
64
+
65
+ <Canvas of={LogViewerStories.AllVariants} />
66
+
67
+ ## 5. A pane measured before it has a box
68
+
69
+ Defect 1 again, and the fix for it cannot see this one. `AccordionSection` renders its panel
70
+ with `hidden` rather than unmounting it, deliberately — an unmounted panel loses a scroll
71
+ position, a partially typed form, and any subscription its content opened, and the fleet's
72
+ log panes are exactly that. A `hidden` subtree has **no layout**, so the mount effect reads
73
+ `scrollHeight 0` and writes `scrollTop = 0`. Measured in mux-magic on a 60-line pane:
74
+
75
+ ```
76
+ while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
77
+ after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
78
+ ```
79
+
80
+ Neither `isFollowing` nor `lines` changes when the section is opened, so the effect never
81
+ runs again and the log opens on its **first** line. That is this component's own `}, [])`
82
+ bug, rebuilt out of two components whose individual decisions are both right, and invisible
83
+ to both of their test suites — `LogViewer`'s mounted visible, `Accordion`'s holding content
84
+ that never measures itself. mux-magic hit it as a consumer and worked around it downstream,
85
+ in a `DisclosedLogViewer` that withheld the pane until the section had been opened once.
86
+ That workaround is now deletable.
87
+
88
+ A `ResizeObserver` on the pane, live only while following, is the fix. Not an
89
+ `IntersectionObserver`: that answers "is it on screen", which is a different question with
90
+ two wrong answers here — a pane below the fold on a long page is not intersecting and has
91
+ perfectly good layout, and scrolling the page later would re-fire for no reason.
92
+ `ResizeObserver` answers "does it have a box, and is that box still the same size", which is
93
+ the precondition the measurement actually needs; per spec it does not fire at `observe()`
94
+ time for an element that is not being rendered, so **gaining a box is the first callback**.
95
+
96
+ It pays for itself twice: a window resize re-wraps the lines and changes what is at the
97
+ bottom, and a following pane belongs at the new bottom.
98
+
99
+ <Canvas of={LogViewerStories.InsideDisclosure} />
100
+
101
+ ## Not every boolean is a state kind
102
+
103
+ `isFollowing` is `useState`, not `useVisibility`, and this is the first component in the
104
+ library to say so out loud.
105
+
106
+ The kinds in `@charcuterie/logic` earn their keep when state is **registered** (members join
107
+ a group), **composed** (focus and selection disagreeing), or **shared** (a trigger and a
108
+ panel at opposite ends of a tree). None of that is true here: one component reads one
109
+ boolean it wrote itself.
110
+
111
+ Reaching for `useVisibility` because it is also a boolean would make `isVisible` mean "is
112
+ following", which is how a state layer stops meaning anything.
113
+
114
+ ## `maxLines`, not virtualization
115
+
116
+ The plan floated an optional `@tanstack/react-virtual` peer. Dropping the oldest lines is
117
+ what actually bounds the DOM, costs no dependency, and is what every one of these panes
118
+ wanted anyway — nobody scrolls back through an hour of sector reads.
119
+
120
+ Virtualization stays unbuilt until a consumer produces a log where 500 lines is not enough,
121
+ which is the same rule that kept a bare `Listbox` out of `Select`.
122
+
123
+ <Canvas of={LogViewerStories.AllStates} />