@charcuterie/ui 0.1.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/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- package/src/tokens.ts +16 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status → intent join, and the reachability of the machines it
|
|
3
|
+
* reads.
|
|
4
|
+
*
|
|
5
|
+
* The interesting assertions here are not "connected is green" —
|
|
6
|
+
* they are the two things that make a shared machine better than the
|
|
7
|
+
* `Record<string, string>` it replaces: **every** state has a colour
|
|
8
|
+
* and a wording, and every state is actually reachable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AsyncStatus,
|
|
13
|
+
ConnectionStatus,
|
|
14
|
+
} from "@charcuterie/logic"
|
|
15
|
+
import {
|
|
16
|
+
asyncTransitions,
|
|
17
|
+
connectionTransitions,
|
|
18
|
+
getUnreachableStates,
|
|
19
|
+
} from "@charcuterie/logic"
|
|
20
|
+
import { expect, test } from "vitest"
|
|
21
|
+
|
|
22
|
+
import { mediaTransitions } from "./MediaTile/mediaStatus.ts"
|
|
23
|
+
import {
|
|
24
|
+
getAsyncIntent,
|
|
25
|
+
getAsyncLabel,
|
|
26
|
+
getConnectionIntent,
|
|
27
|
+
getConnectionLabel,
|
|
28
|
+
getIsConnectionBusy,
|
|
29
|
+
} from "./statusIntent.ts"
|
|
30
|
+
|
|
31
|
+
const connectionStatuses = Object.keys(
|
|
32
|
+
connectionTransitions,
|
|
33
|
+
) as ConnectionStatus[]
|
|
34
|
+
|
|
35
|
+
const asyncStatuses = Object.keys(
|
|
36
|
+
asyncTransitions,
|
|
37
|
+
) as AsyncStatus[]
|
|
38
|
+
|
|
39
|
+
test("every connection state has an intent and a wording", () => {
|
|
40
|
+
// mux-magic's `statusClassMap` is keyed by `string`, so an
|
|
41
|
+
// unrecognised status renders with no colour at all. The
|
|
42
|
+
// exhaustive switch makes that a compile error; this makes it a
|
|
43
|
+
// test failure too, for the JS consumers who have no compiler.
|
|
44
|
+
for (const status of connectionStatuses) {
|
|
45
|
+
expect(getConnectionIntent(status)).toBeTruthy()
|
|
46
|
+
|
|
47
|
+
expect(getConnectionLabel(status)).toBeTruthy()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(connectionStatuses).toHaveLength(4)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("reconnecting is not the same as connecting", () => {
|
|
54
|
+
// The distinction the whole shared machine exists for, and the one
|
|
55
|
+
// all four repos currently lose.
|
|
56
|
+
expect(getConnectionIntent("connecting")).toBe("info")
|
|
57
|
+
|
|
58
|
+
expect(getConnectionIntent("reconnecting")).toBe(
|
|
59
|
+
"warning",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(getConnectionLabel("connecting")).not.toBe(
|
|
63
|
+
getConnectionLabel("reconnecting"),
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("only the in-flight states animate", () => {
|
|
68
|
+
expect(getIsConnectionBusy("connecting")).toBe(true)
|
|
69
|
+
|
|
70
|
+
expect(getIsConnectionBusy("reconnecting")).toBe(true)
|
|
71
|
+
|
|
72
|
+
expect(getIsConnectionBusy("connected")).toBe(false)
|
|
73
|
+
|
|
74
|
+
expect(getIsConnectionBusy("disconnected")).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("connection wordings are distinct", () => {
|
|
78
|
+
// Two states that read identically on a wall-mounted panel are two
|
|
79
|
+
// states the user cannot tell apart, which defeats the point.
|
|
80
|
+
const labels = connectionStatuses.map(getConnectionLabel)
|
|
81
|
+
|
|
82
|
+
expect(new Set(labels).size).toBe(labels.length)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("every async state has an intent and a wording", () => {
|
|
86
|
+
for (const status of asyncStatuses) {
|
|
87
|
+
expect(getAsyncIntent(status)).toBeTruthy()
|
|
88
|
+
|
|
89
|
+
expect(getAsyncLabel(status)).toBeTruthy()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
new Set(asyncStatuses.map(getAsyncLabel)).size,
|
|
94
|
+
).toBe(asyncStatuses.length)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("only failure is red", () => {
|
|
98
|
+
expect(getAsyncIntent("error")).toBe("danger")
|
|
99
|
+
|
|
100
|
+
expect(getAsyncIntent("idle")).toBe("neutral")
|
|
101
|
+
|
|
102
|
+
expect(getAsyncIntent("success")).toBe("success")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("no state of any machine this package renders is unreachable", () => {
|
|
106
|
+
// A `failed` nobody can transition to is a badge variant that will
|
|
107
|
+
// never render — almost always a typo in a transition table. The
|
|
108
|
+
// check belongs to the *definition*, so each machine's consumer
|
|
109
|
+
// runs it.
|
|
110
|
+
expect(
|
|
111
|
+
getUnreachableStates(
|
|
112
|
+
connectionTransitions,
|
|
113
|
+
"connecting",
|
|
114
|
+
),
|
|
115
|
+
).toEqual([])
|
|
116
|
+
|
|
117
|
+
expect(
|
|
118
|
+
getUnreachableStates(asyncTransitions, "idle"),
|
|
119
|
+
).toEqual([])
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
getUnreachableStates(mediaTransitions, "loading"),
|
|
123
|
+
).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("a loaded image cannot become an error", () => {
|
|
127
|
+
// MediaTile's one deliberate omission. A browser does not fire
|
|
128
|
+
// `error` on an image it has already decoded, so an app that
|
|
129
|
+
// thinks it saw that is misreading its own event wiring — and a
|
|
130
|
+
// fallback flickering over a good poster is worse than a throw.
|
|
131
|
+
expect(mediaTransitions.loaded).not.toContain("error")
|
|
132
|
+
|
|
133
|
+
expect(mediaTransitions.loaded).toContain("loading")
|
|
134
|
+
|
|
135
|
+
expect(mediaTransitions.error).toContain("loading")
|
|
136
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two shared machines → an intent and a human label.
|
|
3
|
+
*
|
|
4
|
+
* This is the join M2 promised and the reason `Status` earned its
|
|
5
|
+
* place as a fifth state kind. `Badge` and `LiveStatusIndicator`
|
|
6
|
+
* consume `connectionTransitions` / `asyncTransitions` through
|
|
7
|
+
* these functions, so a connection state has **one** colour and
|
|
8
|
+
* **one** wording across the fleet — against four repos that each
|
|
9
|
+
* spell the same four states differently today, and one
|
|
10
|
+
* (`mux-magic`) whose `statusClassMap` is a
|
|
11
|
+
* `Record<string, string>` that silently renders an unknown status
|
|
12
|
+
* with no colour at all.
|
|
13
|
+
*
|
|
14
|
+
* Every switch here ends in `assertNeverStatus`, which is the whole
|
|
15
|
+
* point: add a state to `connectionTransitions` and this file stops
|
|
16
|
+
* compiling. A `Record<string, string>` would have shipped a blank
|
|
17
|
+
* badge instead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
AsyncStatus,
|
|
22
|
+
ConnectionStatus,
|
|
23
|
+
} from "@charcuterie/logic"
|
|
24
|
+
import { assertNeverStatus } from "@charcuterie/logic"
|
|
25
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
26
|
+
|
|
27
|
+
export const getConnectionIntent = (
|
|
28
|
+
status: ConnectionStatus,
|
|
29
|
+
): IntentName => {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "connected":
|
|
32
|
+
return "success"
|
|
33
|
+
|
|
34
|
+
case "connecting":
|
|
35
|
+
return "info"
|
|
36
|
+
|
|
37
|
+
// Warning rather than danger, and this is the distinction the
|
|
38
|
+
// shared machine exists for: a user who has seen data wants
|
|
39
|
+
// "lost it, getting it back", not the cold-start spinner they
|
|
40
|
+
// already sat through, and not the red of a dead link either.
|
|
41
|
+
case "reconnecting":
|
|
42
|
+
return "warning"
|
|
43
|
+
|
|
44
|
+
case "disconnected":
|
|
45
|
+
return "danger"
|
|
46
|
+
|
|
47
|
+
default:
|
|
48
|
+
return assertNeverStatus(status)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The ellipsis is a real `…`, not three dots, and the trailing one
|
|
54
|
+
* is load-bearing: it is what makes "Reconnecting…" read as
|
|
55
|
+
* in-progress to someone who glances at a wall-mounted panel from
|
|
56
|
+
* across the room.
|
|
57
|
+
*/
|
|
58
|
+
export const getConnectionLabel = (
|
|
59
|
+
status: ConnectionStatus,
|
|
60
|
+
): string => {
|
|
61
|
+
switch (status) {
|
|
62
|
+
case "connected":
|
|
63
|
+
return "Connected"
|
|
64
|
+
|
|
65
|
+
case "connecting":
|
|
66
|
+
return "Connecting…"
|
|
67
|
+
|
|
68
|
+
case "reconnecting":
|
|
69
|
+
return "Reconnecting…"
|
|
70
|
+
|
|
71
|
+
case "disconnected":
|
|
72
|
+
return "Disconnected"
|
|
73
|
+
|
|
74
|
+
default:
|
|
75
|
+
return assertNeverStatus(status)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether the state is one a moving affordance should mark. */
|
|
80
|
+
export const getIsConnectionBusy = (
|
|
81
|
+
status: ConnectionStatus,
|
|
82
|
+
): boolean => {
|
|
83
|
+
switch (status) {
|
|
84
|
+
case "connecting":
|
|
85
|
+
case "reconnecting":
|
|
86
|
+
return true
|
|
87
|
+
|
|
88
|
+
case "connected":
|
|
89
|
+
case "disconnected":
|
|
90
|
+
return false
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
return assertNeverStatus(status)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const getAsyncIntent = (
|
|
98
|
+
status: AsyncStatus,
|
|
99
|
+
): IntentName => {
|
|
100
|
+
switch (status) {
|
|
101
|
+
case "idle":
|
|
102
|
+
return "neutral"
|
|
103
|
+
|
|
104
|
+
case "loading":
|
|
105
|
+
return "info"
|
|
106
|
+
|
|
107
|
+
case "success":
|
|
108
|
+
return "success"
|
|
109
|
+
|
|
110
|
+
case "error":
|
|
111
|
+
return "danger"
|
|
112
|
+
|
|
113
|
+
default:
|
|
114
|
+
return assertNeverStatus(status)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const getAsyncLabel = (
|
|
119
|
+
status: AsyncStatus,
|
|
120
|
+
): string => {
|
|
121
|
+
switch (status) {
|
|
122
|
+
case "idle":
|
|
123
|
+
return "Idle"
|
|
124
|
+
|
|
125
|
+
case "loading":
|
|
126
|
+
return "Loading…"
|
|
127
|
+
|
|
128
|
+
case "success":
|
|
129
|
+
return "Done"
|
|
130
|
+
|
|
131
|
+
case "error":
|
|
132
|
+
return "Failed"
|
|
133
|
+
|
|
134
|
+
default:
|
|
135
|
+
return assertNeverStatus(status)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that would have caught image 4 of the M4 review: a `size`
|
|
3
|
+
* prop rendering as a `{}` JSON textarea in the Controls panel,
|
|
4
|
+
* because Storybook could not see that `ControlSize` is three
|
|
5
|
+
* strings.
|
|
6
|
+
*
|
|
7
|
+
* `react-docgen` follows **relative** imports and stops at bare
|
|
8
|
+
* package specifiers. So a prop typed from `../intentStyles.ts`
|
|
9
|
+
* arrives as a resolved union and gets a radio for free, while the
|
|
10
|
+
* same prop typed from `@charcuterie/tokens` arrives as an opaque
|
|
11
|
+
* name — and Storybook's fallback for an unknown type is the object
|
|
12
|
+
* control. Nothing errors. The story renders. The control is just
|
|
13
|
+
* wrong, in a way only a human opening that panel will notice.
|
|
14
|
+
*
|
|
15
|
+
* So: a prop whose type comes from a bare specifier must have an
|
|
16
|
+
* explicit `argTypes` entry in its story.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
20
|
+
import { join, resolve } from "node:path"
|
|
21
|
+
|
|
22
|
+
import { expect, test } from "vitest"
|
|
23
|
+
|
|
24
|
+
const sourceDirectory = resolve(import.meta.dirname)
|
|
25
|
+
|
|
26
|
+
const readComponent = async (name: string) => {
|
|
27
|
+
const [component, story] = await Promise.all([
|
|
28
|
+
readFile(
|
|
29
|
+
join(sourceDirectory, name, `${name}.tsx`),
|
|
30
|
+
"utf8",
|
|
31
|
+
),
|
|
32
|
+
readFile(
|
|
33
|
+
join(sourceDirectory, name, `${name}.stories.tsx`),
|
|
34
|
+
"utf8",
|
|
35
|
+
),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
return { component, name, story }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const componentNames = (
|
|
42
|
+
await readdir(sourceDirectory, { withFileTypes: true })
|
|
43
|
+
)
|
|
44
|
+
.filter(
|
|
45
|
+
(entry) =>
|
|
46
|
+
entry.isDirectory() && /^[A-Z]/.test(entry.name),
|
|
47
|
+
)
|
|
48
|
+
.map((entry) => entry.name)
|
|
49
|
+
.sort()
|
|
50
|
+
|
|
51
|
+
const components = await Promise.all(
|
|
52
|
+
componentNames.map(readComponent),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `import type { A, B } from "@scope/pkg"` — bare specifiers only.
|
|
57
|
+
* A relative one is exactly the case docgen *can* resolve.
|
|
58
|
+
*/
|
|
59
|
+
const getForeignTypeNames = (source: string) =>
|
|
60
|
+
new Set(
|
|
61
|
+
Array.from(
|
|
62
|
+
source.matchAll(
|
|
63
|
+
/import type \{([^}]+)\} from "([^".][^"]*)"/g,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
.filter(
|
|
67
|
+
([, , specifier]) => !specifier?.startsWith("."),
|
|
68
|
+
)
|
|
69
|
+
.flatMap(([, names]) =>
|
|
70
|
+
(names ?? "")
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((one) => one.trim())
|
|
73
|
+
.filter(Boolean),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const getPropsBlock = (source: string) =>
|
|
78
|
+
/export type \w+Props =[\s\S]*?\n\}/.exec(source)?.[0] ??
|
|
79
|
+
""
|
|
80
|
+
|
|
81
|
+
test("every component's props are declared where a story can find them", () => {
|
|
82
|
+
// A canary on the derivation: if the directory scan ever comes
|
|
83
|
+
// back empty this whole file passes forever.
|
|
84
|
+
expect(componentNames).toContain("Button")
|
|
85
|
+
expect(componentNames.length).toBeGreaterThanOrEqual(14)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("a prop typed from another package has an explicit control", () => {
|
|
89
|
+
const offenders = components.flatMap(
|
|
90
|
+
({ component, name, story }) => {
|
|
91
|
+
const foreignTypes = getForeignTypeNames(component)
|
|
92
|
+
|
|
93
|
+
if (foreignTypes.size === 0) {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `intent?: IntentName` / `size?: ControlSize`. React's own
|
|
98
|
+
// types are excluded: `ReactNode` and friends are not
|
|
99
|
+
// enumerable and are not meant to have a radio.
|
|
100
|
+
const declarations = Array.from(
|
|
101
|
+
getPropsBlock(component).matchAll(
|
|
102
|
+
/^\s{2}(\w+)\??:\s*([A-Za-z_]\w*)\s*$/gm,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return declarations
|
|
107
|
+
.filter(
|
|
108
|
+
([, , typeName]) =>
|
|
109
|
+
typeName &&
|
|
110
|
+
foreignTypes.has(typeName) &&
|
|
111
|
+
!typeName.startsWith("React"),
|
|
112
|
+
)
|
|
113
|
+
.filter(
|
|
114
|
+
([, propName]) =>
|
|
115
|
+
// The story has to name the prop inside its `argTypes`.
|
|
116
|
+
!new RegExp(
|
|
117
|
+
`argTypes:[\\s\\S]*?\\b${propName}:`,
|
|
118
|
+
).test(story),
|
|
119
|
+
)
|
|
120
|
+
.map(
|
|
121
|
+
([, propName, typeName]) =>
|
|
122
|
+
`${name}.${propName} is \`${typeName}\`, imported from another package, with no argTypes entry`,
|
|
123
|
+
)
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(offenders).toEqual([])
|
|
128
|
+
})
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* `@charcuterie/ui/styles.css` — the four looping affordances, and
|
|
2
|
+
* nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Every other visual in this package is a Tailwind utility reading
|
|
5
|
+
* a token, which is what makes a scheme/density/variant flip a
|
|
6
|
+
* repaint with no re-render. Animation is the one thing utilities
|
|
7
|
+
* cannot express against our tokens: Tailwind's own `animate-spin`
|
|
8
|
+
* and `animate-pulse` hardcode 1s and 2s, ignore
|
|
9
|
+
* `prefers-reduced-motion`, and are therefore beyond the theme's
|
|
10
|
+
* reach — which is exactly why `MotionTokens` carries a separate
|
|
11
|
+
* `loopFast`/`loopSlow` ramp.
|
|
12
|
+
*
|
|
13
|
+
* Consumers import this once, after `@charcuterie/tokens/theme.css`.
|
|
14
|
+
*
|
|
15
|
+
* The reduced-motion block is not a nicety. `variables.css` already
|
|
16
|
+
* collapses every duration to `0ms`, and for a *transition* that is
|
|
17
|
+
* the whole fix — but `animation: sweep 0ms infinite` still paints
|
|
18
|
+
* its first keyframe forever, and the first keyframe of a sweep is
|
|
19
|
+
* an off-screen bar. So each loop is switched off outright and owes
|
|
20
|
+
* a static fallback that still reads correctly, which is what the
|
|
21
|
+
* `@media` block below supplies. A spinner that stops spinning must
|
|
22
|
+
* still say "loading", and it does: the label is text in a
|
|
23
|
+
* `role="status"`, not the animation. */
|
|
24
|
+
|
|
25
|
+
@keyframes charcuterie-spin {
|
|
26
|
+
to {
|
|
27
|
+
transform: rotate(360deg);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@keyframes charcuterie-shimmer {
|
|
32
|
+
from {
|
|
33
|
+
background-position: 100% 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
to {
|
|
37
|
+
background-position: -100% 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Logical, not `translateX`. A sweep is the one animation whose
|
|
42
|
+
* direction carries meaning — it reads as "moving forward" — so in
|
|
43
|
+
* RTL it has to move the other way, and `inset-inline-start` is
|
|
44
|
+
* what makes that free. */
|
|
45
|
+
@keyframes charcuterie-sweep {
|
|
46
|
+
from {
|
|
47
|
+
inset-inline-start: -40%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
to {
|
|
51
|
+
inset-inline-start: 100%;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@keyframes charcuterie-pulse {
|
|
56
|
+
0% {
|
|
57
|
+
opacity: 1;
|
|
58
|
+
transform: scale(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
50% {
|
|
62
|
+
opacity: 0.45;
|
|
63
|
+
transform: scale(0.82);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
100% {
|
|
67
|
+
opacity: 1;
|
|
68
|
+
transform: scale(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.charcuterie-spin {
|
|
73
|
+
animation: charcuterie-spin var(--duration-loop-fast)
|
|
74
|
+
linear infinite;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.charcuterie-shimmer {
|
|
78
|
+
background-image: linear-gradient(
|
|
79
|
+
90deg,
|
|
80
|
+
var(--color-surface-sunken) 0%,
|
|
81
|
+
var(--color-surface-raised) 50%,
|
|
82
|
+
var(--color-surface-sunken) 100%
|
|
83
|
+
);
|
|
84
|
+
background-size: 200% 100%;
|
|
85
|
+
animation: charcuterie-shimmer var(--duration-loop-slow)
|
|
86
|
+
var(--easing-standard) infinite;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.charcuterie-sweep {
|
|
90
|
+
animation: charcuterie-sweep var(--duration-loop-slow)
|
|
91
|
+
var(--easing-standard) infinite;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.charcuterie-pulse {
|
|
95
|
+
animation: charcuterie-pulse var(--duration-loop-slow)
|
|
96
|
+
var(--easing-standard) infinite;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@media (prefers-reduced-motion: reduce) {
|
|
100
|
+
.charcuterie-spin,
|
|
101
|
+
.charcuterie-shimmer,
|
|
102
|
+
.charcuterie-sweep,
|
|
103
|
+
.charcuterie-pulse {
|
|
104
|
+
animation: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Flat, not mid-gradient: a stopped shimmer left holding its
|
|
108
|
+
* first keyframe is a lopsided highlight that reads as a
|
|
109
|
+
* rendering bug rather than as a placeholder. */
|
|
110
|
+
.charcuterie-shimmer {
|
|
111
|
+
background-image: none;
|
|
112
|
+
background-color: var(--color-surface-sunken);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* The sweep's static fallback carries the whole meaning of the
|
|
116
|
+
* indeterminate state — "working, no measurable progress" — so it
|
|
117
|
+
* fills the track with hatching rather than parking a bar at one
|
|
118
|
+
* end, where it would read as either finished or stuck. */
|
|
119
|
+
.charcuterie-sweep {
|
|
120
|
+
inset-inline-start: 0;
|
|
121
|
+
inline-size: 100%;
|
|
122
|
+
background-image: repeating-linear-gradient(
|
|
123
|
+
135deg,
|
|
124
|
+
currentColor 0,
|
|
125
|
+
currentColor 2px,
|
|
126
|
+
transparent 2px,
|
|
127
|
+
transparent 6px
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|