@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.
- 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 +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.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 +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -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 +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- 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 +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -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 +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -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 +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { Accordion } from "../Accordion/Accordion.tsx"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
} from "../board.storyHelpers.tsx"
|
|
8
|
+
import type { LogLine } from "./LogViewer.tsx"
|
|
9
|
+
import { LogViewer } from "./LogViewer.tsx"
|
|
10
|
+
|
|
11
|
+
const RIP_LINES: LogLine[] = [
|
|
12
|
+
{
|
|
13
|
+
key: "1",
|
|
14
|
+
text: "14:02:09 Opening /dev/sr0",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: "2",
|
|
18
|
+
text: "14:02:11 AACS handshake complete, reading…",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
intent: "warning",
|
|
22
|
+
key: "3",
|
|
23
|
+
text: "14:02:44 Title 4: 2 read errors, retrying",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: "4",
|
|
27
|
+
text: "14:03:02 Title 4: recovered",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
intent: "success",
|
|
31
|
+
key: "5",
|
|
32
|
+
text: "14:31:57 9 titles written to /mnt/rips",
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Long enough to overflow the pane, which is the only way to story
|
|
38
|
+
* the behaviour that matters here.
|
|
39
|
+
*/
|
|
40
|
+
const LONG_LINES: LogLine[] = Array.from(
|
|
41
|
+
{ length: 60 },
|
|
42
|
+
(_unused, index) => ({
|
|
43
|
+
key: String(index),
|
|
44
|
+
text: `14:${String(index).padStart(2, "0")}:00 Reading sector ${index * 2048}`,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const meta = {
|
|
49
|
+
title: "Components/LogViewer",
|
|
50
|
+
component: LogViewer,
|
|
51
|
+
parameters: { layout: "padded" },
|
|
52
|
+
args: {
|
|
53
|
+
isAnnounced: false,
|
|
54
|
+
label: "Bay 3 rip log",
|
|
55
|
+
lines: RIP_LINES,
|
|
56
|
+
maxLines: 500,
|
|
57
|
+
},
|
|
58
|
+
} satisfies Meta<typeof LogViewer>
|
|
59
|
+
|
|
60
|
+
export default meta
|
|
61
|
+
|
|
62
|
+
type Story = StoryObj<typeof meta>
|
|
63
|
+
|
|
64
|
+
export const Default: Story = {
|
|
65
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const AllVariants: Story = {
|
|
69
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
70
|
+
render: () => (
|
|
71
|
+
<StoryGrid columns={2}>
|
|
72
|
+
<StoryCell label="short">
|
|
73
|
+
<LogViewer
|
|
74
|
+
label="Bay 1 rip log"
|
|
75
|
+
lines={RIP_LINES}
|
|
76
|
+
/>
|
|
77
|
+
</StoryCell>
|
|
78
|
+
|
|
79
|
+
<StoryCell label="overflowing">
|
|
80
|
+
<LogViewer
|
|
81
|
+
label="Bay 2 rip log"
|
|
82
|
+
lines={LONG_LINES}
|
|
83
|
+
/>
|
|
84
|
+
</StoryCell>
|
|
85
|
+
|
|
86
|
+
<StoryCell label="capped at 10 lines">
|
|
87
|
+
<LogViewer
|
|
88
|
+
label="Bay 4 rip log"
|
|
89
|
+
lines={LONG_LINES}
|
|
90
|
+
maxLines={10}
|
|
91
|
+
/>
|
|
92
|
+
</StoryCell>
|
|
93
|
+
|
|
94
|
+
<StoryCell label="announced">
|
|
95
|
+
<LogViewer
|
|
96
|
+
isAnnounced
|
|
97
|
+
label="Bay 5 result"
|
|
98
|
+
lines={RIP_LINES}
|
|
99
|
+
/>
|
|
100
|
+
</StoryCell>
|
|
101
|
+
</StoryGrid>
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Empty is a state, not a blank box. mux-magic's pane says
|
|
107
|
+
* "Waiting for log lines…" and rip-deck's says nothing at all,
|
|
108
|
+
* which reads as a broken subscription.
|
|
109
|
+
*/
|
|
110
|
+
export const AllStates: Story = {
|
|
111
|
+
args: { label: "Bay 3 rip log", lines: RIP_LINES },
|
|
112
|
+
render: () => (
|
|
113
|
+
<StoryGrid columns={2}>
|
|
114
|
+
<StoryCell label="empty">
|
|
115
|
+
<LogViewer label="Bay 6 rip log" lines={[]} />
|
|
116
|
+
</StoryCell>
|
|
117
|
+
|
|
118
|
+
<StoryCell label="with intents">
|
|
119
|
+
<LogViewer
|
|
120
|
+
label="Bay 7 rip log"
|
|
121
|
+
lines={RIP_LINES}
|
|
122
|
+
/>
|
|
123
|
+
</StoryCell>
|
|
124
|
+
</StoryGrid>
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scroll the tall pane up and the **Jump to latest** button
|
|
130
|
+
* appears; scroll back to the bottom and it goes away. Following is
|
|
131
|
+
* pinned to the user's own position, which is the difference between
|
|
132
|
+
* a log that follows and one that fights you.
|
|
133
|
+
*/
|
|
134
|
+
export const Interactive: Story = {
|
|
135
|
+
args: { label: "Bay 8 rip log", lines: LONG_LINES },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pane mounted inside a **collapsed** `Accordion` section, which
|
|
140
|
+
* is how the fleet actually renders a log and is the one arrangement
|
|
141
|
+
* where following used to fail.
|
|
142
|
+
*
|
|
143
|
+
* `AccordionSection` renders its panel with `hidden`, deliberately,
|
|
144
|
+
* so the pane keeps its scroll position and its subscriptions across
|
|
145
|
+
* a collapse. A `hidden` subtree has no layout, so a pane that
|
|
146
|
+
* measures itself on mount measures `scrollHeight 0` — and neither
|
|
147
|
+
* `isFollowing` nor `lines` changes when the section is opened.
|
|
148
|
+
*
|
|
149
|
+
* Expand it: the log opens on its **last** line, not its first.
|
|
150
|
+
*/
|
|
151
|
+
export const InsideDisclosure: Story = {
|
|
152
|
+
args: { label: "Bay 9 rip log", lines: LONG_LINES },
|
|
153
|
+
render: () => (
|
|
154
|
+
<Accordion
|
|
155
|
+
items={[
|
|
156
|
+
{
|
|
157
|
+
content: (
|
|
158
|
+
<LogViewer
|
|
159
|
+
label="Bay 9 rip log"
|
|
160
|
+
lines={LONG_LINES}
|
|
161
|
+
/>
|
|
162
|
+
),
|
|
163
|
+
key: "logs",
|
|
164
|
+
label: "Bay 9 output",
|
|
165
|
+
},
|
|
166
|
+
]}
|
|
167
|
+
/>
|
|
168
|
+
),
|
|
169
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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 {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Default,
|
|
14
|
+
InsideDisclosure,
|
|
15
|
+
Interactive,
|
|
16
|
+
} = composeStories(stories)
|
|
17
|
+
|
|
18
|
+
test("the pane is named, and is a log", async () => {
|
|
19
|
+
const { canvas, canvasElement } =
|
|
20
|
+
await mountStory(Default)
|
|
21
|
+
|
|
22
|
+
// Found by its name, not by a `data-log-id`. mux-magic's pane
|
|
23
|
+
// carries one of those — a `data-testid` under a different name,
|
|
24
|
+
// which this package's `sourceRules.test.ts` bans as a practice
|
|
25
|
+
// rather than as a spelling.
|
|
26
|
+
const pane = expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Bay 3 rip log",
|
|
28
|
+
role: "log",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
await expect(pane).toHaveTextContent(
|
|
32
|
+
"AACS handshake complete",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
await expectNoAxeViolations(canvasElement)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The fix for mux-magic's defect, asserted as the behaviour rather
|
|
40
|
+
* than as the precondition. Its auto-scroll runs in an effect with
|
|
41
|
+
* **empty deps** — once, on mount, when the pane is empty and
|
|
42
|
+
* `scrollHeight` is `clientHeight` — so every line after it arrives
|
|
43
|
+
* below the fold.
|
|
44
|
+
*/
|
|
45
|
+
test("a full pane is scrolled to the bottom", async () => {
|
|
46
|
+
const { canvas } = await mountStory(Interactive)
|
|
47
|
+
|
|
48
|
+
const pane = expectAgentDrivable(canvas, {
|
|
49
|
+
name: "Bay 8 rip log",
|
|
50
|
+
role: "log",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The precondition — that there *is* something to scroll. On its
|
|
54
|
+
// own this proves nothing, which is the lesson `Tabs` learned the
|
|
55
|
+
// expensive way from `scrollWidth > clientWidth`.
|
|
56
|
+
await expect(pane.scrollHeight).toBeGreaterThan(
|
|
57
|
+
pane.clientHeight,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// And the behaviour: it is actually at the end.
|
|
61
|
+
await waitFor(() => {
|
|
62
|
+
expect(
|
|
63
|
+
pane.scrollHeight -
|
|
64
|
+
pane.scrollTop -
|
|
65
|
+
pane.clientHeight,
|
|
66
|
+
).toBeLessThan(4)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The same defect, rebuilt out of two components that are each
|
|
72
|
+
* individually right — and the reason `LogViewer` watches its own box
|
|
73
|
+
* rather than trusting one mount measurement.
|
|
74
|
+
*
|
|
75
|
+
* `AccordionSection` renders `hidden` rather than unmounting, because
|
|
76
|
+
* the fleet's panels hold log panes and an unmounted panel loses its
|
|
77
|
+
* scroll position and its subscription. A `hidden` subtree has no
|
|
78
|
+
* layout box, so the mount effect reads `scrollHeight 0` and writes
|
|
79
|
+
* `scrollTop = 0` — and the effect's dependencies, `isFollowing` and
|
|
80
|
+
* the lines, are both unchanged by the reveal. Measured in mux-magic
|
|
81
|
+
* on a 60-line pane:
|
|
82
|
+
*
|
|
83
|
+
* ```
|
|
84
|
+
* while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
85
|
+
* after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* mux-magic worked around it downstream, in `DisclosedLogViewer`, by
|
|
89
|
+
* not mounting the pane until the section had been opened once. This
|
|
90
|
+
* test is that workaround becoming deletable.
|
|
91
|
+
*/
|
|
92
|
+
test("a pane revealed by a disclosure follows the tail", async () => {
|
|
93
|
+
const { canvas } = await mountStory(InsideDisclosure)
|
|
94
|
+
|
|
95
|
+
// `hidden: true` because that is the whole precondition: the pane
|
|
96
|
+
// is in the accessibility tree's shadow, and in the layout tree not
|
|
97
|
+
// at all.
|
|
98
|
+
const pane = canvas.getByRole("log", {
|
|
99
|
+
hidden: true,
|
|
100
|
+
name: "Bay 9 rip log",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Not a rhetorical assertion. If a future `AccordionSection`
|
|
104
|
+
// unmounts its panel instead, or stops using `hidden`, this test
|
|
105
|
+
// silently stops testing anything — so it fails here instead.
|
|
106
|
+
await expect(pane.scrollHeight).toBe(0)
|
|
107
|
+
|
|
108
|
+
expectAgentDrivable(canvas, {
|
|
109
|
+
name: "Bay 9 output",
|
|
110
|
+
role: "button",
|
|
111
|
+
}).click()
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(pane.scrollHeight).toBeGreaterThan(
|
|
115
|
+
pane.clientHeight,
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(
|
|
121
|
+
pane.scrollHeight -
|
|
122
|
+
pane.scrollTop -
|
|
123
|
+
pane.clientHeight,
|
|
124
|
+
).toBeLessThan(4)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Following is pinned to the user's own position. Scrolling back to
|
|
130
|
+
* read the line that mentioned an error must not be undone twice a
|
|
131
|
+
* second — which is what the naive fix (scroll to bottom on every
|
|
132
|
+
* render) does.
|
|
133
|
+
*/
|
|
134
|
+
test("scrolling away stops the follow and offers a way back", async () => {
|
|
135
|
+
const { canvas } = await mountStory(Interactive)
|
|
136
|
+
|
|
137
|
+
const pane = expectAgentDrivable(canvas, {
|
|
138
|
+
name: "Bay 8 rip log",
|
|
139
|
+
role: "log",
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
canvas.queryAllByRole("button", {
|
|
144
|
+
name: "Jump to latest",
|
|
145
|
+
}),
|
|
146
|
+
).toHaveLength(0)
|
|
147
|
+
|
|
148
|
+
pane.scrollTop = 0
|
|
149
|
+
|
|
150
|
+
pane.dispatchEvent(new Event("scroll", { bubbles: true }))
|
|
151
|
+
|
|
152
|
+
const jump = await waitFor(() =>
|
|
153
|
+
expectAgentDrivable(canvas, {
|
|
154
|
+
name: "Jump to latest",
|
|
155
|
+
role: "button",
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
jump.click()
|
|
160
|
+
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
expect(
|
|
163
|
+
pane.scrollHeight -
|
|
164
|
+
pane.scrollTop -
|
|
165
|
+
pane.clientHeight,
|
|
166
|
+
).toBeLessThan(4)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A live log emitting a line every 300 ms is a screen reader that
|
|
172
|
+
* cannot be interrupted. `role="log"` is correct; `aria-live` is off
|
|
173
|
+
* unless the lines *are* the outcome.
|
|
174
|
+
*/
|
|
175
|
+
test("a log does not announce itself unless asked", async () => {
|
|
176
|
+
const { canvas } = await mountStory(AllVariants)
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
expectAgentDrivable(canvas, {
|
|
180
|
+
name: "Bay 1 rip log",
|
|
181
|
+
role: "log",
|
|
182
|
+
}),
|
|
183
|
+
).toHaveAttribute("aria-live", "off")
|
|
184
|
+
|
|
185
|
+
await expect(
|
|
186
|
+
expectAgentDrivable(canvas, {
|
|
187
|
+
name: "Bay 5 result",
|
|
188
|
+
role: "log",
|
|
189
|
+
}),
|
|
190
|
+
).toHaveAttribute("aria-live", "polite")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("maxLines drops the oldest lines from the DOM", async () => {
|
|
194
|
+
const { canvas } = await mountStory(AllVariants)
|
|
195
|
+
|
|
196
|
+
const capped = expectAgentDrivable(canvas, {
|
|
197
|
+
name: "Bay 4 rip log",
|
|
198
|
+
role: "log",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// A rip emits a line every few hundred milliseconds for an hour,
|
|
202
|
+
// and the browser keeps every node.
|
|
203
|
+
await expect(capped.querySelectorAll("p")).toHaveLength(
|
|
204
|
+
10,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// The *last* ten, not the first — a log you have to scroll up
|
|
208
|
+
// through to reach the newest line is backwards.
|
|
209
|
+
await expect(capped).toHaveTextContent(
|
|
210
|
+
"Reading sector 120832",
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("a scrollable pane is reachable by keyboard", async () => {
|
|
215
|
+
const { canvas, canvasElement } =
|
|
216
|
+
await mountStory(AllStates)
|
|
217
|
+
|
|
218
|
+
// Chrome made scrollers focusable automatically in 2024; Firefox
|
|
219
|
+
// and Safari have not. Without it a keyboard user can see the pane
|
|
220
|
+
// and cannot scroll it.
|
|
221
|
+
await expect(
|
|
222
|
+
expectAgentDrivable(canvas, {
|
|
223
|
+
name: "Bay 7 rip log",
|
|
224
|
+
role: "log",
|
|
225
|
+
}).tabIndex,
|
|
226
|
+
).toBe(0)
|
|
227
|
+
|
|
228
|
+
await expect(
|
|
229
|
+
expectAgentDrivable(canvas, {
|
|
230
|
+
name: "Bay 6 rip log",
|
|
231
|
+
role: "log",
|
|
232
|
+
}),
|
|
233
|
+
).toHaveTextContent("Waiting for output…")
|
|
234
|
+
|
|
235
|
+
await expectNoAxeViolations(canvasElement)
|
|
236
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
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. A fifth, below the effects, is not
|
|
39
|
+
* theirs: it is defect 1 coming back through the front door, in this
|
|
40
|
+
* component, found by the first app to consume it.
|
|
41
|
+
*
|
|
42
|
+
* ### 1. The one that follows nothing
|
|
43
|
+
*
|
|
44
|
+
* ```tsx
|
|
45
|
+
* useEffect(() => {
|
|
46
|
+
* const pane = paneRef.current
|
|
47
|
+
* if (pane) pane.scrollTop = pane.scrollHeight
|
|
48
|
+
* }, []) // ← empty deps
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* That is mux-magic's auto-scroll, and it runs **once**, on mount,
|
|
52
|
+
* when the pane is empty and `scrollHeight` is `clientHeight`. Every
|
|
53
|
+
* line after it arrives below the fold. It reads as correct, it is
|
|
54
|
+
* the shape everyone writes, and the log simply never follows.
|
|
55
|
+
*
|
|
56
|
+
* ### 2. Following that cannot be escaped
|
|
57
|
+
*
|
|
58
|
+
* The naive fix — scroll to the bottom on every render — is worse:
|
|
59
|
+
* a user scrolling back to read the line that mentioned an error is
|
|
60
|
+
* yanked to the end twice a second. Following has to be *pinned to
|
|
61
|
+
* the user's own position*, which is what `isFollowing` below is: it
|
|
62
|
+
* turns off the moment the user scrolls away and back on when they
|
|
63
|
+
* return to the bottom, and there is a button for when they do not
|
|
64
|
+
* want to scroll all that way.
|
|
65
|
+
*
|
|
66
|
+
* ### 3. `data-log-id`
|
|
67
|
+
*
|
|
68
|
+
* mux-magic's pane carries one. It is a `data-testid` under a
|
|
69
|
+
* different name — a handle only the test suite can see — and this
|
|
70
|
+
* package's `sourceRules.test.ts` bans the practice rather than the
|
|
71
|
+
* spelling. The pane here is found by its `label`, the same query
|
|
72
|
+
* Playwright makes.
|
|
73
|
+
*
|
|
74
|
+
* ### 4. Announcing an hour of build output
|
|
75
|
+
*
|
|
76
|
+
* `role="log"` is the correct role and `aria-live` is off unless
|
|
77
|
+
* asked for. A live log pane emitting a line every 300 ms is a
|
|
78
|
+
* screen reader that cannot be interrupted and a user who cannot
|
|
79
|
+
* read anything else on the page — the accessible answer for a
|
|
80
|
+
* long-running job is a `LiveStatusIndicator` announcing *state*
|
|
81
|
+
* and a log that is there to be read when wanted. `isAnnounced`
|
|
82
|
+
* exists for the short case: a five-line result panel where the
|
|
83
|
+
* lines *are* the outcome.
|
|
84
|
+
*
|
|
85
|
+
* ### Not every boolean is a state kind
|
|
86
|
+
*
|
|
87
|
+
* `isFollowing` is `useState`, not `useVisibility`, and this is the
|
|
88
|
+
* first component in the library to say so out loud. The kinds in
|
|
89
|
+
* `@charcuterie/logic` earn their keep when state is **registered**
|
|
90
|
+
* (members join a group), **composed** (focus and selection
|
|
91
|
+
* disagreeing), or **shared** (a trigger and a panel at opposite
|
|
92
|
+
* ends of a tree). None of that is true here: one component reads
|
|
93
|
+
* one boolean it wrote itself. Reaching for `useVisibility` because
|
|
94
|
+
* it is also a boolean would make `isVisible` mean "is following",
|
|
95
|
+
* which is how a state layer stops meaning anything.
|
|
96
|
+
*/
|
|
97
|
+
export const LogViewer = ({
|
|
98
|
+
className,
|
|
99
|
+
isAnnounced = false,
|
|
100
|
+
label,
|
|
101
|
+
lines,
|
|
102
|
+
maxLines = 500,
|
|
103
|
+
}: LogViewerProps): ReactNode => {
|
|
104
|
+
const paneRef = useRef<HTMLDivElement>(null)
|
|
105
|
+
|
|
106
|
+
const [isFollowing, setIsFollowing] = useState(true)
|
|
107
|
+
|
|
108
|
+
const shownLines =
|
|
109
|
+
lines.length > maxLines
|
|
110
|
+
? lines.slice(lines.length - maxLines)
|
|
111
|
+
: lines
|
|
112
|
+
|
|
113
|
+
// 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.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const pane = paneRef.current
|
|
116
|
+
|
|
117
|
+
if (!pane || !isFollowing) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pane.scrollTop = pane.scrollHeight
|
|
122
|
+
// `shownLines` rather than an empty array — this is the whole fix
|
|
123
|
+
// for defect 1, and the dependency *is* the fix.
|
|
124
|
+
}, [isFollowing, shownLines])
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Defect 1 again, and the effect above cannot see it: **a pane can
|
|
128
|
+
* be measured before it has a box.**
|
|
129
|
+
*
|
|
130
|
+
* `AccordionSection` renders its panel with `hidden` rather than
|
|
131
|
+
* unmounting it, deliberately — "a panel that unmounts loses a
|
|
132
|
+
* scroll position, a partially typed form, and any subscription its
|
|
133
|
+
* content opened — and the fleet's log panes are exactly that." A
|
|
134
|
+
* `hidden` subtree has no layout, so the effect above runs on mount
|
|
135
|
+
* with `scrollHeight 0` and writes `scrollTop = 0`, and then never
|
|
136
|
+
* runs again: neither `isFollowing` nor `shownLines` changes when
|
|
137
|
+
* the section is opened. Measured in mux-magic on a 60-line pane:
|
|
138
|
+
*
|
|
139
|
+
* ```
|
|
140
|
+
* while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
141
|
+
* after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* That is this component's own `}, [])` bug, rebuilt out of two
|
|
145
|
+
* components whose individual decisions are both right, and
|
|
146
|
+
* invisible to both of their test suites — `LogViewer`'s mounted
|
|
147
|
+
* visible, `Accordion`'s holding content that never measures
|
|
148
|
+
* itself. mux-magic hit it in its first week as a consumer and
|
|
149
|
+
* worked around it downstream, in a `DisclosedLogViewer` that
|
|
150
|
+
* withheld the pane until the section had been opened once. This is
|
|
151
|
+
* that workaround coming into the library so it can be deleted.
|
|
152
|
+
*
|
|
153
|
+
* ### Why the box and not the viewport
|
|
154
|
+
*
|
|
155
|
+
* An `IntersectionObserver` answers "is it on screen", which is a
|
|
156
|
+
* different question with two wrong answers here: a pane below the
|
|
157
|
+
* fold on a long page is *not* intersecting and has perfectly good
|
|
158
|
+
* layout, and scrolling the page later would re-fire for no reason.
|
|
159
|
+
* `ResizeObserver` answers "does it have a box, and is that box
|
|
160
|
+
* still the same size" — which is the precondition the measurement
|
|
161
|
+
* above actually needs. Per spec it does not fire at `observe()`
|
|
162
|
+
* time for an element that is not being rendered, so gaining a box
|
|
163
|
+
* *is* the first callback, and that is the reveal.
|
|
164
|
+
*
|
|
165
|
+
* It pays for itself twice: a window resize re-wraps the lines and
|
|
166
|
+
* changes what is at the bottom, and a following pane should be at
|
|
167
|
+
* the new bottom rather than a few lines above it.
|
|
168
|
+
*
|
|
169
|
+
* Only while following. A user who scrolled up to read an error is
|
|
170
|
+
* not dragged back by resizing their window.
|
|
171
|
+
*/
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const pane = paneRef.current
|
|
174
|
+
|
|
175
|
+
if (!pane || !isFollowing) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const observer = new ResizeObserver(() => {
|
|
180
|
+
pane.scrollTop = pane.scrollHeight
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
observer.observe(pane)
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
observer.disconnect()
|
|
187
|
+
}
|
|
188
|
+
}, [isFollowing])
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
className={toClassName(
|
|
193
|
+
"relative flex flex-col",
|
|
194
|
+
className,
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
<div
|
|
198
|
+
aria-label={label}
|
|
199
|
+
// `off` unless asked. See the note above; this is the
|
|
200
|
+
// difference between an accessible log and an unusable page.
|
|
201
|
+
aria-live={isAnnounced ? "polite" : "off"}
|
|
202
|
+
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"
|
|
203
|
+
onScroll={(scrollEvent) => {
|
|
204
|
+
const pane = scrollEvent.currentTarget
|
|
205
|
+
|
|
206
|
+
// A few pixels of slack, because a fractional device pixel
|
|
207
|
+
// ratio means `scrollTop + clientHeight` never lands
|
|
208
|
+
// exactly on `scrollHeight` — an equality test here
|
|
209
|
+
// un-pins the pane on a 125% display and on nothing else,
|
|
210
|
+
// which is the kind of bug that gets reported as "it works
|
|
211
|
+
// on my machine".
|
|
212
|
+
const isPinnedToBottom =
|
|
213
|
+
pane.scrollHeight -
|
|
214
|
+
pane.scrollTop -
|
|
215
|
+
pane.clientHeight <
|
|
216
|
+
4
|
|
217
|
+
|
|
218
|
+
setIsFollowing(isPinnedToBottom)
|
|
219
|
+
}}
|
|
220
|
+
ref={paneRef}
|
|
221
|
+
role="log"
|
|
222
|
+
// 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.
|
|
223
|
+
tabIndex={0}
|
|
224
|
+
>
|
|
225
|
+
{shownLines.length === 0 ? (
|
|
226
|
+
<p className="text-content-muted">
|
|
227
|
+
Waiting for output…
|
|
228
|
+
</p>
|
|
229
|
+
) : (
|
|
230
|
+
shownLines.map((line) => (
|
|
231
|
+
<p
|
|
232
|
+
className={toClassName(
|
|
233
|
+
"whitespace-pre-wrap break-all",
|
|
234
|
+
line.intent === undefined
|
|
235
|
+
? null
|
|
236
|
+
: INTENT_CONTENT_CLASS[line.intent],
|
|
237
|
+
)}
|
|
238
|
+
key={line.key}
|
|
239
|
+
>
|
|
240
|
+
{line.text}
|
|
241
|
+
</p>
|
|
242
|
+
))
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{isFollowing ? null : (
|
|
247
|
+
<Button
|
|
248
|
+
appearance="soft"
|
|
249
|
+
className="absolute inset-x-0 bottom-2 mx-auto w-fit"
|
|
250
|
+
intent="neutral"
|
|
251
|
+
onClick={() => {
|
|
252
|
+
setIsFollowing(true)
|
|
253
|
+
}}
|
|
254
|
+
size="sm"
|
|
255
|
+
>
|
|
256
|
+
Jump to latest
|
|
257
|
+
</Button>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|