@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.
- 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 +3 -3
- 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
- package/CHANGELOG.md +0 -13
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
StoryCell,
|
|
5
|
+
StoryGrid,
|
|
6
|
+
} from "../board.storyHelpers.tsx"
|
|
7
|
+
import type { LogLine } from "./LogViewer.tsx"
|
|
8
|
+
import { LogViewer } from "./LogViewer.tsx"
|
|
9
|
+
|
|
10
|
+
const RIP_LINES: LogLine[] = [
|
|
11
|
+
{
|
|
12
|
+
key: "1",
|
|
13
|
+
text: "14:02:09 Opening /dev/sr0",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: "2",
|
|
17
|
+
text: "14:02:11 AACS handshake complete, reading…",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
intent: "warning",
|
|
21
|
+
key: "3",
|
|
22
|
+
text: "14:02:44 Title 4: 2 read errors, retrying",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: "4",
|
|
26
|
+
text: "14:03:02 Title 4: recovered",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
intent: "success",
|
|
30
|
+
key: "5",
|
|
31
|
+
text: "14:31:57 9 titles written to /mnt/rips",
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Long enough to overflow the pane, which is the only way to story
|
|
37
|
+
* the behaviour that matters here.
|
|
38
|
+
*/
|
|
39
|
+
const LONG_LINES: LogLine[] = Array.from(
|
|
40
|
+
{ length: 60 },
|
|
41
|
+
(_unused, index) => ({
|
|
42
|
+
key: String(index),
|
|
43
|
+
text: `14:${String(index).padStart(2, "0")}:00 Reading sector ${index * 2048}`,
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const meta = {
|
|
48
|
+
title: "Components/LogViewer",
|
|
49
|
+
component: LogViewer,
|
|
50
|
+
parameters: { layout: "padded" },
|
|
51
|
+
args: {
|
|
52
|
+
isAnnounced: false,
|
|
53
|
+
label: "Bay 3 rip log",
|
|
54
|
+
lines: RIP_LINES,
|
|
55
|
+
maxLines: 500,
|
|
56
|
+
},
|
|
57
|
+
} satisfies Meta<typeof LogViewer>
|
|
58
|
+
|
|
59
|
+
export default meta
|
|
60
|
+
|
|
61
|
+
type Story = StoryObj<typeof meta>
|
|
62
|
+
|
|
63
|
+
export const Default: Story = {
|
|
64
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const AllVariants: Story = {
|
|
68
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
69
|
+
render: () => (
|
|
70
|
+
<StoryGrid columns={2}>
|
|
71
|
+
<StoryCell label="short">
|
|
72
|
+
<LogViewer
|
|
73
|
+
label="Bay 1 rip log"
|
|
74
|
+
lines={RIP_LINES}
|
|
75
|
+
/>
|
|
76
|
+
</StoryCell>
|
|
77
|
+
|
|
78
|
+
<StoryCell label="overflowing">
|
|
79
|
+
<LogViewer
|
|
80
|
+
label="Bay 2 rip log"
|
|
81
|
+
lines={LONG_LINES}
|
|
82
|
+
/>
|
|
83
|
+
</StoryCell>
|
|
84
|
+
|
|
85
|
+
<StoryCell label="capped at 10 lines">
|
|
86
|
+
<LogViewer
|
|
87
|
+
label="Bay 4 rip log"
|
|
88
|
+
lines={LONG_LINES}
|
|
89
|
+
maxLines={10}
|
|
90
|
+
/>
|
|
91
|
+
</StoryCell>
|
|
92
|
+
|
|
93
|
+
<StoryCell label="announced">
|
|
94
|
+
<LogViewer
|
|
95
|
+
isAnnounced
|
|
96
|
+
label="Bay 5 result"
|
|
97
|
+
lines={RIP_LINES}
|
|
98
|
+
/>
|
|
99
|
+
</StoryCell>
|
|
100
|
+
</StoryGrid>
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Empty is a state, not a blank box. mux-magic's pane says
|
|
106
|
+
* "Waiting for log lines…" and rip-deck's says nothing at all,
|
|
107
|
+
* which reads as a broken subscription.
|
|
108
|
+
*/
|
|
109
|
+
export const AllStates: Story = {
|
|
110
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
111
|
+
render: () => (
|
|
112
|
+
<StoryGrid columns={2}>
|
|
113
|
+
<StoryCell label="empty">
|
|
114
|
+
<LogViewer label="Bay 6 rip log" lines={[]} />
|
|
115
|
+
</StoryCell>
|
|
116
|
+
|
|
117
|
+
<StoryCell label="with intents">
|
|
118
|
+
<LogViewer
|
|
119
|
+
label="Bay 7 rip log"
|
|
120
|
+
lines={RIP_LINES}
|
|
121
|
+
/>
|
|
122
|
+
</StoryCell>
|
|
123
|
+
</StoryGrid>
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scroll the tall pane up and the **Jump to latest** button
|
|
129
|
+
* appears; scroll back to the bottom and it goes away. Following is
|
|
130
|
+
* pinned to the user's own position, which is the difference between
|
|
131
|
+
* a log that follows and one that fights you.
|
|
132
|
+
*/
|
|
133
|
+
export const Interactive: Story = {
|
|
134
|
+
args: { label: "Bay 8 rip log", lines: LONG_LINES },
|
|
135
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, 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 "./LogViewer.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, AllVariants, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("the pane is named, and is a log", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
// Found by its name, not by a `data-log-id`. mux-magic's pane
|
|
18
|
+
// carries one of those — a `data-testid` under a different name,
|
|
19
|
+
// which this package's `sourceRules.test.ts` bans as a practice
|
|
20
|
+
// rather than as a spelling.
|
|
21
|
+
const pane = expectAgentDrivable(canvas, {
|
|
22
|
+
name: "Bay 3 rip log",
|
|
23
|
+
role: "log",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await expect(pane).toHaveTextContent(
|
|
27
|
+
"AACS handshake complete",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
await expectNoAxeViolations(canvasElement)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fix for mux-magic's defect, asserted as the behaviour rather
|
|
35
|
+
* than as the precondition. Its auto-scroll runs in an effect with
|
|
36
|
+
* **empty deps** — once, on mount, when the pane is empty and
|
|
37
|
+
* `scrollHeight` is `clientHeight` — so every line after it arrives
|
|
38
|
+
* below the fold.
|
|
39
|
+
*/
|
|
40
|
+
test("a full pane is scrolled to the bottom", async () => {
|
|
41
|
+
const { canvas } = await mountStory(Interactive)
|
|
42
|
+
|
|
43
|
+
const pane = expectAgentDrivable(canvas, {
|
|
44
|
+
name: "Bay 8 rip log",
|
|
45
|
+
role: "log",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// The precondition — that there *is* something to scroll. On its
|
|
49
|
+
// own this proves nothing, which is the lesson `Tabs` learned the
|
|
50
|
+
// expensive way from `scrollWidth > clientWidth`.
|
|
51
|
+
await expect(pane.scrollHeight).toBeGreaterThan(
|
|
52
|
+
pane.clientHeight,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// And the behaviour: it is actually at the end.
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(
|
|
58
|
+
pane.scrollHeight -
|
|
59
|
+
pane.scrollTop -
|
|
60
|
+
pane.clientHeight,
|
|
61
|
+
).toBeLessThan(4)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Following is pinned to the user's own position. Scrolling back to
|
|
67
|
+
* read the line that mentioned an error must not be undone twice a
|
|
68
|
+
* second — which is what the naive fix (scroll to bottom on every
|
|
69
|
+
* render) does.
|
|
70
|
+
*/
|
|
71
|
+
test("scrolling away stops the follow and offers a way back", async () => {
|
|
72
|
+
const { canvas } = await mountStory(Interactive)
|
|
73
|
+
|
|
74
|
+
const pane = expectAgentDrivable(canvas, {
|
|
75
|
+
name: "Bay 8 rip log",
|
|
76
|
+
role: "log",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
canvas.queryAllByRole("button", {
|
|
81
|
+
name: "Jump to latest",
|
|
82
|
+
}),
|
|
83
|
+
).toHaveLength(0)
|
|
84
|
+
|
|
85
|
+
pane.scrollTop = 0
|
|
86
|
+
|
|
87
|
+
pane.dispatchEvent(new Event("scroll", { bubbles: true }))
|
|
88
|
+
|
|
89
|
+
const jump = await waitFor(() =>
|
|
90
|
+
expectAgentDrivable(canvas, {
|
|
91
|
+
name: "Jump to latest",
|
|
92
|
+
role: "button",
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
jump.click()
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(
|
|
100
|
+
pane.scrollHeight -
|
|
101
|
+
pane.scrollTop -
|
|
102
|
+
pane.clientHeight,
|
|
103
|
+
).toBeLessThan(4)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A live log emitting a line every 300 ms is a screen reader that
|
|
109
|
+
* cannot be interrupted. `role="log"` is correct; `aria-live` is off
|
|
110
|
+
* unless the lines *are* the outcome.
|
|
111
|
+
*/
|
|
112
|
+
test("a log does not announce itself unless asked", async () => {
|
|
113
|
+
const { canvas } = await mountStory(AllVariants)
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
expectAgentDrivable(canvas, {
|
|
117
|
+
name: "Bay 1 rip log",
|
|
118
|
+
role: "log",
|
|
119
|
+
}),
|
|
120
|
+
).toHaveAttribute("aria-live", "off")
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
expectAgentDrivable(canvas, {
|
|
124
|
+
name: "Bay 5 result",
|
|
125
|
+
role: "log",
|
|
126
|
+
}),
|
|
127
|
+
).toHaveAttribute("aria-live", "polite")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("maxLines drops the oldest lines from the DOM", async () => {
|
|
131
|
+
const { canvas } = await mountStory(AllVariants)
|
|
132
|
+
|
|
133
|
+
const capped = expectAgentDrivable(canvas, {
|
|
134
|
+
name: "Bay 4 rip log",
|
|
135
|
+
role: "log",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// A rip emits a line every few hundred milliseconds for an hour,
|
|
139
|
+
// and the browser keeps every node.
|
|
140
|
+
await expect(capped.querySelectorAll("p")).toHaveLength(
|
|
141
|
+
10,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// The *last* ten, not the first — a log you have to scroll up
|
|
145
|
+
// through to reach the newest line is backwards.
|
|
146
|
+
await expect(capped).toHaveTextContent(
|
|
147
|
+
"Reading sector 120832",
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("a scrollable pane is reachable by keyboard", async () => {
|
|
152
|
+
const { canvas, canvasElement } =
|
|
153
|
+
await mountStory(AllStates)
|
|
154
|
+
|
|
155
|
+
// Chrome made scrollers focusable automatically in 2024; Firefox
|
|
156
|
+
// and Safari have not. Without it a keyboard user can see the pane
|
|
157
|
+
// and cannot scroll it.
|
|
158
|
+
await expect(
|
|
159
|
+
expectAgentDrivable(canvas, {
|
|
160
|
+
name: "Bay 7 rip log",
|
|
161
|
+
role: "log",
|
|
162
|
+
}).tabIndex,
|
|
163
|
+
).toBe(0)
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
expectAgentDrivable(canvas, {
|
|
167
|
+
name: "Bay 6 rip log",
|
|
168
|
+
role: "log",
|
|
169
|
+
}),
|
|
170
|
+
).toHaveTextContent("Waiting for output…")
|
|
171
|
+
|
|
172
|
+
await expectNoAxeViolations(canvasElement)
|
|
173
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { Button } from "../Button/Button.tsx"
|
|
6
|
+
import { INTENT_CONTENT_CLASS } from "../intentStyles.ts"
|
|
7
|
+
import { toClassName } from "../toClassName.ts"
|
|
8
|
+
|
|
9
|
+
export type LogLine = {
|
|
10
|
+
intent?: IntentName
|
|
11
|
+
key: string
|
|
12
|
+
text: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type LogViewerProps = {
|
|
16
|
+
className?: string
|
|
17
|
+
/**
|
|
18
|
+
* Announce new lines to a screen reader. **Off by default**, and
|
|
19
|
+
* that is a considered choice rather than an oversight — see
|
|
20
|
+
* below.
|
|
21
|
+
*/
|
|
22
|
+
isAnnounced?: boolean
|
|
23
|
+
/** The pane's accessible name. Required. */
|
|
24
|
+
label: string
|
|
25
|
+
lines: LogLine[]
|
|
26
|
+
/**
|
|
27
|
+
* Oldest lines past this are dropped from the DOM. A rip in
|
|
28
|
+
* progress emits a line every few hundred milliseconds for an
|
|
29
|
+
* hour, and the browser keeps every node.
|
|
30
|
+
*/
|
|
31
|
+
maxLines?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Three repos render a scrolling log pane —
|
|
36
|
+
* mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
|
|
37
|
+
* `LogModal` — and between them they have four defects that this
|
|
38
|
+
* component exists to not have.
|
|
39
|
+
*
|
|
40
|
+
* ### 1. The one that follows nothing
|
|
41
|
+
*
|
|
42
|
+
* ```tsx
|
|
43
|
+
* useEffect(() => {
|
|
44
|
+
* const pane = paneRef.current
|
|
45
|
+
* if (pane) pane.scrollTop = pane.scrollHeight
|
|
46
|
+
* }, []) // ← empty deps
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* That is mux-magic's auto-scroll, and it runs **once**, on mount,
|
|
50
|
+
* when the pane is empty and `scrollHeight` is `clientHeight`. Every
|
|
51
|
+
* line after it arrives below the fold. It reads as correct, it is
|
|
52
|
+
* the shape everyone writes, and the log simply never follows.
|
|
53
|
+
*
|
|
54
|
+
* ### 2. Following that cannot be escaped
|
|
55
|
+
*
|
|
56
|
+
* The naive fix — scroll to the bottom on every render — is worse:
|
|
57
|
+
* a user scrolling back to read the line that mentioned an error is
|
|
58
|
+
* yanked to the end twice a second. Following has to be *pinned to
|
|
59
|
+
* the user's own position*, which is what `isFollowing` below is: it
|
|
60
|
+
* turns off the moment the user scrolls away and back on when they
|
|
61
|
+
* return to the bottom, and there is a button for when they do not
|
|
62
|
+
* want to scroll all that way.
|
|
63
|
+
*
|
|
64
|
+
* ### 3. `data-log-id`
|
|
65
|
+
*
|
|
66
|
+
* mux-magic's pane carries one. It is a `data-testid` under a
|
|
67
|
+
* different name — a handle only the test suite can see — and this
|
|
68
|
+
* package's `sourceRules.test.ts` bans the practice rather than the
|
|
69
|
+
* spelling. The pane here is found by its `label`, the same query
|
|
70
|
+
* Playwright makes.
|
|
71
|
+
*
|
|
72
|
+
* ### 4. Announcing an hour of build output
|
|
73
|
+
*
|
|
74
|
+
* `role="log"` is the correct role and `aria-live` is off unless
|
|
75
|
+
* asked for. A live log pane emitting a line every 300 ms is a
|
|
76
|
+
* screen reader that cannot be interrupted and a user who cannot
|
|
77
|
+
* read anything else on the page — the accessible answer for a
|
|
78
|
+
* long-running job is a `LiveStatusIndicator` announcing *state*
|
|
79
|
+
* and a log that is there to be read when wanted. `isAnnounced`
|
|
80
|
+
* exists for the short case: a five-line result panel where the
|
|
81
|
+
* lines *are* the outcome.
|
|
82
|
+
*
|
|
83
|
+
* ### Not every boolean is a state kind
|
|
84
|
+
*
|
|
85
|
+
* `isFollowing` is `useState`, not `useVisibility`, and this is the
|
|
86
|
+
* first component in the library to say so out loud. The kinds in
|
|
87
|
+
* `@charcuterie/logic` earn their keep when state is **registered**
|
|
88
|
+
* (members join a group), **composed** (focus and selection
|
|
89
|
+
* disagreeing), or **shared** (a trigger and a panel at opposite
|
|
90
|
+
* ends of a tree). None of that is true here: one component reads
|
|
91
|
+
* one boolean it wrote itself. Reaching for `useVisibility` because
|
|
92
|
+
* it is also a boolean would make `isVisible` mean "is following",
|
|
93
|
+
* which is how a state layer stops meaning anything.
|
|
94
|
+
*/
|
|
95
|
+
export const LogViewer = ({
|
|
96
|
+
className,
|
|
97
|
+
isAnnounced = false,
|
|
98
|
+
label,
|
|
99
|
+
lines,
|
|
100
|
+
maxLines = 500,
|
|
101
|
+
}: LogViewerProps): ReactNode => {
|
|
102
|
+
const paneRef = useRef<HTMLDivElement>(null)
|
|
103
|
+
|
|
104
|
+
const [isFollowing, setIsFollowing] = useState(true)
|
|
105
|
+
|
|
106
|
+
const shownLines =
|
|
107
|
+
lines.length > maxLines
|
|
108
|
+
? lines.slice(lines.length - maxLines)
|
|
109
|
+
: lines
|
|
110
|
+
|
|
111
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `shownLines` is read through the DOM, not through the closure — `pane.scrollHeight` is a *consequence* of the lines having rendered, so the linter sees a dependency it cannot trace and calls it unnecessary. Removing it is not a style change: the effect then runs only when `isFollowing` flips, which is mux-magic's `}, [])` bug rebuilt exactly. `yarn lint` runs `--write --unsafe`, so this was applied silently once already and the log stopped following.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const pane = paneRef.current
|
|
114
|
+
|
|
115
|
+
if (!pane || !isFollowing) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pane.scrollTop = pane.scrollHeight
|
|
120
|
+
// `shownLines` rather than an empty array — this is the whole fix
|
|
121
|
+
// for defect 1, and the dependency *is* the fix.
|
|
122
|
+
}, [isFollowing, shownLines])
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={toClassName(
|
|
127
|
+
"relative flex flex-col",
|
|
128
|
+
className,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
<div
|
|
132
|
+
aria-label={label}
|
|
133
|
+
// `off` unless asked. See the note above; this is the
|
|
134
|
+
// difference between an accessible log and an unusable page.
|
|
135
|
+
aria-live={isAnnounced ? "polite" : "off"}
|
|
136
|
+
className="max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs"
|
|
137
|
+
onScroll={(scrollEvent) => {
|
|
138
|
+
const pane = scrollEvent.currentTarget
|
|
139
|
+
|
|
140
|
+
// A few pixels of slack, because a fractional device pixel
|
|
141
|
+
// ratio means `scrollTop + clientHeight` never lands
|
|
142
|
+
// exactly on `scrollHeight` — an equality test here
|
|
143
|
+
// un-pins the pane on a 125% display and on nothing else,
|
|
144
|
+
// which is the kind of bug that gets reported as "it works
|
|
145
|
+
// on my machine".
|
|
146
|
+
const isPinnedToBottom =
|
|
147
|
+
pane.scrollHeight -
|
|
148
|
+
pane.scrollTop -
|
|
149
|
+
pane.clientHeight <
|
|
150
|
+
4
|
|
151
|
+
|
|
152
|
+
setIsFollowing(isPinnedToBottom)
|
|
153
|
+
}}
|
|
154
|
+
ref={paneRef}
|
|
155
|
+
role="log"
|
|
156
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Chrome started doing it automatically in 2024; Firefox and Safari have not. The rule is right about `<div>`s in general and wrong about scrollers.
|
|
157
|
+
tabIndex={0}
|
|
158
|
+
>
|
|
159
|
+
{shownLines.length === 0 ? (
|
|
160
|
+
<p className="text-content-muted">
|
|
161
|
+
Waiting for output…
|
|
162
|
+
</p>
|
|
163
|
+
) : (
|
|
164
|
+
shownLines.map((line) => (
|
|
165
|
+
<p
|
|
166
|
+
className={toClassName(
|
|
167
|
+
"whitespace-pre-wrap break-all",
|
|
168
|
+
line.intent === undefined
|
|
169
|
+
? null
|
|
170
|
+
: INTENT_CONTENT_CLASS[line.intent],
|
|
171
|
+
)}
|
|
172
|
+
key={line.key}
|
|
173
|
+
>
|
|
174
|
+
{line.text}
|
|
175
|
+
</p>
|
|
176
|
+
))
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{isFollowing ? null : (
|
|
181
|
+
<Button
|
|
182
|
+
appearance="soft"
|
|
183
|
+
className="absolute inset-x-0 bottom-2 mx-auto w-fit"
|
|
184
|
+
intent="neutral"
|
|
185
|
+
onClick={() => {
|
|
186
|
+
setIsFollowing(true)
|
|
187
|
+
}}
|
|
188
|
+
size="sm"
|
|
189
|
+
>
|
|
190
|
+
Jump to latest
|
|
191
|
+
</Button>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as MenuStories from "./Menu.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={MenuStories} />
|
|
6
|
+
|
|
7
|
+
# Menu
|
|
8
|
+
|
|
9
|
+
A menu is **not** a listbox, and mux-magic's `TypePicker` is the fleet's one attempt at
|
|
10
|
+
telling them apart — it renders `role="menu"` over items that set a value, which is a
|
|
11
|
+
listbox wearing the wrong role.
|
|
12
|
+
|
|
13
|
+
<Canvas of={MenuStories.Default} />
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
<Controls />
|
|
18
|
+
|
|
19
|
+
## `menuitem` versus `option`
|
|
20
|
+
|
|
21
|
+
| | `menuitem` | `option` |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| What it is | an **action** — Retry, Skip, Eject | a **value** you are choosing |
|
|
24
|
+
| Announced as | "menu, 3 items" | "listbox, selected, 2 of 4" |
|
|
25
|
+
| Leaves state behind | no | yes |
|
|
26
|
+
| Lives in | this component | `Select` |
|
|
27
|
+
|
|
28
|
+
The last row is why it matters to an agent driving the app: after choosing an `option` it
|
|
29
|
+
needs to know what the control now holds. After invoking a `menuitem` there is nothing to
|
|
30
|
+
read back. Getting the role wrong is invisible in a browser and invisible to axe.
|
|
31
|
+
|
|
32
|
+
## A `[popover]` that was never shown is `display: none`
|
|
33
|
+
|
|
34
|
+
The panel calls `showPopover()` from its ref callback, like `Popover` does — plus an
|
|
35
|
+
`isConnected` guard and an effect that `Popover` does not need.
|
|
36
|
+
|
|
37
|
+
`FloatingFocusManager` renders a pair of focus guards around its child, so React commits this
|
|
38
|
+
subtree in an order where the ref can fire while the node is still **detached**.
|
|
39
|
+
`showPopover()` on a disconnected element throws `InvalidStateError`, which surfaces as an
|
|
40
|
+
unhandled rejection rather than a failing assertion — and if it is simply swallowed, the menu
|
|
41
|
+
renders, typechecks, and is invisible to `getByRole` and to a user alike.
|
|
42
|
+
|
|
43
|
+
The effect is the backstop, because effects run after the tree is connected. Both are
|
|
44
|
+
idempotent: `:popover-open` is the same question.
|
|
45
|
+
|
|
46
|
+
## There is no `label` prop, and that corrects a lesson
|
|
47
|
+
|
|
48
|
+
`Spinner` (M3), `Popover` and `Modal` (M4) all take a **required** name, because a
|
|
49
|
+
`role="status"` and a `role="dialog"` take none from their content. The obvious move here was
|
|
50
|
+
a `label` prop doing the same job — it was written that way first, and then measured as
|
|
51
|
+
doing **nothing**.
|
|
52
|
+
|
|
53
|
+
`useRole(context, { role: "menu" })` puts `aria-labelledby` on the panel pointing at the
|
|
54
|
+
trigger, and `aria-labelledby` beats `aria-label`. The menu was already named "Bay 3", from
|
|
55
|
+
the button, and the prop was silently discarded. That is also the APG menu-button pattern.
|
|
56
|
+
|
|
57
|
+
So the rule the other three teach is narrower than it looked:
|
|
58
|
+
|
|
59
|
+
> An overlay with **no trigger relationship** needs its own name.
|
|
60
|
+
|
|
61
|
+
A menu has one, so the requirement moves to the trigger — which `Button` and `IconButton`
|
|
62
|
+
already enforce.
|
|
63
|
+
|
|
64
|
+
## One kind, and that is the other half of the `Tabs` argument
|
|
65
|
+
|
|
66
|
+
`Tabs` needed **two** state kinds because a tab is chosen *and* focused, and in `manual`
|
|
67
|
+
activation those two disagree on purpose. A menu needs **one** — `RovingFocus` — because a
|
|
68
|
+
menu item is never selected, only invoked.
|
|
69
|
+
|
|
70
|
+
Two components, opposite conclusions, same model. That is the composition claim being paid
|
|
71
|
+
off rather than asserted.
|
|
72
|
+
|
|
73
|
+
<Canvas of={MenuStories.AllVariants} />
|
|
74
|
+
|
|
75
|
+
## Focus moves in, once
|
|
76
|
+
|
|
77
|
+
A menu is one of the few patterns where focus must jump on open, or the keyboard user who
|
|
78
|
+
pressed Enter is still on the trigger with a menu they cannot reach.
|
|
79
|
+
|
|
80
|
+
It happens in the panel's own effect rather than through `FloatingFocusManager`'s
|
|
81
|
+
`initialFocus`, because the element to focus is whichever one `RovingFocus` says is active —
|
|
82
|
+
and that is not known until the items have registered. `initialFocus={-1}` turns the
|
|
83
|
+
manager's own aim off, so there is one owner for the caret.
|
|
84
|
+
|
|
85
|
+
`modal={false}`, so Tab **leaves** the menu and lands on the next control in the page, which
|
|
86
|
+
is what closes it. A trapped menu is a dialog.
|
|
87
|
+
|
|
88
|
+
<Canvas of={MenuStories.AllStates} />
|
|
89
|
+
|
|
90
|
+
## Registration is membership
|
|
91
|
+
|
|
92
|
+
A disabled item never registers with the roving group, so the arrow keys skip it with
|
|
93
|
+
nothing in `RovingFocus` knowing what "disabled" means. It stays in the DOM and stays
|
|
94
|
+
announced — "not right now", rather than "does not exist".
|
|
95
|
+
|
|
96
|
+
## No type-ahead, deliberately
|
|
97
|
+
|
|
98
|
+
The APG lists type-ahead as **optional** for a menu and **required** for a listbox, and none
|
|
99
|
+
of the fleet's menus are long enough to need it — the largest has six items. Adding it means
|
|
100
|
+
a keystroke buffer and a timer in a component that has neither, for a menu you can cross in
|
|
101
|
+
three arrow presses.
|
|
102
|
+
|
|
103
|
+
<Canvas of={MenuStories.Interactive} />
|