@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,172 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
3
|
+
import type {
|
|
4
|
+
ComponentPropsWithRef,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import { INTENT_SOLID_FILL_CLASS } from "../intentStyles.ts"
|
|
9
|
+
import { toClassName } from "../toClassName.ts"
|
|
10
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
11
|
+
import type { ProgressThreshold } from "./progressValue.ts"
|
|
12
|
+
import {
|
|
13
|
+
getProgressIntent,
|
|
14
|
+
toProgressPercent,
|
|
15
|
+
toProgressValue,
|
|
16
|
+
} from "./progressValue.ts"
|
|
17
|
+
|
|
18
|
+
export type ProgressBarSize = "lg" | "md" | "sm"
|
|
19
|
+
|
|
20
|
+
export type ProgressBarProps = Omit<
|
|
21
|
+
ComponentPropsWithRef<"div">,
|
|
22
|
+
"children"
|
|
23
|
+
> & {
|
|
24
|
+
intent?: IntentName
|
|
25
|
+
/**
|
|
26
|
+
* Indeterminate means "working, no measurable progress yet" — not
|
|
27
|
+
* zero and not unknown-therefore-full. rip-deck's is the
|
|
28
|
+
* AACS/BD+ preamble: ~25 s of a real Blu-ray emitting nothing, in
|
|
29
|
+
* which a full bar reads as a finished rip and an empty one as a
|
|
30
|
+
* wedged drive.
|
|
31
|
+
*/
|
|
32
|
+
isIndeterminate?: boolean
|
|
33
|
+
isLabelVisible?: boolean
|
|
34
|
+
isValueShown?: boolean
|
|
35
|
+
/** Required. A progressbar with no name is a grey rectangle. */
|
|
36
|
+
label: string
|
|
37
|
+
max?: number
|
|
38
|
+
size?: ProgressBarSize
|
|
39
|
+
thresholds?: readonly ProgressThreshold[]
|
|
40
|
+
value?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TRACK_SIZE_CLASS: Record<ProgressBarSize, string> = {
|
|
44
|
+
sm: "h-1",
|
|
45
|
+
md: "h-2",
|
|
46
|
+
lg: "h-3",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The union of what the fleet has: rip-deck's ARIA and its
|
|
51
|
+
* indeterminate sweep, plus threshold colours nobody has yet.
|
|
52
|
+
*
|
|
53
|
+
* Five repos hand-roll a progress bar and **only rip-deck's has
|
|
54
|
+
* `role="progressbar"`** — the other four are a `<div>` with a
|
|
55
|
+
* percentage width, invisible to assistive technology and to
|
|
56
|
+
* `getByRole("progressbar")`.
|
|
57
|
+
*
|
|
58
|
+
* Three corrections to rip-deck's version, which is otherwise the
|
|
59
|
+
* best in the fleet:
|
|
60
|
+
*
|
|
61
|
+
* - **The role goes on the track, not the fill.** rip-deck puts
|
|
62
|
+
* `role="progressbar"` on the inner filled element, so the
|
|
63
|
+
* widget's box is the *filled portion* — a 4% rip is a
|
|
64
|
+
* progressbar 4px wide as far as AT and a Playwright bounding
|
|
65
|
+
* box are concerned.
|
|
66
|
+
* - **The name comes from a real label**, wired with
|
|
67
|
+
* `aria-labelledby` when it is visible and `VisuallyHidden` when
|
|
68
|
+
* it is not, rather than a hardcoded `aria-label` string that
|
|
69
|
+
* cannot be translated or reused.
|
|
70
|
+
* - **`aria-valuenow` is omitted while indeterminate**, which is
|
|
71
|
+
* what tells AT "unknown" rather than "zero". rip-deck gets this
|
|
72
|
+
* right by rendering a separate element; stating it on one
|
|
73
|
+
* element is the same answer without the duplication.
|
|
74
|
+
*/
|
|
75
|
+
export const ProgressBar = ({
|
|
76
|
+
className,
|
|
77
|
+
intent = "accent",
|
|
78
|
+
isIndeterminate = false,
|
|
79
|
+
isLabelVisible = false,
|
|
80
|
+
isValueShown = false,
|
|
81
|
+
label,
|
|
82
|
+
max = 100,
|
|
83
|
+
size = "md",
|
|
84
|
+
thresholds,
|
|
85
|
+
value = 0,
|
|
86
|
+
...divProps
|
|
87
|
+
}: ProgressBarProps): ReactNode => {
|
|
88
|
+
const labelId = useUniqueId()
|
|
89
|
+
|
|
90
|
+
const percent = toProgressPercent(value, max)
|
|
91
|
+
|
|
92
|
+
const clampedValue = toProgressValue(value, max)
|
|
93
|
+
|
|
94
|
+
const fillIntent = getProgressIntent(
|
|
95
|
+
percent,
|
|
96
|
+
intent,
|
|
97
|
+
thresholds,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
{...divProps}
|
|
103
|
+
className={toClassName(
|
|
104
|
+
"flex flex-col gap-1",
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{isLabelVisible || isValueShown ? (
|
|
109
|
+
<div className="flex items-baseline justify-between gap-2 text-sm">
|
|
110
|
+
{isLabelVisible ? (
|
|
111
|
+
<span
|
|
112
|
+
className="text-content-secondary"
|
|
113
|
+
id={labelId}
|
|
114
|
+
>
|
|
115
|
+
{label}
|
|
116
|
+
</span>
|
|
117
|
+
) : (
|
|
118
|
+
<VisuallyHidden id={labelId}>
|
|
119
|
+
{label}
|
|
120
|
+
</VisuallyHidden>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{isValueShown && !isIndeterminate ? (
|
|
124
|
+
<span className="text-content-muted tabular-nums">
|
|
125
|
+
{percent}%
|
|
126
|
+
</span>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<VisuallyHidden id={labelId}>
|
|
131
|
+
{label}
|
|
132
|
+
</VisuallyHidden>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<div
|
|
136
|
+
aria-busy={isIndeterminate || undefined}
|
|
137
|
+
aria-labelledby={labelId}
|
|
138
|
+
aria-valuemax={max}
|
|
139
|
+
aria-valuemin={0}
|
|
140
|
+
aria-valuenow={
|
|
141
|
+
isIndeterminate ? undefined : clampedValue
|
|
142
|
+
}
|
|
143
|
+
className={toClassName(
|
|
144
|
+
"relative w-full overflow-hidden rounded-full bg-surface-sunken",
|
|
145
|
+
TRACK_SIZE_CLASS[size],
|
|
146
|
+
)}
|
|
147
|
+
role="progressbar"
|
|
148
|
+
>
|
|
149
|
+
{isIndeterminate ? (
|
|
150
|
+
<span
|
|
151
|
+
className={toClassName(
|
|
152
|
+
// The sweep animates `inset-inline-start`, so it runs
|
|
153
|
+
// the other way in RTL for free — and its
|
|
154
|
+
// reduced-motion fallback hatches the whole track
|
|
155
|
+
// rather than parking at one end.
|
|
156
|
+
"charcuterie-sweep absolute inset-y-0 w-2/5 rounded-full",
|
|
157
|
+
INTENT_SOLID_FILL_CLASS[fillIntent],
|
|
158
|
+
)}
|
|
159
|
+
/>
|
|
160
|
+
) : (
|
|
161
|
+
<span
|
|
162
|
+
className={toClassName(
|
|
163
|
+
"block h-full rounded-full transition-[inline-size] duration-(--duration-normal) ease-standard",
|
|
164
|
+
INTENT_SOLID_FILL_CLASS[fillIntent],
|
|
165
|
+
)}
|
|
166
|
+
style={{ inlineSize: `${percent}%` }}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic a progress bar gets handed by a log parser.
|
|
3
|
+
*
|
|
4
|
+
* None of these inputs is hypothetical: rip-deck's `fillPercent`
|
|
5
|
+
* comes out of a ripper's stdout, and `-1`, `101`, `NaN`, and
|
|
6
|
+
* `0 of 0` all arrive in normal operation. Today each of them
|
|
7
|
+
* renders as a bar that overflows its track, vanishes, or reads as
|
|
8
|
+
* finished.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { expect, test } from "vitest"
|
|
12
|
+
|
|
13
|
+
import type { ProgressThreshold } from "./progressValue.ts"
|
|
14
|
+
import {
|
|
15
|
+
getProgressIntent,
|
|
16
|
+
toProgressPercent,
|
|
17
|
+
toProgressValue,
|
|
18
|
+
} from "./progressValue.ts"
|
|
19
|
+
|
|
20
|
+
test("a normal value becomes a rounded percentage", () => {
|
|
21
|
+
expect(toProgressPercent(38, 100)).toBe(38)
|
|
22
|
+
|
|
23
|
+
expect(toProgressPercent(4, 9)).toBe(44)
|
|
24
|
+
|
|
25
|
+
expect(toProgressPercent(1, 3)).toBe(33)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("out-of-range values clamp rather than overflow", () => {
|
|
29
|
+
expect(toProgressPercent(-5, 100)).toBe(0)
|
|
30
|
+
|
|
31
|
+
expect(toProgressPercent(150, 100)).toBe(100)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("an empty queue is 0%, not NaN%", () => {
|
|
35
|
+
// `0 of 0 files done` — every empty queue's first render.
|
|
36
|
+
expect(toProgressPercent(0, 0)).toBe(0)
|
|
37
|
+
|
|
38
|
+
expect(toProgressPercent(1, 0)).toBe(0)
|
|
39
|
+
|
|
40
|
+
expect(toProgressPercent(Number.NaN, 100)).toBe(0)
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
toProgressPercent(Number.POSITIVE_INFINITY, 100),
|
|
44
|
+
).toBe(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("aria-valuenow never leaves its own min/max", () => {
|
|
48
|
+
// A `valuenow` outside `valuemin`/`valuemax` is read as a nonsense
|
|
49
|
+
// percentage by some screen readers and dropped by others, and
|
|
50
|
+
// either way it disagrees with the bar on screen.
|
|
51
|
+
expect(toProgressValue(38, 100)).toBe(38)
|
|
52
|
+
|
|
53
|
+
expect(toProgressValue(-5, 100)).toBe(0)
|
|
54
|
+
|
|
55
|
+
expect(toProgressValue(150, 100)).toBe(100)
|
|
56
|
+
|
|
57
|
+
expect(toProgressValue(5, 0)).toBe(0)
|
|
58
|
+
|
|
59
|
+
expect(toProgressValue(Number.NaN, 100)).toBe(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const THRESHOLDS: ProgressThreshold[] = [
|
|
63
|
+
{ from: 100, intent: "success" },
|
|
64
|
+
{ from: 90, intent: "warning" },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
test("with no thresholds the component's intent wins", () => {
|
|
68
|
+
expect(getProgressIntent(50, "accent")).toBe("accent")
|
|
69
|
+
|
|
70
|
+
expect(getProgressIntent(50, "accent", [])).toBe("accent")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("the highest matching threshold wins, whatever the array order", () => {
|
|
74
|
+
// Order-independence is the point: a caller appending to a config
|
|
75
|
+
// array should not have to know it was meant to be sorted.
|
|
76
|
+
expect(getProgressIntent(94, "accent", THRESHOLDS)).toBe(
|
|
77
|
+
"warning",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(
|
|
81
|
+
getProgressIntent(
|
|
82
|
+
94,
|
|
83
|
+
"accent",
|
|
84
|
+
[...THRESHOLDS].reverse(),
|
|
85
|
+
),
|
|
86
|
+
).toBe("warning")
|
|
87
|
+
|
|
88
|
+
expect(getProgressIntent(100, "accent", THRESHOLDS)).toBe(
|
|
89
|
+
"success",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(getProgressIntent(89, "accent", THRESHOLDS)).toBe(
|
|
93
|
+
"accent",
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("a threshold at the boundary is inclusive", () => {
|
|
98
|
+
expect(getProgressIntent(90, "accent", THRESHOLDS)).toBe(
|
|
99
|
+
"warning",
|
|
100
|
+
)
|
|
101
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A threshold is "from this percentage upward, use this intent".
|
|
5
|
+
* Order does not matter — the highest matching entry wins — because
|
|
6
|
+
* a caller sorting a config array by hand is a bug waiting for the
|
|
7
|
+
* day someone appends to it.
|
|
8
|
+
*/
|
|
9
|
+
export type ProgressThreshold = {
|
|
10
|
+
from: number
|
|
11
|
+
intent: IntentName
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Clamped, rounded, and NaN-proof, in a file a Node test can reach
|
|
16
|
+
* without a browser.
|
|
17
|
+
*
|
|
18
|
+
* All three cases are real. rip-deck's `fillPercent` comes from a
|
|
19
|
+
* ripper's stdout, and a progress value that arrives as `-1`,
|
|
20
|
+
* `101`, or `NaN` from a log parse currently renders as a bar that
|
|
21
|
+
* overflows its track or vanishes. `max: 0` is the one that looks
|
|
22
|
+
* hypothetical and is not: it is "0 of 0 files done", which every
|
|
23
|
+
* empty queue produces on its first render.
|
|
24
|
+
*/
|
|
25
|
+
export const toProgressPercent = (
|
|
26
|
+
value: number,
|
|
27
|
+
max: number,
|
|
28
|
+
) => {
|
|
29
|
+
if (!Number.isFinite(value) || !Number.isFinite(max)) {
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (max <= 0) {
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Math.min(
|
|
38
|
+
100,
|
|
39
|
+
Math.max(0, Math.round((value / max) * 100)),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The same clamp in the value's own units, for `aria-valuenow`.
|
|
45
|
+
*
|
|
46
|
+
* Reporting the raw prop there would hand assistive technology a
|
|
47
|
+
* `valuenow` outside its own `valuemin`/`valuemax` — which some
|
|
48
|
+
* screen readers render as a nonsense percentage and others drop
|
|
49
|
+
* entirely — while the bar on screen showed the clamped one. The
|
|
50
|
+
* two must not disagree.
|
|
51
|
+
*/
|
|
52
|
+
export const toProgressValue = (
|
|
53
|
+
value: number,
|
|
54
|
+
max: number,
|
|
55
|
+
) => {
|
|
56
|
+
if (!Number.isFinite(value) || !Number.isFinite(max)) {
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Math.min(Math.max(0, max), Math.max(0, value))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The threshold colour, or the component's own intent when no
|
|
65
|
+
* threshold applies.
|
|
66
|
+
*
|
|
67
|
+
* This is the piece rip-deck hardcodes as `FILL_CLASS` keyed by a
|
|
68
|
+
* rip state, and mux-magic does not have at all. Generalising it to
|
|
69
|
+
* "percentage → intent" covers both the "green when done" case and
|
|
70
|
+
* the "amber past 80% of the disk" case with one mechanism.
|
|
71
|
+
*/
|
|
72
|
+
export const getProgressIntent = (
|
|
73
|
+
percent: number,
|
|
74
|
+
intent: IntentName,
|
|
75
|
+
thresholds?: readonly ProgressThreshold[],
|
|
76
|
+
): IntentName => {
|
|
77
|
+
if (!thresholds || thresholds.length === 0) {
|
|
78
|
+
return intent
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const matched = thresholds
|
|
82
|
+
.filter((threshold) => percent >= threshold.from)
|
|
83
|
+
.sort((first, second) => second.from - first.from)
|
|
84
|
+
.at(0)
|
|
85
|
+
|
|
86
|
+
return matched?.intent ?? intent
|
|
87
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SegmentedControlStories from "./SegmentedControl.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SegmentedControlStories} />
|
|
6
|
+
|
|
7
|
+
# SegmentedControl
|
|
8
|
+
|
|
9
|
+
One choice out of a handful, all of them on screen at once.
|
|
10
|
+
|
|
11
|
+
This is `Tabs`' composition — `SinglePicker` + `RovingFocus` — **with the panels taken
|
|
12
|
+
away**, and it is here because taking the panels away used to be impossible. Under M4's
|
|
13
|
+
model a tab bar's selection *was* a group of visibilities, so a control that chooses
|
|
14
|
+
something without revealing anything had nothing to reuse and hand-rolled its own state.
|
|
15
|
+
rip-deck's `ColumnPicker` is that hand-roll
|
|
16
|
+
([decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md)).
|
|
17
|
+
|
|
18
|
+
<Canvas of={SegmentedControlStories.Default} />
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
<Controls />
|
|
23
|
+
|
|
24
|
+
## `radiogroup`, not a row of pressed buttons
|
|
25
|
+
|
|
26
|
+
rip-deck's version is five `<button aria-pressed>` inside a `<fieldset>` — the **toolbar of
|
|
27
|
+
independent toggles** pattern. Nothing in it says the five are mutually exclusive, so a
|
|
28
|
+
screen reader announces *"auto, pressed"* with no "1 of 5" and no clue that pressing
|
|
29
|
+
another one un-presses this one.
|
|
30
|
+
|
|
31
|
+
A single choice from a named set is a radio group, and ARIA has a pattern for it: a
|
|
32
|
+
`radiogroup` with an accessible name, `role="radio"` children carrying `aria-checked`, and
|
|
33
|
+
a roving tabindex so Tab enters and leaves the group once.
|
|
34
|
+
|
|
35
|
+
<Canvas of={SegmentedControlStories.AllVariants} />
|
|
36
|
+
|
|
37
|
+
## Selection follows focus, and here that is correct
|
|
38
|
+
|
|
39
|
+
`Tabs` has two activation modes because a tab panel can cost a network request. A radio
|
|
40
|
+
group has one: APG says the arrow keys both move focus **and** check, and there is nothing
|
|
41
|
+
behind an option here to be expensive. So there is no `activation` prop — a `manual`
|
|
42
|
+
segmented control would be a control the arrow keys appear to do nothing in.
|
|
43
|
+
|
|
44
|
+
The two kinds are still separate underneath, which is what makes Space trivial rather than
|
|
45
|
+
a special case: it checks whatever focus is on, whether or not focus moved there by arrow.
|
|
46
|
+
`select` is idempotent, so it is one command and not a second code path.
|
|
47
|
+
|
|
48
|
+
<Canvas of={SegmentedControlStories.AllStates} />
|
|
49
|
+
|
|
50
|
+
## Registration is membership
|
|
51
|
+
|
|
52
|
+
A disabled option is out of the **focus** group and still one of the **options** — it can
|
|
53
|
+
even be the one a consumer names as the initial `selectedValue`, which is what a saved
|
|
54
|
+
preference pointing at a layout the window no longer fits looks like.
|
|
55
|
+
|
|
56
|
+
`SegmentedOption` is its own file for the same reason `TabTrigger` is: both registrations
|
|
57
|
+
are effects, and an effect cannot run in a loop.
|
|
58
|
+
|
|
59
|
+
<Canvas of={SegmentedControlStories.Responsive} />
|
|
60
|
+
|
|
61
|
+
## `selectedValue` is initial, not controlled
|
|
62
|
+
|
|
63
|
+
Read once, on the first render. There is no controlled mode and no prop-sync effect, which
|
|
64
|
+
is [the M2 decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md).
|
|
65
|
+
|
|
66
|
+
The first paint reads `pendingValue` as well as `selectedValue`, and that fallback is
|
|
67
|
+
load-bearing: members register from an effect, so `selectedValue` is still `null` when the
|
|
68
|
+
group first paints. Reading only the former renders a group with nothing checked that
|
|
69
|
+
corrects itself a frame later — a flash on a desktop, a visible blank on a kiosk Pi.
|
|
70
|
+
|
|
71
|
+
<Canvas of={SegmentedControlStories.Interactive} />
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
ContainerBoard,
|
|
6
|
+
StoryCell,
|
|
7
|
+
StoryGrid,
|
|
8
|
+
StorySection,
|
|
9
|
+
} from "../board.storyHelpers.tsx"
|
|
10
|
+
import type { SegmentedItem } from "./SegmentedControl.tsx"
|
|
11
|
+
import { SegmentedControl } from "./SegmentedControl.tsx"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* rip-deck's column picker, which is where this component came
|
|
15
|
+
* from. `auto` sits first and always, because a control that only
|
|
16
|
+
* offers numbers once a number has been picked is a one-way door.
|
|
17
|
+
*/
|
|
18
|
+
const COLUMN_ITEMS: SegmentedItem[] = [
|
|
19
|
+
{ label: "auto · 3", value: "auto" },
|
|
20
|
+
{ label: "1", value: "1" },
|
|
21
|
+
{ label: "2", value: "2" },
|
|
22
|
+
{ label: "3", value: "3" },
|
|
23
|
+
{ label: "4", value: "4" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Components/SegmentedControl",
|
|
28
|
+
component: SegmentedControl,
|
|
29
|
+
parameters: { layout: "padded" },
|
|
30
|
+
argTypes: { size: controlSizeArgType },
|
|
31
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
32
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
33
|
+
// table with nothing selected in its control.
|
|
34
|
+
args: { size: "md" },
|
|
35
|
+
} satisfies Meta<typeof SegmentedControl>
|
|
36
|
+
|
|
37
|
+
export default meta
|
|
38
|
+
|
|
39
|
+
type Story = StoryObj<typeof meta>
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
args: { items: COLUMN_ITEMS, label: "Columns" },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const AllVariants: Story = {
|
|
46
|
+
args: { items: COLUMN_ITEMS, label: "Columns" },
|
|
47
|
+
render: (controlProps) => (
|
|
48
|
+
<StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
|
|
49
|
+
<StoryGrid columns={3}>
|
|
50
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
51
|
+
<StoryCell key={size} label={size}>
|
|
52
|
+
<SegmentedControl
|
|
53
|
+
{...controlProps}
|
|
54
|
+
label={`Columns at ${size}`}
|
|
55
|
+
size={size}
|
|
56
|
+
/>
|
|
57
|
+
</StoryCell>
|
|
58
|
+
))}
|
|
59
|
+
</StoryGrid>
|
|
60
|
+
</StorySection>
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const STATE_ITEMS: SegmentedItem[] = [
|
|
65
|
+
{ label: "auto · 3", value: "auto" },
|
|
66
|
+
{ label: "1", value: "1" },
|
|
67
|
+
{ isDisabled: true, label: "2", value: "2" },
|
|
68
|
+
{ label: "3", value: "3" },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A disabled option is out of the *focus* group and still one of the
|
|
73
|
+
* *options* — it can even be the one a consumer names as the initial
|
|
74
|
+
* `selectedValue`, which is what a saved preference pointing at a
|
|
75
|
+
* layout the window no longer fits looks like.
|
|
76
|
+
*/
|
|
77
|
+
export const AllStates: Story = {
|
|
78
|
+
args: { items: STATE_ITEMS, label: "Columns" },
|
|
79
|
+
render: (controlProps) => (
|
|
80
|
+
<StorySection title="What varies is which option is checked and which are reachable.">
|
|
81
|
+
<StoryGrid columns={2}>
|
|
82
|
+
<StoryCell label="first option checked">
|
|
83
|
+
<SegmentedControl
|
|
84
|
+
{...controlProps}
|
|
85
|
+
label="Columns starting on auto"
|
|
86
|
+
/>
|
|
87
|
+
</StoryCell>
|
|
88
|
+
|
|
89
|
+
<StoryCell label="starts on the third option">
|
|
90
|
+
<SegmentedControl
|
|
91
|
+
{...controlProps}
|
|
92
|
+
label="Columns starting on 3"
|
|
93
|
+
selectedValue="3"
|
|
94
|
+
/>
|
|
95
|
+
</StoryCell>
|
|
96
|
+
|
|
97
|
+
<StoryCell label="a disabled option">
|
|
98
|
+
<SegmentedControl
|
|
99
|
+
{...controlProps}
|
|
100
|
+
label="Columns with 2 unavailable"
|
|
101
|
+
/>
|
|
102
|
+
</StoryCell>
|
|
103
|
+
|
|
104
|
+
<StoryCell label="two options">
|
|
105
|
+
<SegmentedControl
|
|
106
|
+
{...controlProps}
|
|
107
|
+
items={[
|
|
108
|
+
{ label: "Grid", value: "grid" },
|
|
109
|
+
{ label: "List", value: "list" },
|
|
110
|
+
]}
|
|
111
|
+
label="Layout"
|
|
112
|
+
/>
|
|
113
|
+
</StoryCell>
|
|
114
|
+
</StoryGrid>
|
|
115
|
+
</StorySection>
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* It is `inline-flex`, so it takes the width of its options and no
|
|
121
|
+
* more — a segmented control that stretched to its container would
|
|
122
|
+
* put four numbers across a whole dashboard header.
|
|
123
|
+
*
|
|
124
|
+
* `ContainerBoard` is a width harness here rather than a
|
|
125
|
+
* container-query claim: `SegmentedControl` declares no container,
|
|
126
|
+
* because nothing about it needs to *know* its width.
|
|
127
|
+
*/
|
|
128
|
+
export const Responsive: Story = {
|
|
129
|
+
args: { items: COLUMN_ITEMS, label: "Columns" },
|
|
130
|
+
render: (controlProps) => (
|
|
131
|
+
<ContainerBoard>
|
|
132
|
+
{(width) => (
|
|
133
|
+
<SegmentedControl
|
|
134
|
+
{...controlProps}
|
|
135
|
+
label={`Columns at ${width}`}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</ContainerBoard>
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The complete keyboard path. Tab enters the group **once** — the
|
|
144
|
+
* roving-tabindex rule — then the arrow keys move and check
|
|
145
|
+
* together, which is what a radio group does and what `Tabs`'
|
|
146
|
+
* `manual` mode deliberately does not.
|
|
147
|
+
*/
|
|
148
|
+
export const Interactive: Story = {
|
|
149
|
+
args: { items: COLUMN_ITEMS, label: "Columns" },
|
|
150
|
+
}
|