@charcuterie/ui 4.7.1 → 4.9.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.
@@ -0,0 +1,43 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+ import * as ProgressCardStories from "./ProgressCard.stories";
3
+
4
+ <Meta of={ProgressCardStories} />
5
+
6
+ # ProgressCard
7
+
8
+ A progress-first card with a large value over a filled band and distinct metric blocks.
9
+ Use it for transfers, imports, backups, or other measurable work. `Card` owns the shell;
10
+ `ProgressBar` owns clamping, the named ARIA widget, and indeterminate motion.
11
+
12
+ <Canvas of={ProgressCardStories.Playground} />
13
+
14
+ ## Ownership
15
+
16
+ The library owns layout, responsive typography, metric contrast, and whole-card intent.
17
+ The app supplies identity, media, formatted values, actions, and meaningful issue text.
18
+ Neither an absent diagnosis nor a numeric percentage implies a warning here.
19
+
20
+ `intent` colors the entire card; `progressIntent` colors the fill independently.
21
+ `valueText` is app-formatted text, while `value` and `max` drive measurable progress.
22
+ Set `isIndeterminate` when progress cannot yet be measured and use a state such as
23
+ "Preparing" for `valueText`. Set `isValueEmphasized={false}` for terminal state text.
24
+ The band overlay stays outside the progressbar role so its content retains semantics.
25
+
26
+ <Controls />
27
+
28
+ ## Layouts and states
29
+
30
+ `band` is the default. `hierarchy` places the same headline above a thin progress track.
31
+ Metrics stack when their own content area is narrow, even if media makes the whole card
32
+ wide. The viewport does not decide this. Labels should be unique.
33
+
34
+ <Canvas of={ProgressCardStories.AllVariants} />
35
+ <Canvas of={ProgressCardStories.AllStates} />
36
+ <Canvas of={ProgressCardStories.Responsive} />
37
+
38
+ ## App-owned controls
39
+
40
+ Action slots use the same controls as the rest of the app. The card does not define cancel,
41
+ retry, or confirmation policy. Additional issue details belong in `children`.
42
+
43
+ <Canvas of={ProgressCardStories.Interactive} />
@@ -0,0 +1,178 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+
5
+ import { intentArgType } from "../argTypes.storyHelpers.ts"
6
+ import { Button } from "../Button/Button.tsx"
7
+ import {
8
+ ContainerBoard,
9
+ StoryCell,
10
+ StoryGrid,
11
+ } from "../board.storyHelpers.tsx"
12
+ import { ProgressCard } from "./ProgressCard.tsx"
13
+
14
+ const metrics = [
15
+ { label: "Speed", value: "17.2 MB/s" },
16
+ { label: "Remaining", value: "9m" },
17
+ { label: "Finishes", value: "4:44 AM" },
18
+ ]
19
+
20
+ const meta = {
21
+ title: "Components/Feedback/ProgressCard",
22
+ component: ProgressCard,
23
+ parameters: { layout: "padded" },
24
+ argTypes: {
25
+ intent: intentArgType,
26
+ progressIntent: intentArgType,
27
+ },
28
+ args: {
29
+ "aria-label": "Archive transfer",
30
+ header: "Archive transfer",
31
+ layout: "band",
32
+ metrics,
33
+ progressLabel: "Transfer progress",
34
+ status: "Transferring",
35
+ value: 82.5,
36
+ valueText: "82.5%",
37
+ },
38
+ } satisfies Meta<typeof ProgressCard>
39
+
40
+ export default meta
41
+ type Story = StoryObj<typeof meta>
42
+
43
+ export const Playground: Story = {
44
+ parameters: playgroundParameters,
45
+ }
46
+
47
+ export const AllVariants: Story = {
48
+ render: (props) => (
49
+ <StoryGrid columns={2}>
50
+ {(["band", "hierarchy"] as const).map((layout) => (
51
+ <StoryCell
52
+ align="stretch"
53
+ key={layout}
54
+ label={layout}
55
+ >
56
+ <ProgressCard
57
+ {...props}
58
+ aria-label={`Transfer ${layout}`}
59
+ layout={layout}
60
+ />
61
+ </StoryCell>
62
+ ))}
63
+ </StoryGrid>
64
+ ),
65
+ }
66
+
67
+ export const AllStates: Story = {
68
+ render: (props) => (
69
+ <StoryGrid columns={2}>
70
+ <StoryCell align="stretch" label="Preparing">
71
+ <ProgressCard
72
+ {...props}
73
+ aria-label="Preparing transfer"
74
+ isIndeterminate
75
+ isValueEmphasized={false}
76
+ metrics={[]}
77
+ status={undefined}
78
+ valueText="Preparing"
79
+ />
80
+ </StoryCell>
81
+ <StoryCell align="stretch" label="Warning">
82
+ <ProgressCard
83
+ {...props}
84
+ aria-label="Transfer warning"
85
+ intent="warning"
86
+ progressIntent="warning"
87
+ status="Needs attention"
88
+ >
89
+ <p className="mt-2 text-intent-warning-content text-sm">
90
+ The source stopped responding.
91
+ </p>
92
+ </ProgressCard>
93
+ </StoryCell>
94
+ <StoryCell align="stretch" label="Failed">
95
+ <ProgressCard
96
+ {...props}
97
+ aria-label="Failed transfer"
98
+ intent="danger"
99
+ isValueEmphasized={false}
100
+ metrics={[]}
101
+ progressIntent="danger"
102
+ status={undefined}
103
+ valueText="Failed"
104
+ />
105
+ </StoryCell>
106
+ <StoryCell align="stretch" label="Complete">
107
+ <ProgressCard
108
+ {...props}
109
+ aria-label="Complete transfer"
110
+ isValueEmphasized={false}
111
+ metrics={[]}
112
+ progressIntent="success"
113
+ status={undefined}
114
+ value={100}
115
+ valueText="Complete"
116
+ />
117
+ </StoryCell>
118
+ </StoryGrid>
119
+ ),
120
+ }
121
+
122
+ export const Responsive: Story = {
123
+ render: (props) => (
124
+ <ContainerBoard>
125
+ {(width) => (
126
+ <ProgressCard
127
+ {...props}
128
+ aria-label={`Transfer at ${width}`}
129
+ header="ARCHIVE_TRANSFER_WITH_A_VERY_LONG_UNBROKEN_NAME"
130
+ media={
131
+ <span className="rounded-md bg-surface-sunken p-2">
132
+ 07
133
+ </span>
134
+ }
135
+ actions={
136
+ <Button
137
+ appearance="outline"
138
+ intent="danger"
139
+ size="sm"
140
+ >
141
+ Cancel
142
+ </Button>
143
+ }
144
+ />
145
+ )}
146
+ </ContainerBoard>
147
+ ),
148
+ }
149
+
150
+ const ControlledProgressCard = () => {
151
+ const [isCanceled, setIsCanceled] = useState(false)
152
+ return (
153
+ <ProgressCard
154
+ aria-label="Cancelable transfer"
155
+ header="Archive transfer"
156
+ actions={
157
+ <Button
158
+ appearance="outline"
159
+ intent="danger"
160
+ isDisabled={isCanceled}
161
+ onClick={() => setIsCanceled(true)}
162
+ >
163
+ Cancel
164
+ </Button>
165
+ }
166
+ isValueEmphasized={!isCanceled}
167
+ metrics={isCanceled ? [] : metrics}
168
+ progressLabel="Transfer progress"
169
+ status={isCanceled ? undefined : "Transferring"}
170
+ value={82.5}
171
+ valueText={isCanceled ? "Canceled" : "82.5%"}
172
+ />
173
+ )
174
+ }
175
+
176
+ export const Interactive: Story = {
177
+ render: () => <ControlledProgressCard />,
178
+ }
@@ -0,0 +1,118 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, within } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import {
8
+ DESKTOP,
9
+ setViewport,
10
+ } from "../viewport.testHelpers.ts"
11
+ import * as stories from "./ProgressCard.stories.tsx"
12
+
13
+ const {
14
+ Playground,
15
+ AllVariants,
16
+ AllStates,
17
+ Responsive,
18
+ Interactive,
19
+ } = composeStories(stories)
20
+
21
+ test("the large value overlays a real named progressbar and the metrics stay outside it", async () => {
22
+ const { canvas } = await mountStory(Playground)
23
+ const card = expectAgentDrivable(canvas, {
24
+ role: "region",
25
+ name: "Archive transfer",
26
+ })
27
+ const bar = within(card).getByRole("progressbar", {
28
+ name: "Transfer progress",
29
+ })
30
+ const percentage = within(card).getByText("82.5%")
31
+ await expect(bar).toHaveAttribute("aria-valuenow", "82.5")
32
+ await expect(bar).not.toContainElement(percentage)
33
+ await expect(
34
+ bar.getBoundingClientRect().height,
35
+ ).toBeGreaterThan(50)
36
+ await expect(
37
+ bar.getBoundingClientRect().top,
38
+ ).toBeLessThan(percentage.getBoundingClientRect().top)
39
+ await expect(
40
+ within(card).getAllByRole("term"),
41
+ ).toHaveLength(3)
42
+ })
43
+
44
+ test("both layouts preserve progress semantics", async () => {
45
+ const { canvas } = await mountStory(AllVariants)
46
+ for (const layout of ["band", "hierarchy"]) {
47
+ const card = expectAgentDrivable(canvas, {
48
+ role: "region",
49
+ name: `Transfer ${layout}`,
50
+ })
51
+ await expect(
52
+ within(card).getByRole("progressbar"),
53
+ ).toHaveAttribute("aria-valuenow", "82.5")
54
+ await expect(
55
+ within(card).getByText("82.5%"),
56
+ ).toBeVisible()
57
+ }
58
+ })
59
+
60
+ test("issues tint the complete card and indeterminate progress has no invented value", async () => {
61
+ const { canvas } = await mountStory(AllStates)
62
+ const warning = expectAgentDrivable(canvas, {
63
+ role: "region",
64
+ name: "Transfer warning",
65
+ })
66
+ await expect(warning).toHaveClass(
67
+ "bg-intent-warning-surface",
68
+ )
69
+ await expect(
70
+ canvas.getByRole("region", { name: "Failed transfer" }),
71
+ ).toHaveClass("bg-intent-danger-surface")
72
+ const preparing = canvas.getByRole("region", {
73
+ name: "Preparing transfer",
74
+ })
75
+ await expect(
76
+ within(preparing).getByRole("progressbar"),
77
+ ).not.toHaveAttribute("aria-valuenow")
78
+ await expect(
79
+ within(preparing).queryByRole("term"),
80
+ ).not.toBeInTheDocument()
81
+ })
82
+
83
+ test("the card fits each container width with a long title, media, and actions", async () => {
84
+ await setViewport(DESKTOP)
85
+ const { canvas } = await mountStory(Responsive)
86
+ for (const width of ["15rem", "24rem", "34rem"]) {
87
+ const card = expectAgentDrivable(canvas, {
88
+ role: "region",
89
+ name: `Transfer at ${width}`,
90
+ })
91
+ await expect(card.scrollWidth).toBeLessThanOrEqual(
92
+ card.clientWidth,
93
+ )
94
+ const metrics = card.querySelector("dl") as HTMLElement
95
+ await expect(
96
+ getComputedStyle(metrics).gridTemplateColumns.split(
97
+ " ",
98
+ ),
99
+ ).toHaveLength(width === "34rem" ? 3 : 1)
100
+ }
101
+ })
102
+
103
+ test("the app action remains keyboard-operable above the progress band", async () => {
104
+ const { canvas } = await mountStory(Interactive)
105
+ const card = expectAgentDrivable(canvas, {
106
+ role: "region",
107
+ name: "Cancelable transfer",
108
+ })
109
+ const cancel = within(card).getByRole("button", {
110
+ name: "Cancel",
111
+ })
112
+ cancel.focus()
113
+ await userEvent.keyboard("{Enter}")
114
+ await expect(
115
+ within(card).getByText("Canceled"),
116
+ ).toBeVisible()
117
+ await expect(cancel).toBeDisabled()
118
+ })
@@ -0,0 +1,152 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { Card, type CardProps } from "../Card/Card.tsx"
5
+ import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
6
+ import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
7
+ import { toClassName } from "../toClassName.ts"
8
+
9
+ export type ProgressCardMetric = {
10
+ label: string
11
+ value: ReactNode
12
+ }
13
+
14
+ export type ProgressCardLayout = "band" | "hierarchy"
15
+
16
+ export type ProgressCardProps = Omit<
17
+ CardProps,
18
+ "heading" | "footer"
19
+ > & {
20
+ "aria-label": string
21
+ /** App-owned identity: a title, an identifier, or a format mark. */
22
+ header: ReactNode
23
+ /** Whole-card intent is independent of the progress fill. */
24
+ intent?: IntentName
25
+ isIndeterminate?: boolean
26
+ isValueEmphasized?: boolean
27
+ layout?: ProgressCardLayout
28
+ max?: number
29
+ media?: ReactNode
30
+ metrics?: readonly ProgressCardMetric[]
31
+ progressIntent?: IntentName
32
+ progressLabel: string
33
+ status?: string
34
+ value?: number
35
+ /** App-formatted percentage or terminal state; never inferred from intent. */
36
+ valueText: string
37
+ }
38
+
39
+ /** A progress-first card. The app owns state, formatting, actions, and diagnosis. */
40
+ export const ProgressCard = ({
41
+ actions,
42
+ children,
43
+ className,
44
+ header,
45
+ intent = "neutral",
46
+ isIndeterminate = false,
47
+ isValueEmphasized = true,
48
+ layout = "band",
49
+ max,
50
+ media,
51
+ metrics = [],
52
+ progressIntent = "accent",
53
+ progressLabel,
54
+ status,
55
+ value,
56
+ valueText,
57
+ ...cardProps
58
+ }: ProgressCardProps): ReactNode => {
59
+ const headline = (
60
+ <div
61
+ className={toClassName(
62
+ "relative flex flex-wrap items-end justify-between gap-2",
63
+ layout === "band" ? "p-3" : "mb-3",
64
+ )}
65
+ >
66
+ {status ? (
67
+ <span className="min-w-0 wrap-anywhere font-medium text-content-secondary text-sm">
68
+ {status}
69
+ </span>
70
+ ) : null}
71
+ <span
72
+ className={toClassName(
73
+ "min-w-0 wrap-anywhere text-end tabular-nums",
74
+ isValueEmphasized
75
+ ? "font-bold text-4xl leading-none tracking-tight cq-sm:text-[3.25rem]"
76
+ : "font-semibold text-xl",
77
+ )}
78
+ >
79
+ {valueText}
80
+ </span>
81
+ </div>
82
+ )
83
+
84
+ return (
85
+ <Card
86
+ {...cardProps}
87
+ className={toClassName(
88
+ "relative rounded-2xl",
89
+ intent === "neutral"
90
+ ? undefined
91
+ : INTENT_APPEARANCE_CLASS[intent].soft,
92
+ "text-content-primary",
93
+ className,
94
+ )}
95
+ >
96
+ <div className="flex gap-3.5">
97
+ {media ? (
98
+ <div className="flex shrink-0 flex-col items-start gap-2">
99
+ {media}
100
+ </div>
101
+ ) : null}
102
+ <div className="min-w-0 flex-1">
103
+ <div className="flex flex-wrap items-start justify-between gap-3">
104
+ <div className="min-w-0 flex-1 basis-40 wrap-anywhere">
105
+ {header}
106
+ </div>
107
+ {actions ? (
108
+ <div className="relative z-10 flex flex-wrap items-center gap-2">
109
+ {actions}
110
+ </div>
111
+ ) : null}
112
+ </div>
113
+ <div className="mt-5">
114
+ {layout === "hierarchy" ? headline : null}
115
+ <ProgressBar
116
+ intent={progressIntent}
117
+ isIndeterminate={isIndeterminate}
118
+ label={progressLabel}
119
+ max={max}
120
+ overlay={
121
+ layout === "band" ? headline : undefined
122
+ }
123
+ value={value}
124
+ />
125
+ </div>
126
+ {metrics.length > 0 ? (
127
+ <div className="@container">
128
+ <dl className="mt-3 grid grid-cols-1 gap-2 cq-sm:grid-cols-3">
129
+ {metrics.map(
130
+ ({ label, value: metricValue }) => (
131
+ <div
132
+ className="flex items-baseline justify-between gap-2 rounded-lg border border-border-subtle bg-surface-sunken/70 px-2.5 py-2 cq-sm:block"
133
+ key={label}
134
+ >
135
+ <dt className="min-w-0 wrap-anywhere font-semibold text-content-muted text-xs uppercase tracking-wide">
136
+ {label}
137
+ </dt>
138
+ <dd className="m-0 min-w-0 wrap-anywhere font-semibold text-lg tabular-nums cq-sm:mt-1">
139
+ {metricValue ?? "—"}
140
+ </dd>
141
+ </div>
142
+ ),
143
+ )}
144
+ </dl>
145
+ </div>
146
+ ) : null}
147
+ {children}
148
+ </div>
149
+ </div>
150
+ </Card>
151
+ )
152
+ }
package/src/index.ts CHANGED
@@ -334,6 +334,12 @@ export type {
334
334
  NavBarProps,
335
335
  } from "./NavBar/NavBar.tsx"
336
336
  export { NavBar } from "./NavBar/NavBar.tsx"
337
+ /**
338
+ * What a `Menu` in anchor mode hangs off: an element, or a virtual
339
+ * one that is only a rectangle. A context menu passes the second,
340
+ * holding the point that was pressed.
341
+ */
342
+ export type { AnchoredOverlayAnchor } from "./Overlay/useAnchoredOverlay.ts"
337
343
  export type {
338
344
  PickerOption,
339
345
  PickerProps,
@@ -358,6 +364,12 @@ export {
358
364
  toProgressPercent,
359
365
  toProgressValue,
360
366
  } from "./ProgressBar/progressValue.ts"
367
+ export type {
368
+ ProgressCardLayout,
369
+ ProgressCardMetric,
370
+ ProgressCardProps,
371
+ } from "./ProgressCard/ProgressCard.tsx"
372
+ export { ProgressCard } from "./ProgressCard/ProgressCard.tsx"
361
373
  export type {
362
374
  QueryBuilderLabels,
363
375
  QueryBuilderProps,
@@ -251,6 +251,8 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
251
251
  // answer neither, because a track is about the same width in
252
252
  // both.
253
253
  "PortraitTiles",
254
+ // Metrics query the space remaining beside the media, not the entire card.
255
+ "ProgressCard",
254
256
  // `Stepper`'s container is the one that decides whether a
255
257
  // `horizontal` sequence is actually horizontal. Below `--cq-md`
256
258
  // it falls back to the vertical column, because four steps side
@@ -612,7 +614,8 @@ test("the barrel is the only place components are re-exported", async () => {
612
614
  // consumer deciding whether an item is on screen must ask the
613
615
  // question the same way the bars are placed, or the two answers
614
616
  // drift by a day and nothing says so.
615
- expect(componentNames.length).toBe(66)
617
+ // ProgressCard composes Card and ProgressBar for progress-first dashboards.
618
+ expect(componentNames.length).toBe(67)
616
619
 
617
620
  for (const name of componentNames) {
618
621
  expect(barrel).toContain(`export { ${name} }`)