@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.
@@ -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
+ }