@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,136 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import { FileDropZone } from "./FileDropZone.tsx"
9
+
10
+ const noop = () => undefined
11
+
12
+ const meta = {
13
+ title: "Components/FileDropZone",
14
+ component: FileDropZone,
15
+ parameters: { layout: "padded" },
16
+ args: {
17
+ isDisabled: false,
18
+ isMultiple: false,
19
+ label: "Drop a disc image here",
20
+ onDropFiles: noop,
21
+ },
22
+ } satisfies Meta<typeof FileDropZone>
23
+
24
+ export default meta
25
+
26
+ type Story = StoryObj<typeof meta>
27
+
28
+ export const Default: Story = {
29
+ args: {
30
+ description: "ISO or MKV, up to 50 GB.",
31
+ label: "Drop a disc image here",
32
+ },
33
+ }
34
+
35
+ export const AllVariants: Story = {
36
+ args: { label: "Drop a disc image here" },
37
+ render: () => (
38
+ <StoryGrid columns={2}>
39
+ <StoryCell align="stretch" label="single file">
40
+ <FileDropZone
41
+ description="ISO or MKV, up to 50 GB."
42
+ label="Drop a disc image"
43
+ onDropFiles={noop}
44
+ />
45
+ </StoryCell>
46
+
47
+ <StoryCell align="stretch" label="multiple">
48
+ <FileDropZone
49
+ description="Several at once."
50
+ isMultiple
51
+ label="Drop subtitle files"
52
+ onDropFiles={noop}
53
+ />
54
+ </StoryCell>
55
+
56
+ <StoryCell align="stretch" label="accepting a type">
57
+ <FileDropZone
58
+ accept=".srt,.ass"
59
+ description="Subtitles only."
60
+ label="Drop a subtitle track"
61
+ onDropFiles={noop}
62
+ />
63
+ </StoryCell>
64
+
65
+ <StoryCell align="stretch" label="no description">
66
+ <FileDropZone
67
+ label="Drop anything"
68
+ onDropFiles={noop}
69
+ />
70
+ </StoryCell>
71
+ </StoryGrid>
72
+ ),
73
+ }
74
+
75
+ export const AllStates: Story = {
76
+ args: { label: "Drop a disc image here" },
77
+ render: () => (
78
+ <StoryGrid columns={2}>
79
+ <StoryCell align="stretch" label="idle">
80
+ <FileDropZone
81
+ label="Drop an idle image"
82
+ onDropFiles={noop}
83
+ />
84
+ </StoryCell>
85
+
86
+ <StoryCell align="stretch" label="disabled">
87
+ <FileDropZone
88
+ description="A rip is already running."
89
+ isDisabled
90
+ label="Drop a disabled image"
91
+ onDropFiles={noop}
92
+ />
93
+ </StoryCell>
94
+ </StoryGrid>
95
+ ),
96
+ }
97
+
98
+ /**
99
+ * Tab to it and press Enter — the file picker opens, which is the
100
+ * whole point: **there is no keyboard gesture for drag-and-drop**,
101
+ * so the zone is a `<label>` around a real `<input type="file">` and
102
+ * the drop handlers are an enhancement on top of a control that
103
+ * already works without them.
104
+ *
105
+ * Dragging a *link* reports through `onDropText`, because a browser
106
+ * hands over a dragged URL as `text/plain` with no files at all —
107
+ * which is what gallery-downloader's page is built entirely around.
108
+ */
109
+ export const Interactive: Story = {
110
+ args: { label: "Drop a disc image here" },
111
+ render: function DropTarget() {
112
+ const [dropped, setDropped] = useState<string[]>([])
113
+
114
+ return (
115
+ <div className="flex flex-col gap-3">
116
+ <FileDropZone
117
+ description="Files, or a dragged link."
118
+ isMultiple
119
+ label="Drop files or a link"
120
+ onDropFiles={(files) => {
121
+ setDropped(files.map((file) => file.name))
122
+ }}
123
+ onDropText={(text) => {
124
+ setDropped([text])
125
+ }}
126
+ />
127
+
128
+ <ul className="list-none p-0 text-content-secondary text-sm">
129
+ {dropped.map((one) => (
130
+ <li key={one}>{one}</li>
131
+ ))}
132
+ </ul>
133
+ </div>
134
+ )
135
+ },
136
+ }
@@ -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,88 @@
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.
12
+
13
+ <Canvas of={LogViewerStories.Default} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## 1. The auto-scroll that follows nothing
20
+
21
+ ```tsx
22
+ useEffect(() => {
23
+ const pane = paneRef.current
24
+ if (pane) pane.scrollTop = pane.scrollHeight
25
+ }, []) // ← empty deps
26
+ ```
27
+
28
+ That is mux-magic's, and it runs **once**, on mount, when the pane is empty and
29
+ `scrollHeight` *is* `clientHeight`. Every line after it arrives below the fold.
30
+
31
+ It reads as correct, it is the shape everyone writes, and the log simply never follows.
32
+
33
+ ## 2. Following that cannot be escaped
34
+
35
+ The naive fix — scroll to the bottom on every render — is worse: a user scrolling back to
36
+ read the line that mentioned an error is yanked to the end twice a second.
37
+
38
+ Following has to be **pinned to the user's own position**. It turns off the moment they
39
+ scroll away and back on when they return to the bottom, and there is a **Jump to latest**
40
+ button for when they do not want to scroll all that way.
41
+
42
+ The pin test allows a few pixels of slack, because a fractional device pixel ratio means
43
+ `scrollTop + clientHeight` never lands exactly on `scrollHeight`. An equality test here
44
+ un-pins the pane on a 125% display and on nothing else.
45
+
46
+ <Canvas of={LogViewerStories.Interactive} />
47
+
48
+ ## 3. `data-log-id`
49
+
50
+ mux-magic's pane carries one. It is a `data-testid` under a different name — a handle only
51
+ the test suite can see — and `sourceRules.test.ts` bans the **practice**, not the spelling.
52
+ The pane here is found by its `label`, which is the query Playwright makes.
53
+
54
+ ## 4. Announcing an hour of build output
55
+
56
+ `role="log"` is the correct role and `aria-live` is **off** unless asked for. A live log
57
+ emitting a line every 300 ms is a screen reader that cannot be interrupted and a user who
58
+ cannot read anything else on the page.
59
+
60
+ The accessible answer for a long-running job is a `LiveStatusIndicator` announcing *state*
61
+ and a log that is there to be read when wanted. `isAnnounced` exists for the short case: a
62
+ five-line result panel where the lines **are** the outcome.
63
+
64
+ <Canvas of={LogViewerStories.AllVariants} />
65
+
66
+ ## Not every boolean is a state kind
67
+
68
+ `isFollowing` is `useState`, not `useVisibility`, and this is the first component in the
69
+ library to say so out loud.
70
+
71
+ The kinds in `@charcuterie/logic` earn their keep when state is **registered** (members join
72
+ a group), **composed** (focus and selection disagreeing), or **shared** (a trigger and a
73
+ panel at opposite ends of a tree). None of that is true here: one component reads one
74
+ boolean it wrote itself.
75
+
76
+ Reaching for `useVisibility` because it is also a boolean would make `isVisible` mean "is
77
+ following", which is how a state layer stops meaning anything.
78
+
79
+ ## `maxLines`, not virtualization
80
+
81
+ The plan floated an optional `@tanstack/react-virtual` peer. Dropping the oldest lines is
82
+ what actually bounds the DOM, costs no dependency, and is what every one of these panes
83
+ wanted anyway — nobody scrolls back through an hour of sector reads.
84
+
85
+ Virtualization stays unbuilt until a consumer produces a log where 500 lines is not enough,
86
+ which is the same rule that kept a bare `Listbox` out of `Select`.
87
+
88
+ <Canvas of={LogViewerStories.AllStates} />