@stonedogcode/style 0.12.0 → 0.15.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,254 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { buttonRecipe } from "styled-system/recipes";
5
+ import { css, cx } from "styled-system/css";
6
+ import { useLinkComponent, useResolvedVariant } from "../config/style-config";
7
+ import { ALL_VARIANTS } from "../config/types";
8
+
9
+ /**
10
+ * The variants a link may take.
11
+ *
12
+ * `link` is included and is the default, which is why this list is passed to
13
+ * `useResolvedVariant` explicitly rather than letting it fall back to
14
+ * `THEME_VARIANTS`. Without it, `variant="link"` — the value nearly every call
15
+ * site wants — narrows to `solid` and every link renders as a filled button.
16
+ * That is the exact silent narrowing documented on `useResolvedVariant`.
17
+ */
18
+ const LINK_VARIANTS = ALL_VARIANTS;
19
+
20
+ /**
21
+ * How a link sits in its surroundings.
22
+ *
23
+ * `text` is the default and the safe one: a link in a sentence must not become
24
+ * a 48px control. `flow` is the layout participant. `control` is the tap
25
+ * target, and carries the house 48px floor.
26
+ */
27
+ export type LinkPresentation = "text" | "flow" | "control";
28
+
29
+ export interface StyledLinkProps
30
+ extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
31
+ /** Where the link goes. */
32
+ href: string;
33
+ children: React.ReactNode;
34
+ /**
35
+ * Marks the destination as outside this application.
36
+ *
37
+ * Two things follow, and both matter: the link renders through a plain `<a>`
38
+ * rather than the host's router (a client-side router cannot navigate to
39
+ * another origin, and handing it one is how a "link that does nothing" bug
40
+ * starts), and it gains a visible external indicator.
41
+ */
42
+ isExternal?: boolean;
43
+ /** Open in a new browsing context. */
44
+ newWindow?: boolean;
45
+ /**
46
+ * Renders the link inert.
47
+ *
48
+ * There is no `disabled` attribute for an anchor, so this removes `href` —
49
+ * which is what actually stops activation and takes the element out of the
50
+ * tab order — and states `aria-disabled` for assistive technology. Setting
51
+ * only `aria-disabled` would leave a fully working link that merely claims
52
+ * not to be.
53
+ */
54
+ disabled?: boolean;
55
+ leftIcon?: React.ReactNode;
56
+ rightIcon?: React.ReactNode;
57
+ /**
58
+ * The external-destination indicator.
59
+ *
60
+ * Defaults to a text glyph, for the reason `StyledAlert` uses one: this
61
+ * package ships no artwork, and a character inherits `currentColor` and the
62
+ * font scale, so it cannot end up a different colour or size from the label
63
+ * beside it. Pass a node to substitute an icon, or `null` for none.
64
+ */
65
+ externalIndicator?: React.ReactNode;
66
+ variant?: string;
67
+ /**
68
+ * Render as a standalone control with a 48x48 tap target, rather than as
69
+ * text inside a sentence.
70
+ *
71
+ * **The default is inline, and that is deliberate rather than a shortcut.**
72
+ * `buttonRecipe`'s base states `min-height: 48px`, `display: inline-flex`
73
+ * and padding — correct for a control, and wrong for a link in a paragraph,
74
+ * where it forces a 48px line box and breaks the text flow. Measured at
75
+ * 48.375px before this prop existed.
76
+ *
77
+ * Inline is also what the standard expects: WCAG 2.5.5 and 2.5.8 both carve
78
+ * out targets that are "in a sentence or block of text", so a text link at
79
+ * text height is conformant. The floor applies to the standalone case, and
80
+ * `standalone` is how a nav item, a card action or a button-shaped link asks
81
+ * for it.
82
+ *
83
+ * @deprecated Use `presentation` instead — `standalone` maps to
84
+ * `presentation="control"`. It is kept because it is public API and consumers
85
+ * pass it today; it will be removed once they have moved.
86
+ */
87
+ standalone?: boolean;
88
+ /**
89
+ * How the link sits in its surroundings. Three cases, because there are
90
+ * genuinely three (NEH-728).
91
+ *
92
+ * | | display | min-height | for |
93
+ * |---|---|---|---|
94
+ * | `text` (default) | `inline` | none | a link inside a sentence |
95
+ * | `flow` | `inline-flex` | none | a link that is a layout participant |
96
+ * | `control` | `inline-flex` | **48px** | a nav item, a card action |
97
+ *
98
+ * ## Why `flow` had to exist
99
+ *
100
+ * `text` and `control` look like they cover the space, and they do not. A
101
+ * link that is neither prose nor a tap target is extremely common — a row in
102
+ * a list, a cell in a grid, anything given a width by its parent — and
103
+ * HopperGuard had 67 of them (NEH-728).
104
+ *
105
+ * Neither of the other two can express it, and **both fail silently**:
106
+ *
107
+ * - `control` adds the 48px floor to links that are not tap targets, which
108
+ * changes layout everywhere it is wrong.
109
+ * - `text` sets `display: inline`, and on a non-replaced inline box **`width`
110
+ * does not apply** and **`margin-left: auto` does nothing** — so a `w` prop
111
+ * becomes a no-op and a right-hand icon loses its push-to-end. No build
112
+ * error, no type error, no warning; the link just renders wrong.
113
+ *
114
+ * `flow` is inline-flex without the floor: it takes a width, it lays its
115
+ * icons out, and it does not claim to be a 48px target when it is not.
116
+ *
117
+ * **Do not reach for `flow` to escape the tap-target floor on something that
118
+ * IS a control.** The floor is a house minimum, not a default to be routed
119
+ * around; `control` is the honest answer for anything a finger aims at.
120
+ */
121
+ presentation?: LinkPresentation;
122
+ }
123
+
124
+ /** The default external-destination glyph — "↗", north-east arrow. */
125
+ const EXTERNAL_GLYPH = "↗";
126
+
127
+ /**
128
+ * A link.
129
+ *
130
+ * ```tsx
131
+ * <StyledLink href="/settings">Settings</StyledLink>
132
+ * <StyledLink href="https://example.com" isExternal newWindow>Docs</StyledLink>
133
+ * ```
134
+ *
135
+ * In-app destinations render through the host's `linkComponent` (a plain `<a>`
136
+ * unless configured); external ones always render a plain `<a>`.
137
+ */
138
+ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
139
+ function StyledLink(
140
+ {
141
+ href,
142
+ children,
143
+ isExternal,
144
+ newWindow,
145
+ disabled,
146
+ leftIcon,
147
+ rightIcon,
148
+ externalIndicator,
149
+ variant,
150
+ standalone = false,
151
+ presentation,
152
+ className,
153
+ ...rest
154
+ },
155
+ ref,
156
+ ) {
157
+ const HostLink = useLinkComponent();
158
+ const resolved = useResolvedVariant(variant ?? "link", LINK_VARIANTS);
159
+
160
+ /*
161
+ * `presentation` wins; `standalone` is the deprecated spelling of
162
+ * `control`. Resolved in one place so there is no call site where the two
163
+ * disagree and the answer depends on which branch is read first.
164
+ */
165
+ const mode: LinkPresentation =
166
+ presentation ?? (standalone ? "control" : "text");
167
+
168
+ // The variant still comes from `buttonRecipe`, so colour, underline and
169
+ // hover stay one definition shared with every other control. Only the BOX
170
+ // is overridden — the properties that make a control a control are exactly
171
+ // the ones that break a sentence.
172
+ const classes = cx(
173
+ buttonRecipe({ variant: resolved }),
174
+ mode === "control"
175
+ ? undefined
176
+ : css({
177
+ /*
178
+ * `text` goes fully inline so it sits in a line box like any other
179
+ * word. `flow` stays a flex container: it is a layout participant,
180
+ * and on a non-replaced inline box `width` does not apply and
181
+ * `margin-left: auto` does nothing — so an inline `flow` would
182
+ * silently drop both (NEH-728).
183
+ */
184
+ display: mode === "flow" ? "inline-flex" : "inline",
185
+ minHeight: "0",
186
+ minWidth: "0",
187
+ padding: "0",
188
+ }),
189
+ className,
190
+ );
191
+
192
+ const indicator =
193
+ externalIndicator === undefined ? EXTERNAL_GLYPH : externalIndicator;
194
+
195
+ const content = (
196
+ <>
197
+ {leftIcon !== undefined && leftIcon !== null && (
198
+ <span aria-hidden="true">{leftIcon}</span>
199
+ )}
200
+ <span>{children}</span>
201
+ {rightIcon !== undefined && rightIcon !== null && (
202
+ <span aria-hidden="true">{rightIcon}</span>
203
+ )}
204
+ {isExternal && indicator !== null && (
205
+ // `aria-hidden` because the accessible name already carries the
206
+ // destination, and because "opens in a new window" is conveyed by the
207
+ // visible glyph for sighted users and by nothing useful when read
208
+ // aloud as "north east arrow".
209
+ <span aria-hidden="true">{indicator}</span>
210
+ )}
211
+ </>
212
+ );
213
+
214
+ // Shared by both branches. `href` is omitted entirely when disabled rather
215
+ // than set to "#": "#" is a live link to the top of the page, so it stays
216
+ // focusable and activating it scrolls — a disabled control that does
217
+ // something is worse than one that looks enabled.
218
+ const common = {
219
+ ...(disabled ? {} : { href }),
220
+ "aria-disabled": disabled ? true : undefined,
221
+ className: classes,
222
+ ...(newWindow
223
+ ? {
224
+ target: "_blank",
225
+ // Both, and not only for security. `noopener` severs
226
+ // `window.opener` (tabnabbing); `noreferrer` also suppresses the
227
+ // Referer header. They are separate protections and older engines
228
+ // implement only one.
229
+ rel: "noopener noreferrer",
230
+ }
231
+ : {}),
232
+ ...rest,
233
+ };
234
+
235
+ // An external destination never goes through the host's router: a
236
+ // client-side router cannot navigate off-origin, and several will
237
+ // intercept the click and do nothing at all.
238
+ if (isExternal || disabled) {
239
+ return (
240
+ <a ref={ref} {...common}>
241
+ {content}
242
+ </a>
243
+ );
244
+ }
245
+
246
+ return (
247
+ <HostLink ref={ref} href={href} {...common}>
248
+ {content}
249
+ </HostLink>
250
+ );
251
+ },
252
+ );
253
+
254
+ export default StyledLink;
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { css, cx } from "styled-system/css";
5
+
6
+ export interface StyledPageProps
7
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
8
+ children?: React.ReactNode;
9
+ /** Heading for the page. Rendered as an `<h1>` unless `titleAs` says otherwise. */
10
+ title?: React.ReactNode;
11
+ /**
12
+ * Which heading level the title takes.
13
+ *
14
+ * `h1` by default, because a page's title is the page's heading. A host
15
+ * rendering `StyledPage` inside another heading structure passes `h2`/`h3` —
16
+ * skipped or duplicated levels are a real navigation problem for screen
17
+ * reader users, and the component cannot see its own surroundings.
18
+ */
19
+ titleAs?: "h1" | "h2" | "h3";
20
+ /** Show a Save action. */
21
+ includeSave?: boolean;
22
+ onSave?: (() => void) | undefined;
23
+ /**
24
+ * What Cancel does.
25
+ *
26
+ * Defaults to going back one entry in session history — see the note on
27
+ * `goBack` below for why that needs no router.
28
+ */
29
+ onCancel?: (() => void) | undefined;
30
+ saveDisabled?: boolean;
31
+ cancelDisabled?: boolean;
32
+ saveLabel?: string;
33
+ cancelLabel?: string;
34
+ /**
35
+ * Whether there are unsaved changes.
36
+ *
37
+ * Gates both actions: Cancel appears only when there is something to
38
+ * discard, and Save is disabled until there is something to save.
39
+ */
40
+ isDirty?: boolean;
41
+ /**
42
+ * Whether the page's own content area scrolls.
43
+ *
44
+ * Defaults to `true` — meaning the content box is `overflow: hidden` and an
45
+ * element INSIDE it is expected to own the scroll. This is the surprising
46
+ * default and it is inherited deliberately: it is what the originating app's
47
+ * pages are built against, and flipping it would give every one of them a
48
+ * second scrollbar. Pass `false` to let the page scroll as one unit.
49
+ */
50
+ hideScrollbar?: boolean;
51
+ /** Optional icon nodes for the actions. This package ships no artwork. */
52
+ saveIcon?: React.ReactNode;
53
+ cancelIcon?: React.ReactNode;
54
+ }
55
+
56
+ /**
57
+ * Go back one entry in session history.
58
+ *
59
+ * **This is why `StyledPage` needs no routing seam**, and it is worth stating
60
+ * plainly because the issue that scheduled this work (NEH-430) assumed
61
+ * otherwise — it proposed passing the current path in, or a `useRouter`-shaped
62
+ * seam defaulting to `window.location`.
63
+ *
64
+ * Reading the component it replaces, the only thing it ever asked the router
65
+ * for was `router.back()`, as the default `onCancel`. And a router's `back()`
66
+ * *is* session history — Next's app-router `back()` and react-router's
67
+ * `navigate(-1)` both end up here. So this is not a degraded fallback standing
68
+ * in for the real thing; it is the same operation, reached directly.
69
+ *
70
+ * The corollary matters more than the saving: `StyleConfig` gains no field.
71
+ * Every field there is one a new host must understand before it can render a
72
+ * button, and adding one for an operation the platform already provides would
73
+ * have been a seam that bought nothing.
74
+ */
75
+ function goBack(): void {
76
+ // Guarded for SSR: this is only ever called from a click handler, so in
77
+ // practice `window` is there — but a host rendering an action on the server
78
+ // and hydrating should not crash on a missing global.
79
+ if (typeof window !== "undefined") window.history.back();
80
+ }
81
+
82
+ /**
83
+ * The shared look of the two actions.
84
+ *
85
+ * Deliberately not `StyledButton`: these need to be buttons at the 48px floor
86
+ * and nothing else, and routing them through the button recipe would make the
87
+ * page's toolbar change appearance with the app-wide variant, which is not a
88
+ * decision a page shell should be making for its host.
89
+ */
90
+ const actionClass = css({
91
+ display: "inline-flex",
92
+ alignItems: "center",
93
+ justifyContent: "center",
94
+ gap: "2",
95
+ // The house floor, stated rather than left to emerge from padding.
96
+ minHeight: "48px",
97
+ minWidth: "48px",
98
+ paddingInline: "3",
99
+ borderRadius: "md",
100
+ backgroundColor: "buttonBgPrimary",
101
+ color: "buttonTextPrimary",
102
+ cursor: "pointer",
103
+ _disabled: {
104
+ cursor: "not-allowed",
105
+ opacity: 0.6,
106
+ },
107
+ });
108
+
109
+ /**
110
+ * The standard wrapper for a page rendered into an application's body region.
111
+ *
112
+ * ```tsx
113
+ * <StyledPage title="Settings" hideScrollbar={false}>
114
+ * …
115
+ * </StyledPage>
116
+ * ```
117
+ *
118
+ * ## The layout contract, which is the load-bearing part
119
+ *
120
+ * The shell this is designed for is a fixed three-row grid — header, body,
121
+ * footer — where only the body row may scroll. `StyledPage` fills that row
122
+ * (`flex: 1; min-height: 0; overflow: hidden`) and renders a content box that
123
+ * fills what is left.
124
+ *
125
+ * `min-height: 0` is the non-obvious half and must not be "tidied" away: a flex
126
+ * child refuses to shrink below its content height without it, so the overflow
127
+ * escapes the row and pushes the footer off-screen instead of scrolling.
128
+ *
129
+ * ## Two things deliberately different from the component this replaces
130
+ *
131
+ * **The actions row no longer claims `flex: 1`.** The original set
132
+ * `flex="1" minH={0} height="100%"` on it, which asks a toolbar to fill the
133
+ * page — so it competed with the content box for the same space. A toolbar is
134
+ * `min-content` tall.
135
+ *
136
+ * **It paints through tokens.** The original set
137
+ * `backgroundColor: "var(--hopper-box-main-bg)"` inline, which hardcodes the
138
+ * default custom-property namespace and so ignores `cssVarPrefix` entirely —
139
+ * the exact defect class documented in CLAUDE.md. Under a host using another
140
+ * prefix it painted nothing at all.
141
+ */
142
+ export const StyledPage = React.forwardRef<HTMLDivElement, StyledPageProps>(
143
+ function StyledPage(
144
+ {
145
+ children,
146
+ title,
147
+ titleAs: TitleTag = "h1",
148
+ includeSave,
149
+ onSave,
150
+ onCancel,
151
+ saveDisabled,
152
+ cancelDisabled,
153
+ saveLabel = "Save",
154
+ cancelLabel = "Cancel",
155
+ isDirty = false,
156
+ hideScrollbar = true,
157
+ saveIcon,
158
+ cancelIcon,
159
+ className,
160
+ ...rest
161
+ },
162
+ ref,
163
+ ) {
164
+ const showActions = includeSave === true;
165
+
166
+ return (
167
+ <div
168
+ ref={ref}
169
+ data-testid="styled-page-root"
170
+ className={cx(
171
+ css({
172
+ display: "flex",
173
+ flexDirection: "column",
174
+ flex: "1",
175
+ width: "100%",
176
+ // See the contract note above: without this the overflow escapes
177
+ // the row rather than scrolling inside it.
178
+ minHeight: "0",
179
+ overflow: "hidden",
180
+ backgroundColor: "boxBgMain",
181
+ // `textMain`, not `textPrimary`. These tokens are a SURFACE axis:
182
+ // `textPrimary` means "text on the PRIMARY surface" and pairing it
183
+ // with `boxBgMain` is the mistake TEXT_BACKGROUND_PAIRS exists to
184
+ // prevent — it would render a colour never contrast-checked against
185
+ // this background. The original's raw properties were
186
+ // `box-main-bg` / `box-main-text`, which is this pair.
187
+ color: "textMain",
188
+ }),
189
+ className,
190
+ )}
191
+ {...rest}
192
+ >
193
+ {title !== undefined && title !== null && (
194
+ <TitleTag
195
+ data-testid="styled-page-title"
196
+ className={css({
197
+ // min-content by construction: a heading is not a flex item that
198
+ // should grow. Stated as flexShrink/flexGrow rather than left
199
+ // implicit because the surrounding column makes `flex: 1` the
200
+ // thing a reader expects to see.
201
+ flexGrow: 0,
202
+ flexShrink: 0,
203
+ fontSize: "xl",
204
+ fontWeight: "bold",
205
+ })}
206
+ >
207
+ {title}
208
+ </TitleTag>
209
+ )}
210
+
211
+ {showActions && (
212
+ <div
213
+ data-testid="styled-page-actions"
214
+ className={css({
215
+ display: "flex",
216
+ flexDirection: "row",
217
+ justifyContent: "flex-end",
218
+ alignItems: "center",
219
+ gap: "2",
220
+ // A toolbar is min-content tall. The original asked for flex:1
221
+ // and height:100%, which made it compete with the content.
222
+ flexGrow: 0,
223
+ flexShrink: 0,
224
+ })}
225
+ >
226
+ {/*
227
+ Cancel appears only when there is something to discard. Rendering
228
+ a disabled Cancel on a pristine page is a control that can never
229
+ do anything, and "discard nothing" is not an action.
230
+ */}
231
+ {isDirty && (
232
+ <button
233
+ type="button"
234
+ onClick={onCancel ?? goBack}
235
+ disabled={cancelDisabled}
236
+ className={actionClass}
237
+ >
238
+ {cancelIcon}
239
+ {cancelLabel}
240
+ </button>
241
+ )}
242
+ <button
243
+ type="button"
244
+ onClick={onSave}
245
+ // Disabled until there is something to save. `saveDisabled` is
246
+ // the host's own veto and is independent of dirtiness.
247
+ disabled={saveDisabled === true || !isDirty}
248
+ className={actionClass}
249
+ >
250
+ {saveIcon}
251
+ {saveLabel}
252
+ </button>
253
+ </div>
254
+ )}
255
+
256
+ <div
257
+ data-testid="styled-page-content"
258
+ className={css({
259
+ display: "flex",
260
+ flexDirection: "column",
261
+ alignItems: "stretch",
262
+ flex: "1",
263
+ minHeight: "0",
264
+ })}
265
+ style={{ overflow: hideScrollbar ? "hidden" : "auto" }}
266
+ >
267
+ {children}
268
+ </div>
269
+ </div>
270
+ );
271
+ },
272
+ );
273
+
274
+ export default StyledPage;
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { css, cx } from "styled-system/css";
5
+ import { tagRecipe } from "styled-system/recipes";
6
+ import type { AlertStatus } from "./StyledAlert";
7
+
8
+ /**
9
+ * A tag's tone.
10
+ *
11
+ * The four status names ARE `AlertStatus`, referenced rather than retyped, so
12
+ * the package cannot drift into two status vocabularies — a green that means
13
+ * "success" on a banner and "active" on a tag is the kind of divergence nobody
14
+ * notices until a product has both.
15
+ *
16
+ * `neutral` and `accent` extend it. `neutral` is the historical appearance and
17
+ * stays the default; `accent` exists because a tag is frequently just a
18
+ * *category* — a type, a group, a label — and forcing those into `info` would
19
+ * make "informational" mean nothing.
20
+ */
21
+ export type TagTone = "neutral" | AlertStatus | "accent";
22
+
23
+ export interface StyledTagProps
24
+ extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect"> {
25
+ children: React.ReactNode;
26
+ /**
27
+ * The tag's colour, carrying meaning.
28
+ *
29
+ * ## Colour must not be the only signal (WCAG 1.4.1, Level A)
30
+ *
31
+ * Usually it is not, and that is why there is no forced glyph here: a tag
32
+ * generally *is* its label, so `<StyledTag tone="success">Enabled</StyledTag>`
33
+ * says "enabled" in words and the colour merely reinforces it. `StyledAlert`
34
+ * needs a glyph because a banner's status is genuinely carried by its
35
+ * colouring; a tag's is carried by its text.
36
+ *
37
+ * **The exception is a tag whose label does not name its own state** — a
38
+ * feature name tinted green for on and grey for off, say. There the colour is
39
+ * the only signal and the criterion is unmet, so pass `indicator`.
40
+ */
41
+ tone?: TagTone;
42
+ /**
43
+ * A non-colour signal rendered before the label.
44
+ *
45
+ * Deliberately not defaulted per tone. See `tone` above: defaulting one would
46
+ * put a glyph on every tag in every consumer to fix the minority of cases
47
+ * where the label does not already say what the colour says.
48
+ */
49
+ indicator?: React.ReactNode;
50
+ /**
51
+ * Show a remove control, and call this when it is activated.
52
+ *
53
+ * Omitting it renders a plain, non-interactive tag — which is what the great
54
+ * majority of call sites want. A tag that is merely a label should not be
55
+ * focusable, and should not offer a button that does nothing.
56
+ */
57
+ onRemove?: (() => void) | undefined;
58
+ /**
59
+ * The remove button's accessible name.
60
+ *
61
+ * Defaults to `Remove` — deliberately generic, because the component cannot
62
+ * see the label text as a string (children may be any node) and inventing
63
+ * "Remove {children}" from a React tree produces "Remove [object Object]" as
64
+ * often as it produces something useful. A call site with a plain text label
65
+ * should pass the specific form; a list of tags all announcing "Remove" is
66
+ * navigable but tedious.
67
+ */
68
+ removeLabel?: string;
69
+ }
70
+
71
+ /**
72
+ * A small label, optionally removable.
73
+ *
74
+ * ```tsx
75
+ * <StyledTag>Draft</StyledTag>
76
+ * <StyledTag onRemove={() => drop(id)} removeLabel="Remove tag Draft">Draft</StyledTag>
77
+ * ```
78
+ *
79
+ * ## Why this is a `<span>` and not a compound component
80
+ *
81
+ * The version this replaces was built on `@ark-ui/react`, which is what kept it
82
+ * out of this package — a dependency imposed on every consumer, one of them a
83
+ * proprietary SaaS and one AGPLv3.
84
+ *
85
+ * The judgement (NEH-430, and the same one the dropdown got) is that the
86
+ * library buys interactive/dismissible behaviour that a span with a close
87
+ * button also provides, and usually provides *more* accessibly, because there
88
+ * is no reimplemented focus management to get wrong. A host that genuinely
89
+ * needs the compound version composes it locally, at the one call site.
90
+ */
91
+ export const StyledTag = React.forwardRef<HTMLSpanElement, StyledTagProps>(
92
+ function StyledTag(
93
+ {
94
+ children,
95
+ tone = "neutral",
96
+ indicator,
97
+ onRemove,
98
+ removeLabel = "Remove",
99
+ className,
100
+ ...rest
101
+ },
102
+ ref,
103
+ ) {
104
+ return (
105
+ <span
106
+ ref={ref}
107
+ /*
108
+ * The recipe, not an inline `css()` — see `preset/recipes/tag.ts`. A
109
+ * tone computed at runtime (`tone={STATUS_COLOR[status]}`) is invisible
110
+ * to Panda's extractor, and `staticCssRecipes` is what covers it.
111
+ */
112
+ className={cx(tagRecipe({ tone }), className)}
113
+ {...rest}
114
+ >
115
+ {indicator !== undefined && <span aria-hidden="true">{indicator}</span>}
116
+ <span>{children}</span>
117
+ {onRemove !== undefined && (
118
+ <button
119
+ type="button"
120
+ onClick={onRemove}
121
+ aria-label={removeLabel}
122
+ className={css({
123
+ display: "inline-flex",
124
+ alignItems: "center",
125
+ justifyContent: "center",
126
+ // The house floor, stated rather than left to emerge from
127
+ // padding — see CLAUDE.md. A remove control inside a small tag is
128
+ // exactly where a hit area silently shrinks below it.
129
+ minWidth: "48px",
130
+ minHeight: "48px",
131
+ // The visible glyph stays small while the hit area does not, so
132
+ // the tag does not become 48px tall to hold its own button.
133
+ marginBlock: "-3",
134
+ marginInlineEnd: "-2",
135
+ background: "transparent",
136
+ border: "none",
137
+ cursor: "pointer",
138
+ color: "inherit",
139
+ })}
140
+ >
141
+ {/*
142
+ `aria-hidden` so the button announces its `aria-label` alone. A
143
+ multiplication sign, not a letter x: it is the correct glyph and
144
+ a screen reader that ignores the hiding reads "times" rather
145
+ than "x", which at least is not a letter of the label.
146
+ */}
147
+ <span aria-hidden="true">×</span>
148
+ </button>
149
+ )}
150
+ </span>
151
+ );
152
+ },
153
+ );
154
+
155
+ export default StyledTag;