@charcuterie/ui 0.1.1 → 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.
- package/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +1 -1
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -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} />
|