@charcuterie/ui 3.9.0 → 3.11.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 +11 -0
- package/dist/Badge/Badge.d.ts +2 -37
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +15 -30
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Badge/useBadgeShape.d.ts +57 -0
- package/dist/Badge/useBadgeShape.d.ts.map +1 -0
- package/dist/Badge/useBadgeShape.js +35 -0
- package/dist/Badge/useBadgeShape.js.map +1 -0
- package/dist/BadgeButton/BadgeButton.d.ts +59 -0
- package/dist/BadgeButton/BadgeButton.d.ts.map +1 -0
- package/dist/BadgeButton/BadgeButton.js +64 -0
- package/dist/BadgeButton/BadgeButton.js.map +1 -0
- package/dist/Card/Card.d.ts +12 -1
- package/dist/Card/Card.d.ts.map +1 -1
- package/dist/Card/Card.js +14 -2
- package/dist/Card/Card.js.map +1 -1
- package/dist/Card/cardAccentEdge.d.ts +101 -0
- package/dist/Card/cardAccentEdge.d.ts.map +1 -0
- package/dist/Card/cardAccentEdge.js +92 -0
- package/dist/Card/cardAccentEdge.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Badge/Badge.tsx +30 -113
- package/src/Badge/useBadgeShape.tsx +142 -0
- package/src/BadgeButton/BadgeButton.mdx +67 -0
- package/src/BadgeButton/BadgeButton.stories.tsx +199 -0
- package/src/BadgeButton/BadgeButton.test.tsx +164 -0
- package/src/BadgeButton/BadgeButton.tsx +129 -0
- package/src/Card/Card.mdx +21 -0
- package/src/Card/Card.stories.tsx +91 -0
- package/src/Card/Card.test.tsx +92 -1
- package/src/Card/Card.tsx +34 -0
- package/src/Card/cardAccentEdge.test.ts +105 -0
- package/src/Card/cardAccentEdge.ts +135 -0
- package/src/index.ts +4 -0
- package/src/sourceRules.test.ts +8 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
categoricalArgType,
|
|
5
|
+
intentArgType,
|
|
6
|
+
} from "../argTypes.storyHelpers.ts"
|
|
7
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
8
|
+
import {
|
|
9
|
+
StoryCell,
|
|
10
|
+
StoryGrid,
|
|
11
|
+
StoryRow,
|
|
12
|
+
StorySection,
|
|
13
|
+
} from "../board.storyHelpers.tsx"
|
|
14
|
+
import { DotIcon } from "../icons.storyHelpers.tsx"
|
|
15
|
+
import { BadgeButton } from "./BadgeButton.tsx"
|
|
16
|
+
|
|
17
|
+
const INTENTS = [
|
|
18
|
+
"neutral",
|
|
19
|
+
"accent",
|
|
20
|
+
"success",
|
|
21
|
+
"warning",
|
|
22
|
+
"danger",
|
|
23
|
+
"info",
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Components/Actions/BadgeButton",
|
|
28
|
+
component: BadgeButton,
|
|
29
|
+
parameters: { layout: "padded" },
|
|
30
|
+
argTypes: {
|
|
31
|
+
categorical: categoricalArgType,
|
|
32
|
+
intent: intentArgType,
|
|
33
|
+
},
|
|
34
|
+
// The component's own defaults, restated — Storybook does not
|
|
35
|
+
// seed `args` from docgen, so an unstated default shows in the
|
|
36
|
+
// props table with nothing selected in its control.
|
|
37
|
+
args: {
|
|
38
|
+
appearance: "soft",
|
|
39
|
+
intent: "neutral",
|
|
40
|
+
isDisabled: false,
|
|
41
|
+
onClick: () => {},
|
|
42
|
+
overflow: "truncate",
|
|
43
|
+
size: "md",
|
|
44
|
+
},
|
|
45
|
+
} satisfies Meta<typeof BadgeButton>
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
|
|
49
|
+
type Story = StoryObj<typeof meta>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A pill with a `button` role, so `getByRole("button", { name })`
|
|
53
|
+
* finds it — which is what an agent, and every test in the fleet,
|
|
54
|
+
* actually matches on.
|
|
55
|
+
*/
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
args: { children: "1 episode", intent: "accent" },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The claim this component exists to make, side by side: a
|
|
62
|
+
* `BadgeButton` and a `Badge` with identical visual props are the
|
|
63
|
+
* same pill, and one of them is a real `<button>`.
|
|
64
|
+
*
|
|
65
|
+
* `BadgeButton.test.tsx` compares their computed styles rather than
|
|
66
|
+
* trusting this board — but the board is where a human sees it.
|
|
67
|
+
*/
|
|
68
|
+
export const BesideABadge: Story = {
|
|
69
|
+
args: { children: "1 episode" },
|
|
70
|
+
render: ({ categorical, ...badgeButtonProps }) => (
|
|
71
|
+
<StorySection title="Same pill, different element. Tab reaches the left one and Enter fires it; the right one is a word about something else.">
|
|
72
|
+
<StoryRow>
|
|
73
|
+
<StoryCell label="BadgeButton — a <button>">
|
|
74
|
+
<BadgeButton {...badgeButtonProps} />
|
|
75
|
+
</StoryCell>
|
|
76
|
+
|
|
77
|
+
<StoryCell label="Badge — a <span>">
|
|
78
|
+
<Badge
|
|
79
|
+
appearance={badgeButtonProps.appearance}
|
|
80
|
+
intent={badgeButtonProps.intent}
|
|
81
|
+
size={badgeButtonProps.size}
|
|
82
|
+
>
|
|
83
|
+
1 episode
|
|
84
|
+
</Badge>
|
|
85
|
+
</StoryCell>
|
|
86
|
+
</StoryRow>
|
|
87
|
+
</StorySection>
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const AllVariants: Story = {
|
|
92
|
+
args: { children: "1 episode" },
|
|
93
|
+
// `categorical` is destructured out rather than spread: this board
|
|
94
|
+
// is about intents, and the type refuses a pill holding both — a
|
|
95
|
+
// badge is one colour.
|
|
96
|
+
render: ({ categorical, ...badgeButtonProps }) => (
|
|
97
|
+
<StorySection title="Six intents x three appearances — the same maps Badge indexes, because they are the same maps. `ghost` is excluded: a pill that paints nothing until hovered has no pill left.">
|
|
98
|
+
<StoryGrid columns={6}>
|
|
99
|
+
{(["soft", "solid", "outline"] as const).flatMap(
|
|
100
|
+
(appearance) =>
|
|
101
|
+
INTENTS.map((intent) => (
|
|
102
|
+
<StoryCell
|
|
103
|
+
key={`${appearance}-${intent}`}
|
|
104
|
+
label={`${appearance} · ${intent}`}
|
|
105
|
+
>
|
|
106
|
+
<BadgeButton
|
|
107
|
+
{...badgeButtonProps}
|
|
108
|
+
appearance={appearance}
|
|
109
|
+
intent={intent}
|
|
110
|
+
>
|
|
111
|
+
{intent}
|
|
112
|
+
</BadgeButton>
|
|
113
|
+
</StoryCell>
|
|
114
|
+
)),
|
|
115
|
+
)}
|
|
116
|
+
</StoryGrid>
|
|
117
|
+
</StorySection>
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The states a `Badge` does not have, which is the whole reason not
|
|
123
|
+
* to solve this with a `className`.
|
|
124
|
+
*/
|
|
125
|
+
export const AllStates: Story = {
|
|
126
|
+
args: { children: "1 episode" },
|
|
127
|
+
render: ({ categorical, ...badgeButtonProps }) => (
|
|
128
|
+
<StorySection title="Hover, focus and disabled — hover one, Tab to one, and read the third.">
|
|
129
|
+
<StoryGrid columns={2}>
|
|
130
|
+
<StoryCell label="sm">
|
|
131
|
+
<BadgeButton {...badgeButtonProps} size="sm" />
|
|
132
|
+
</StoryCell>
|
|
133
|
+
|
|
134
|
+
<StoryCell label="md">
|
|
135
|
+
<BadgeButton {...badgeButtonProps} size="md" />
|
|
136
|
+
</StoryCell>
|
|
137
|
+
|
|
138
|
+
<StoryCell label="with a glyph">
|
|
139
|
+
<BadgeButton
|
|
140
|
+
{...badgeButtonProps}
|
|
141
|
+
icon={<DotIcon />}
|
|
142
|
+
intent="success"
|
|
143
|
+
>
|
|
144
|
+
verified
|
|
145
|
+
</BadgeButton>
|
|
146
|
+
</StoryCell>
|
|
147
|
+
|
|
148
|
+
<StoryCell label="disabled">
|
|
149
|
+
<BadgeButton {...badgeButtonProps} isDisabled>
|
|
150
|
+
1 episode
|
|
151
|
+
</BadgeButton>
|
|
152
|
+
</StoryCell>
|
|
153
|
+
</StoryGrid>
|
|
154
|
+
</StorySection>
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Where it came from: QueuePilot's poster tile, whose per-entry
|
|
160
|
+
* settings are TAGS. A default entry says nothing and every tag you
|
|
161
|
+
* do see is a deviation worth reading — and each one opens the panel
|
|
162
|
+
* that changes it, which is why they are pressable.
|
|
163
|
+
*/
|
|
164
|
+
export const InATileFooter: Story = {
|
|
165
|
+
args: { children: "Edit" },
|
|
166
|
+
render: ({ categorical, ...badgeButtonProps }) => (
|
|
167
|
+
<StorySection title="A row of chips under a poster: three settings and the editor that changes them.">
|
|
168
|
+
<div className="flex max-w-64 flex-wrap items-center gap-1 rounded-md border border-border-subtle bg-surface-raised p-2">
|
|
169
|
+
<span className="w-full text-content-primary text-sm">
|
|
170
|
+
Blade Runner (1982)
|
|
171
|
+
</span>
|
|
172
|
+
|
|
173
|
+
<BadgeButton
|
|
174
|
+
{...badgeButtonProps}
|
|
175
|
+
intent="accent"
|
|
176
|
+
size="sm"
|
|
177
|
+
>
|
|
178
|
+
3 episodes
|
|
179
|
+
</BadgeButton>
|
|
180
|
+
|
|
181
|
+
<BadgeButton
|
|
182
|
+
{...badgeButtonProps}
|
|
183
|
+
intent="info"
|
|
184
|
+
size="sm"
|
|
185
|
+
>
|
|
186
|
+
2x weight
|
|
187
|
+
</BadgeButton>
|
|
188
|
+
|
|
189
|
+
<BadgeButton
|
|
190
|
+
{...badgeButtonProps}
|
|
191
|
+
appearance="outline"
|
|
192
|
+
size="sm"
|
|
193
|
+
>
|
|
194
|
+
Edit
|
|
195
|
+
</BadgeButton>
|
|
196
|
+
</div>
|
|
197
|
+
</StorySection>
|
|
198
|
+
),
|
|
199
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeStories,
|
|
3
|
+
composeStory,
|
|
4
|
+
} from "@storybook/react"
|
|
5
|
+
import { expect, fn, userEvent } from "storybook/test"
|
|
6
|
+
import { test } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
9
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
10
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
11
|
+
import meta, * as stories from "./BadgeButton.stories.tsx"
|
|
12
|
+
|
|
13
|
+
const { BesideABadge, Default, InATileFooter } =
|
|
14
|
+
composeStories(stories)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The properties a `<span onClick>` cannot have, and the reason this
|
|
18
|
+
* is a component rather than a `className` on `Badge`.
|
|
19
|
+
*/
|
|
20
|
+
test("is a button, not a span", async () => {
|
|
21
|
+
const { canvas } = await mountStory(Default)
|
|
22
|
+
|
|
23
|
+
const chip = expectAgentDrivable(canvas, {
|
|
24
|
+
name: "1 episode",
|
|
25
|
+
role: "button",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await expect(chip.tagName).toBe("BUTTON")
|
|
29
|
+
// Never `submit`: these chips sit inside forms, and the platform's
|
|
30
|
+
// default would make "change the start point" save the dialog.
|
|
31
|
+
await expect(chip).toHaveAttribute("type", "button")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("Tab reaches it and Enter fires it", async () => {
|
|
35
|
+
const onClick = fn()
|
|
36
|
+
|
|
37
|
+
const { canvas } = await mountStory(
|
|
38
|
+
composeStory(
|
|
39
|
+
{ args: { children: "1 episode", onClick } },
|
|
40
|
+
meta,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const chip = canvas.getByRole("button", {
|
|
45
|
+
name: "1 episode",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await userEvent.tab()
|
|
49
|
+
await expect(chip).toHaveFocus()
|
|
50
|
+
|
|
51
|
+
await userEvent.keyboard("{Enter}")
|
|
52
|
+
await expect(onClick).toHaveBeenCalledTimes(1)
|
|
53
|
+
|
|
54
|
+
// Space too — the second half of what the platform gives a
|
|
55
|
+
// `<button>` and gives a pressable `<span>` never.
|
|
56
|
+
await userEvent.keyboard(" ")
|
|
57
|
+
await expect(onClick).toHaveBeenCalledTimes(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The claim the component is named for, measured.
|
|
62
|
+
*
|
|
63
|
+
* `Badge` and `BadgeButton` both assemble their pill through
|
|
64
|
+
* `useBadgeShape`, so there is one source — but a shared hook still
|
|
65
|
+
* leaves room for a stray utility, a different base string, or a
|
|
66
|
+
* wrapper element that changes the box. Comparing **computed**
|
|
67
|
+
* styles is the version of "paints identically" that a refactor
|
|
68
|
+
* cannot quietly break.
|
|
69
|
+
*
|
|
70
|
+
* `cursor` is deliberately NOT in this list: the pressable one is a
|
|
71
|
+
* pointer and the static one is not, which is the difference being
|
|
72
|
+
* asserted elsewhere rather than a drift to catch here.
|
|
73
|
+
*/
|
|
74
|
+
const PAINTED_PROPERTIES = [
|
|
75
|
+
"background-color",
|
|
76
|
+
"border-color",
|
|
77
|
+
"border-radius",
|
|
78
|
+
"border-width",
|
|
79
|
+
"color",
|
|
80
|
+
"display",
|
|
81
|
+
"font-size",
|
|
82
|
+
"font-weight",
|
|
83
|
+
"padding-block-end",
|
|
84
|
+
"padding-block-start",
|
|
85
|
+
"padding-inline-end",
|
|
86
|
+
"padding-inline-start",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const getPaint = (element: Element) => {
|
|
90
|
+
const computed = getComputedStyle(element)
|
|
91
|
+
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
PAINTED_PROPERTIES.map((property) => [
|
|
94
|
+
property,
|
|
95
|
+
computed.getPropertyValue(property),
|
|
96
|
+
]),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("paints identically to a Badge with the same props", async () => {
|
|
101
|
+
const { canvas } = await mountStory(BesideABadge)
|
|
102
|
+
|
|
103
|
+
const chip = canvas.getByRole("button", {
|
|
104
|
+
name: "1 episode",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// The `Badge` has no role by design, so it is found by its text —
|
|
108
|
+
// which is the handle its own story asserts on.
|
|
109
|
+
const badge = canvas.getByText("1 episode", {
|
|
110
|
+
selector: "span span",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await expect(getPaint(chip)).toEqual(
|
|
114
|
+
getPaint(badge.parentElement as Element),
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("disabled is the native attribute, and it stops the click", async () => {
|
|
119
|
+
const onClick = fn()
|
|
120
|
+
|
|
121
|
+
const { canvas } = await mountStory(
|
|
122
|
+
composeStory(
|
|
123
|
+
{
|
|
124
|
+
args: {
|
|
125
|
+
children: "1 episode",
|
|
126
|
+
isDisabled: true,
|
|
127
|
+
onClick,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
meta,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const chip = canvas.getByRole("button", {
|
|
135
|
+
name: "1 episode",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// The NATIVE attribute, not `aria-disabled`: an inert `<button>`
|
|
139
|
+
// is out of the tab order and ignores a click by the platform's
|
|
140
|
+
// own rules, which is what a pill wearing `aria-disabled` and a
|
|
141
|
+
// live handler never gets.
|
|
142
|
+
await expect(chip).toBeDisabled()
|
|
143
|
+
|
|
144
|
+
await userEvent.click(chip, { pointerEventsCheck: 0 })
|
|
145
|
+
|
|
146
|
+
await expect(onClick).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("a row of chips is drivable and clean", async () => {
|
|
150
|
+
const { canvas, canvasElement } =
|
|
151
|
+
await mountStory(InATileFooter)
|
|
152
|
+
|
|
153
|
+
for (const name of ["3 episodes", "2x weight", "Edit"]) {
|
|
154
|
+
expectAgentDrivable(canvas, { name, role: "button" })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await expectNoAxeViolations(canvasElement)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("the story meta names the component", async () => {
|
|
161
|
+
await expect(meta.title).toBe(
|
|
162
|
+
"Components/Actions/BadgeButton",
|
|
163
|
+
)
|
|
164
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentPropsWithRef,
|
|
3
|
+
ReactNode,
|
|
4
|
+
} from "react"
|
|
5
|
+
|
|
6
|
+
import type { BadgeShapeProps } from "../Badge/useBadgeShape.tsx"
|
|
7
|
+
import { useBadgeShape } from "../Badge/useBadgeShape.tsx"
|
|
8
|
+
import { CATEGORICAL_HOVER_CLASS } from "../categoricalStyles.ts"
|
|
9
|
+
import {
|
|
10
|
+
DISABLED_CLASS,
|
|
11
|
+
FOCUS_RING_CLASS,
|
|
12
|
+
INTENT_HOVER_CLASS,
|
|
13
|
+
} from "../intentStyles.ts"
|
|
14
|
+
import { toClassName } from "../toClassName.ts"
|
|
15
|
+
|
|
16
|
+
export type BadgeButtonProps = Omit<
|
|
17
|
+
ComponentPropsWithRef<"button">,
|
|
18
|
+
"disabled"
|
|
19
|
+
> &
|
|
20
|
+
BadgeShapeProps & {
|
|
21
|
+
/**
|
|
22
|
+
* Required, and that is the component's argument in one line: a
|
|
23
|
+
* pill nobody can press is a `Badge`, and a pill that looks
|
|
24
|
+
* pressable and is not is the defect this exists to stop.
|
|
25
|
+
*/
|
|
26
|
+
onClick: NonNullable<
|
|
27
|
+
ComponentPropsWithRef<"button">["onClick"]
|
|
28
|
+
>
|
|
29
|
+
/**
|
|
30
|
+
* `isDisabled`, not `disabled` — the same prop `Button` keeps
|
|
31
|
+
* instead of the native one, so a control's disabled state is
|
|
32
|
+
* spelled one way across the library.
|
|
33
|
+
*/
|
|
34
|
+
isDisabled?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* See `BadgeShapeProps`. Restated only to refuse the case where
|
|
37
|
+
* both colours arrive.
|
|
38
|
+
*/
|
|
39
|
+
} & ({ categorical?: never } | { intent?: never })
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A badge you can press.
|
|
43
|
+
*
|
|
44
|
+
* A sibling component rather than a prop, exactly as `ButtonLink` is
|
|
45
|
+
* to `Button`: the paint is shared (both call `useBadgeShape`, so
|
|
46
|
+
* there is one class list and `BadgeButton.test.tsx` compares the
|
|
47
|
+
* two elements' *computed* styles rather than trusting that), and
|
|
48
|
+
* what differs is the element and everything the platform hangs off
|
|
49
|
+
* it.
|
|
50
|
+
*
|
|
51
|
+
* **Why not `asChild`, and why not `Badge` growing an `onClick`.**
|
|
52
|
+
* This library has no polymorphism pattern and has not needed one;
|
|
53
|
+
* adding it here would mean every future component answering "can it
|
|
54
|
+
* be something else?" instead of "what is it?". And an element type
|
|
55
|
+
* that changes as a *side effect* of passing a handler is a footgun
|
|
56
|
+
* with no error: forget the handler and a control silently ships as
|
|
57
|
+
* a `<span>` — not focusable, not in the tab order, invisible to
|
|
58
|
+
* `getByRole("button")` and to every agent driving the app.
|
|
59
|
+
*
|
|
60
|
+
* **What the element buys, and none of it is paint.** Focus, the tab
|
|
61
|
+
* order, Enter and Space, `:disabled`, form participation, and a
|
|
62
|
+
* `button` role a screen reader announces as pressable. QueuePilot
|
|
63
|
+
* had six pills doing all of this with a hand-rolled
|
|
64
|
+
* `<button className="badge …">`, which is where this came from —
|
|
65
|
+
* six chips that open an editor, change a start point, or drop a
|
|
66
|
+
* title from a pool.
|
|
67
|
+
*
|
|
68
|
+
* **`type="button"` by default.** These chips sit inside forms —
|
|
69
|
+
* QueuePilot's entry sheet is one — and the platform's default of
|
|
70
|
+
* `submit` would make a "change the start point" chip save the whole
|
|
71
|
+
* dialog. Overridable, because a badge-shaped submit is a real thing
|
|
72
|
+
* and the default is a safe floor rather than a ban.
|
|
73
|
+
*
|
|
74
|
+
* **`ghost` is still excluded**, inherited from `Badge`: a pill that
|
|
75
|
+
* paints nothing until hovered has no pill left, and a control that
|
|
76
|
+
* only advertises itself on hover cannot be found by touch.
|
|
77
|
+
*/
|
|
78
|
+
export const BadgeButton = ({
|
|
79
|
+
appearance = "soft",
|
|
80
|
+
categorical,
|
|
81
|
+
children,
|
|
82
|
+
className,
|
|
83
|
+
icon,
|
|
84
|
+
intent = "neutral",
|
|
85
|
+
isDisabled = false,
|
|
86
|
+
overflow,
|
|
87
|
+
size,
|
|
88
|
+
title,
|
|
89
|
+
type = "button",
|
|
90
|
+
...buttonProps
|
|
91
|
+
}: BadgeButtonProps): ReactNode => {
|
|
92
|
+
const shape = useBadgeShape({
|
|
93
|
+
appearance,
|
|
94
|
+
categorical,
|
|
95
|
+
children,
|
|
96
|
+
icon,
|
|
97
|
+
intent,
|
|
98
|
+
overflow,
|
|
99
|
+
size,
|
|
100
|
+
title,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
{...buttonProps}
|
|
106
|
+
className={toClassName(
|
|
107
|
+
shape.className,
|
|
108
|
+
"cursor-pointer",
|
|
109
|
+
// The three states the `<span>` has no business carrying,
|
|
110
|
+
// keyed the same way the appearance above was.
|
|
111
|
+
categorical === undefined
|
|
112
|
+
? INTENT_HOVER_CLASS[intent][appearance]
|
|
113
|
+
: CATEGORICAL_HOVER_CLASS[categorical][
|
|
114
|
+
appearance
|
|
115
|
+
],
|
|
116
|
+
FOCUS_RING_CLASS,
|
|
117
|
+
DISABLED_CLASS,
|
|
118
|
+
// Last, so a caller's layout beats the defaults above — the
|
|
119
|
+
// same order `getControlClassName` uses.
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
disabled={isDisabled}
|
|
123
|
+
title={shape.title}
|
|
124
|
+
type={type}
|
|
125
|
+
>
|
|
126
|
+
{shape.content}
|
|
127
|
+
</button>
|
|
128
|
+
)
|
|
129
|
+
}
|
package/src/Card/Card.mdx
CHANGED
|
@@ -51,3 +51,24 @@ The header stacks below `--cq-sm` and sits on one row above it, and the containe
|
|
|
51
51
|
cannot tell apart, and it is where the fleet's poster grids look wrong today.
|
|
52
52
|
|
|
53
53
|
<Canvas of={CardStories.Responsive} />
|
|
54
|
+
|
|
55
|
+
## The accent edge follows the corner
|
|
56
|
+
|
|
57
|
+
`accentEdge` draws a coloured bar down the leading edge. `{ categorical: 3 }` is a colour a
|
|
58
|
+
user picked, from the ten-wide contrast-audited family; `{ color }` is one the app computed,
|
|
59
|
+
for the case where ten indices are not enough — Folio hashes a repo's name so a repo added
|
|
60
|
+
tomorrow already has a hue and nobody maintains a palette.
|
|
61
|
+
|
|
62
|
+
> **It is a pseudo-element, not a border, and that is the whole point.** Three apps grew this
|
|
63
|
+
> shape independently and all three drew a straight border beside a rounded box, so the bar
|
|
64
|
+
> ran past the curve and stopped square while the card was round. A border is painted on the
|
|
65
|
+
> border box; it cannot follow a corner. The overlay here takes `border-radius: inherit`, so
|
|
66
|
+
> the bar reads the radius off whatever card it is on and nobody states one twice.
|
|
67
|
+
|
|
68
|
+
Two shapes this deliberately is not. **Not `box-shadow: inset` on the card itself** — `Card`
|
|
69
|
+
carries `shadow-low` from the elevation scale, and a `box-shadow` at the call site outranks
|
|
70
|
+
that utility and takes the elevation with it. **Not a 3px-wide pseudo-element with a rounded
|
|
71
|
+
leading edge** — a `0.5rem` radius on a 3px box is clamped to fit, so it caps the bar instead
|
|
72
|
+
of following the curve.
|
|
73
|
+
|
|
74
|
+
<Canvas of={CardStories.AccentEdge} />
|
|
@@ -13,12 +13,18 @@ import { IconButton } from "../IconButton/IconButton.tsx"
|
|
|
13
13
|
import { SettingsIcon } from "../icons.storyHelpers.tsx"
|
|
14
14
|
import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
|
|
15
15
|
import { Card, ELEVATION_CLASS } from "./Card.tsx"
|
|
16
|
+
import { ACCENT_EDGE_CATEGORICAL_CLASS } from "./cardAccentEdge.ts"
|
|
16
17
|
|
|
17
18
|
const meta = {
|
|
18
19
|
title: "Components/Layout/Card",
|
|
19
20
|
component: Card,
|
|
20
21
|
parameters: { layout: "padded" },
|
|
21
22
|
argTypes: {
|
|
23
|
+
// An object union, so an object control is the right one — but
|
|
24
|
+
// it is stated rather than inferred, because docgen's fallback
|
|
25
|
+
// for a type it cannot read is also an object control and the
|
|
26
|
+
// two are indistinguishable in the panel.
|
|
27
|
+
accentEdge: { control: "object" },
|
|
22
28
|
elevation: toStoryChoice(
|
|
23
29
|
Object.keys(
|
|
24
30
|
ELEVATION_CLASS,
|
|
@@ -205,3 +211,88 @@ export const Interactive: Story = {
|
|
|
205
211
|
</div>
|
|
206
212
|
),
|
|
207
213
|
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The leading edge, and the two answers the fleet has for what
|
|
217
|
+
* colour it is.
|
|
218
|
+
*
|
|
219
|
+
* A **categorical index** is a colour a user picked — Docket's
|
|
220
|
+
* project colours come from this ten-wide family, and every pair in
|
|
221
|
+
* it is contrast-audited. An explicit **colour** is one the app
|
|
222
|
+
* computed: Folio hashes a repo's name so a repo added tomorrow
|
|
223
|
+
* already has a hue and nobody maintains a palette, which is 360
|
|
224
|
+
* answers rather than ten.
|
|
225
|
+
*
|
|
226
|
+
* Both bars follow the card's own corner, because the treatment
|
|
227
|
+
* inherits its radius instead of naming one. Every app that grew
|
|
228
|
+
* this shape by hand drew it as a straight border beside a rounded
|
|
229
|
+
* box, and a border cannot follow a curve.
|
|
230
|
+
*/
|
|
231
|
+
export const AccentEdge: Story = {
|
|
232
|
+
args: { children: "Content" },
|
|
233
|
+
render: () => (
|
|
234
|
+
<StorySection title="A categorical index, and an app's own colour. Both take the card's radius.">
|
|
235
|
+
<StoryGrid columns={3}>
|
|
236
|
+
{(
|
|
237
|
+
Object.keys(
|
|
238
|
+
ACCENT_EDGE_CATEGORICAL_CLASS,
|
|
239
|
+
) as unknown as (keyof typeof ACCENT_EDGE_CATEGORICAL_CLASS)[]
|
|
240
|
+
).map((index) => (
|
|
241
|
+
<StoryCell
|
|
242
|
+
align="stretch"
|
|
243
|
+
key={index}
|
|
244
|
+
label={`categorical ${index}`}
|
|
245
|
+
>
|
|
246
|
+
<Card
|
|
247
|
+
accentEdge={{
|
|
248
|
+
categorical: Number(index) as 1,
|
|
249
|
+
}}
|
|
250
|
+
heading={`Project ${index}`}
|
|
251
|
+
>
|
|
252
|
+
<p className="text-content-secondary text-sm">
|
|
253
|
+
A colour the user picked.
|
|
254
|
+
</p>
|
|
255
|
+
</Card>
|
|
256
|
+
</StoryCell>
|
|
257
|
+
))}
|
|
258
|
+
|
|
259
|
+
<StoryCell align="stretch" label="a hashed hue">
|
|
260
|
+
<Card
|
|
261
|
+
accentEdge={{ color: "hsl(287 55% 52%)" }}
|
|
262
|
+
heading="home-assistant"
|
|
263
|
+
>
|
|
264
|
+
<p className="text-content-secondary text-sm">
|
|
265
|
+
A hue hashed from the name, not an index.
|
|
266
|
+
</p>
|
|
267
|
+
</Card>
|
|
268
|
+
</StoryCell>
|
|
269
|
+
|
|
270
|
+
<StoryCell
|
|
271
|
+
align="stretch"
|
|
272
|
+
label="a rounder card, same rule"
|
|
273
|
+
>
|
|
274
|
+
<Card
|
|
275
|
+
accentEdge={{ color: "hsl(24 55% 52%)" }}
|
|
276
|
+
className="rounded-3xl"
|
|
277
|
+
heading="A wider radius"
|
|
278
|
+
>
|
|
279
|
+
<p className="text-content-secondary text-sm">
|
|
280
|
+
The bar reads the radius off the card rather
|
|
281
|
+
than naming one, so an override still lines
|
|
282
|
+
up.
|
|
283
|
+
</p>
|
|
284
|
+
</Card>
|
|
285
|
+
</StoryCell>
|
|
286
|
+
|
|
287
|
+
<StoryCell align="stretch" label="a link inside it">
|
|
288
|
+
<Card
|
|
289
|
+
accentEdge={{ categorical: 4 }}
|
|
290
|
+
heading="Still clickable"
|
|
291
|
+
>
|
|
292
|
+
<Button size="sm">Open</Button>
|
|
293
|
+
</Card>
|
|
294
|
+
</StoryCell>
|
|
295
|
+
</StoryGrid>
|
|
296
|
+
</StorySection>
|
|
297
|
+
),
|
|
298
|
+
}
|