@charcuterie/ui 3.9.0 → 3.10.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 +10 -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/index.d.ts +3 -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/index.ts +3 -0
- package/src/sourceRules.test.ts +8 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CategoricalIndex,
|
|
3
|
+
IntentName,
|
|
4
|
+
} from "@charcuterie/tokens"
|
|
5
|
+
import type { ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
import { CATEGORICAL_APPEARANCE_CLASS } from "../categoricalStyles.ts"
|
|
8
|
+
import type { BadgeSize } from "../controlStyles.ts"
|
|
9
|
+
import { BADGE_SIZE_CLASS } from "../controlStyles.ts"
|
|
10
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
11
|
+
import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
import { useIsTextClipped } from "./useIsTextClipped.ts"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The visual half of a badge, shared by the two elements that wear
|
|
17
|
+
* it.
|
|
18
|
+
*
|
|
19
|
+
* `Badge` is a `<span>` and `BadgeButton` is a `<button>`, and the
|
|
20
|
+
* pill is supposed to be indistinguishable between them — so the
|
|
21
|
+
* class list, the icon slot, the truncation and the
|
|
22
|
+
* only-when-clipped `title` are computed **here**, once, exactly as
|
|
23
|
+
* `Button` and `ButtonLink` both call `getControlClassName`. A
|
|
24
|
+
* second copy of this arithmetic is how the two drift: the wrapping
|
|
25
|
+
* pill's `rounded-2xl` and the label's `min-w-0` are each one line,
|
|
26
|
+
* and each is load-bearing.
|
|
27
|
+
*/
|
|
28
|
+
export type BadgeShapeProps = {
|
|
29
|
+
appearance?: Exclude<IntentAppearance, "ghost">
|
|
30
|
+
/**
|
|
31
|
+
* A numbered, **non-semantic** colour: 1–10, no meaning attached.
|
|
32
|
+
*
|
|
33
|
+
* For a badge whose colour the user picked — a Docket label, a
|
|
34
|
+
* project, a chart series — where `intent` would be a lie.
|
|
35
|
+
* `getCategoricalIndex(key)` from `@charcuterie/tokens` is the
|
|
36
|
+
* stable fallback for a row nobody has chosen a colour for yet.
|
|
37
|
+
*
|
|
38
|
+
* Mutually exclusive with `intent` **in the type**: a badge is one
|
|
39
|
+
* colour, and passing both is a question with no answer rather
|
|
40
|
+
* than a precedence rule to remember.
|
|
41
|
+
*/
|
|
42
|
+
categorical?: CategoricalIndex
|
|
43
|
+
children: ReactNode
|
|
44
|
+
icon?: ReactNode
|
|
45
|
+
intent?: IntentName
|
|
46
|
+
/**
|
|
47
|
+
* What happens when the label is wider than the space it is given.
|
|
48
|
+
*
|
|
49
|
+
* - `truncate` — one line, capped at the container, ellipsis. The
|
|
50
|
+
* default, because a status pill that changes its row's height
|
|
51
|
+
* when a message gets longer breaks the table it sits in.
|
|
52
|
+
* - `wrap` — the pill grows taller and shows everything. For the
|
|
53
|
+
* kiosk and any touch context, where the hover readout that
|
|
54
|
+
* `truncate` relies on does not exist.
|
|
55
|
+
*/
|
|
56
|
+
overflow?: "truncate" | "wrap"
|
|
57
|
+
size?: BadgeSize
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const useBadgeShape = ({
|
|
61
|
+
appearance = "soft",
|
|
62
|
+
categorical,
|
|
63
|
+
children,
|
|
64
|
+
className,
|
|
65
|
+
icon,
|
|
66
|
+
intent = "neutral",
|
|
67
|
+
overflow = "truncate",
|
|
68
|
+
size = "md",
|
|
69
|
+
title,
|
|
70
|
+
}: BadgeShapeProps & {
|
|
71
|
+
className?: string
|
|
72
|
+
title?: string
|
|
73
|
+
}): {
|
|
74
|
+
className: string
|
|
75
|
+
content: ReactNode
|
|
76
|
+
title?: string
|
|
77
|
+
} => {
|
|
78
|
+
const [labelRef, clippedText] = useIsTextClipped()
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
className: toClassName(
|
|
82
|
+
"inline-flex shrink-0 items-center border font-medium",
|
|
83
|
+
// The cap. Without it every other rule here is decoration.
|
|
84
|
+
"max-w-full",
|
|
85
|
+
overflow === "wrap"
|
|
86
|
+
? // A stadium end-cap on a three-line box reads as a
|
|
87
|
+
// rendering fault rather than a badge, so the wrapping
|
|
88
|
+
// pill relaxes to a rounded rectangle.
|
|
89
|
+
"rounded-2xl"
|
|
90
|
+
: "rounded-full",
|
|
91
|
+
BADGE_SIZE_CLASS[size],
|
|
92
|
+
// A ternary rather than a merged map: the two are indexed
|
|
93
|
+
// by different keys, and `undefined` is the only honest
|
|
94
|
+
// discriminant once the type has already refused the case
|
|
95
|
+
// where both arrive.
|
|
96
|
+
categorical === undefined
|
|
97
|
+
? INTENT_APPEARANCE_CLASS[intent][appearance]
|
|
98
|
+
: CATEGORICAL_APPEARANCE_CLASS[categorical][
|
|
99
|
+
appearance
|
|
100
|
+
],
|
|
101
|
+
className,
|
|
102
|
+
),
|
|
103
|
+
content: (
|
|
104
|
+
<>
|
|
105
|
+
{icon ? (
|
|
106
|
+
<span
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
className="contents shrink-0"
|
|
109
|
+
>
|
|
110
|
+
{icon}
|
|
111
|
+
</span>
|
|
112
|
+
) : null}
|
|
113
|
+
|
|
114
|
+
{/*
|
|
115
|
+
* The truncation happens here rather than on the pill so the
|
|
116
|
+
* border and the rounded end-cap stay outside the clip — an
|
|
117
|
+
* `overflow: hidden` on the pill itself squares off the end
|
|
118
|
+
* the ellipsis is nearest to.
|
|
119
|
+
*
|
|
120
|
+
* `min-w-0` because a flex item's automatic minimum size is
|
|
121
|
+
* its content, which would win against `max-w-full` on the
|
|
122
|
+
* parent and put the overflow back.
|
|
123
|
+
*/}
|
|
124
|
+
<span
|
|
125
|
+
className={
|
|
126
|
+
overflow === "wrap"
|
|
127
|
+
? "min-w-0"
|
|
128
|
+
: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
|
129
|
+
}
|
|
130
|
+
ref={labelRef}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</span>
|
|
134
|
+
</>
|
|
135
|
+
),
|
|
136
|
+
// Only when something is actually hidden. A tooltip on every
|
|
137
|
+
// short pill in a bay list is noise, and a `title` that
|
|
138
|
+
// duplicates fully-visible text is a screen-reader duplicate
|
|
139
|
+
// for no gain.
|
|
140
|
+
title: title ?? clippedText,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as BadgeButtonStories from "./BadgeButton.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={BadgeButtonStories} />
|
|
6
|
+
|
|
7
|
+
# BadgeButton
|
|
8
|
+
|
|
9
|
+
**A badge you can press.** It takes `Badge`'s visual props and paints identically to it —
|
|
10
|
+
and it is a real `<button>`.
|
|
11
|
+
|
|
12
|
+
QueuePilot is where this came from. Its poster tiles carry per-entry settings as *tags*: a
|
|
13
|
+
default entry says nothing, and every tag you do see is a deviation worth reading. Each one
|
|
14
|
+
opens the panel that changes it, so each one is pressable — and all six were spelled
|
|
15
|
+
`<button className="badge tagbtn …">`, a hand-painted pill in app CSS, because the library
|
|
16
|
+
had no way to make `Badge` pressable. The same shape turns up on a start-point chip, a
|
|
17
|
+
group chip and a pool's Exclude chip.
|
|
18
|
+
|
|
19
|
+
<Canvas of={BadgeButtonStories.Default} />
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
|
|
23
|
+
<Controls />
|
|
24
|
+
|
|
25
|
+
## It is the same pill, not a copy of it
|
|
26
|
+
|
|
27
|
+
`Badge` and `BadgeButton` both build their pill through `useBadgeShape`, so the class list,
|
|
28
|
+
the icon slot, the truncation and the only-when-clipped `title` are read once rather than
|
|
29
|
+
duplicated. `BadgeButton.test.tsx` then compares the two elements' **computed** styles in
|
|
30
|
+
the real browser, which is the version of that claim a refactor cannot quietly break.
|
|
31
|
+
|
|
32
|
+
<Canvas of={BadgeButtonStories.BesideABadge} />
|
|
33
|
+
|
|
34
|
+
<Canvas of={BadgeButtonStories.AllVariants} />
|
|
35
|
+
|
|
36
|
+
## What the element buys, and none of it is paint
|
|
37
|
+
|
|
38
|
+
Focus, the tab order, Enter **and** Space, `:disabled`, form participation, and a `button`
|
|
39
|
+
role a screen reader announces as pressable. A `<span onClick>` has none of them, and
|
|
40
|
+
`getByRole("button", { name })` — what every test and every agent driving the app matches
|
|
41
|
+
on — finds nothing at all.
|
|
42
|
+
|
|
43
|
+
<Canvas of={BadgeButtonStories.AllStates} />
|
|
44
|
+
|
|
45
|
+
## Why not `asChild`, and why not `Badge` growing an `onClick`
|
|
46
|
+
|
|
47
|
+
This library has no polymorphism pattern and has not needed one. Adding `asChild` here would
|
|
48
|
+
make every future component answer "can it be something else?" instead of "what is it?",
|
|
49
|
+
and the precedent already exists in the other direction: `ButtonLink` is a sibling of
|
|
50
|
+
`Button` for exactly this reason.
|
|
51
|
+
|
|
52
|
+
An element type that changes as a **side effect** of passing a handler is worse. Forget the
|
|
53
|
+
handler and the control silently ships as a `<span>` — not focusable, not in the tab order,
|
|
54
|
+
invisible to `getByRole`, with no error anywhere. `onClick` is required here instead, so a
|
|
55
|
+
pill nobody can press is a `Badge` by construction.
|
|
56
|
+
|
|
57
|
+
## Two defaults worth knowing
|
|
58
|
+
|
|
59
|
+
**`type="button"`.** These chips sit inside forms — QueuePilot's entry sheet is one — and
|
|
60
|
+
the platform's default of `submit` would make a "change the start point" chip save the whole
|
|
61
|
+
dialog. It is overridable, because a badge-shaped submit is a real thing.
|
|
62
|
+
|
|
63
|
+
**`ghost` is excluded**, inherited from `Badge`: a pill that paints nothing until hovered
|
|
64
|
+
has no pill left, and a control that only advertises itself on hover cannot be found by
|
|
65
|
+
touch.
|
|
66
|
+
|
|
67
|
+
<Canvas of={BadgeButtonStories.InATileFooter} />
|
|
@@ -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
|
+
}
|