@stonedogcode/style 0.13.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/components/StyledLink.tsx +99 -2
- package/src/components/StyledTag.tsx +57 -20
- package/src/index.ts +2 -2
- package/src/preset/index.ts +3 -1
- package/src/preset/recipes/tag.ts +96 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ the whole component set re-skins at runtime. No component here knows a colour.
|
|
|
12
12
|
|
|
13
13
|
## Status
|
|
14
14
|
|
|
15
|
-
Early. The preset is complete (
|
|
15
|
+
Early. The preset is complete (23 recipes, 43 colour tokens); the component set
|
|
16
16
|
is being extracted incrementally and currently covers the layout and typography
|
|
17
17
|
primitives. See [CLAUDE.md](./CLAUDE.md) for the architecture and the
|
|
18
18
|
contribution rules.
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { buttonRecipe } from "styled-system/recipes";
|
|
5
|
-
import { cx } from "styled-system/css";
|
|
5
|
+
import { css, cx } from "styled-system/css";
|
|
6
6
|
import { useLinkComponent, useResolvedVariant } from "../config/style-config";
|
|
7
7
|
import { ALL_VARIANTS } from "../config/types";
|
|
8
8
|
|
|
@@ -17,6 +17,15 @@ import { ALL_VARIANTS } from "../config/types";
|
|
|
17
17
|
*/
|
|
18
18
|
const LINK_VARIANTS = ALL_VARIANTS;
|
|
19
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
|
+
|
|
20
29
|
export interface StyledLinkProps
|
|
21
30
|
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
22
31
|
/** Where the link goes. */
|
|
@@ -55,6 +64,61 @@ export interface StyledLinkProps
|
|
|
55
64
|
*/
|
|
56
65
|
externalIndicator?: React.ReactNode;
|
|
57
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;
|
|
58
122
|
}
|
|
59
123
|
|
|
60
124
|
/** The default external-destination glyph — "↗", north-east arrow. */
|
|
@@ -83,6 +147,8 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
|
|
|
83
147
|
rightIcon,
|
|
84
148
|
externalIndicator,
|
|
85
149
|
variant,
|
|
150
|
+
standalone = false,
|
|
151
|
+
presentation,
|
|
86
152
|
className,
|
|
87
153
|
...rest
|
|
88
154
|
},
|
|
@@ -90,7 +156,38 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
|
|
|
90
156
|
) {
|
|
91
157
|
const HostLink = useLinkComponent();
|
|
92
158
|
const resolved = useResolvedVariant(variant ?? "link", LINK_VARIANTS);
|
|
93
|
-
|
|
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
|
+
);
|
|
94
191
|
|
|
95
192
|
const indicator =
|
|
96
193
|
externalIndicator === undefined ? EXTERNAL_GLYPH : externalIndicator;
|
|
@@ -2,10 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
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";
|
|
5
22
|
|
|
6
23
|
export interface StyledTagProps
|
|
7
24
|
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect"> {
|
|
8
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;
|
|
9
50
|
/**
|
|
10
51
|
* Show a remove control, and call this when it is activated.
|
|
11
52
|
*
|
|
@@ -49,33 +90,29 @@ export interface StyledTagProps
|
|
|
49
90
|
*/
|
|
50
91
|
export const StyledTag = React.forwardRef<HTMLSpanElement, StyledTagProps>(
|
|
51
92
|
function StyledTag(
|
|
52
|
-
{
|
|
93
|
+
{
|
|
94
|
+
children,
|
|
95
|
+
tone = "neutral",
|
|
96
|
+
indicator,
|
|
97
|
+
onRemove,
|
|
98
|
+
removeLabel = "Remove",
|
|
99
|
+
className,
|
|
100
|
+
...rest
|
|
101
|
+
},
|
|
53
102
|
ref,
|
|
54
103
|
) {
|
|
55
104
|
return (
|
|
56
105
|
<span
|
|
57
106
|
ref={ref}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Vertical padding is deliberately absent: the height comes from
|
|
65
|
-
// the line box and the horizontal padding, so a tag tracks the
|
|
66
|
-
// font scale instead of needing a re-tune whenever it moves.
|
|
67
|
-
borderRadius: "md",
|
|
68
|
-
backgroundColor: "boxBgSecondary",
|
|
69
|
-
color: "textSecondary",
|
|
70
|
-
// Not a tap target: a plain tag is not interactive, so the 48px
|
|
71
|
-
// floor does not apply to it. The remove BUTTON below is, and does.
|
|
72
|
-
fontSize: "sm",
|
|
73
|
-
whiteSpace: "nowrap",
|
|
74
|
-
}),
|
|
75
|
-
className,
|
|
76
|
-
)}
|
|
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)}
|
|
77
113
|
{...rest}
|
|
78
114
|
>
|
|
115
|
+
{indicator !== undefined && <span aria-hidden="true">{indicator}</span>}
|
|
79
116
|
<span>{children}</span>
|
|
80
117
|
{onRemove !== undefined && (
|
|
81
118
|
<button
|
package/src/index.ts
CHANGED
|
@@ -204,10 +204,10 @@ export type { StyledFormLabelProps } from "./components/StyledFormLabel";
|
|
|
204
204
|
// that stops it working.
|
|
205
205
|
// ---------------------------------------------------------------------------
|
|
206
206
|
export { default as StyledLink, StyledLink as Link } from "./components/StyledLink";
|
|
207
|
-
export type { StyledLinkProps } from "./components/StyledLink";
|
|
207
|
+
export type { StyledLinkProps, LinkPresentation } from "./components/StyledLink";
|
|
208
208
|
|
|
209
209
|
export { default as StyledTag, StyledTag as Tag } from "./components/StyledTag";
|
|
210
|
-
export type { StyledTagProps } from "./components/StyledTag";
|
|
210
|
+
export type { StyledTagProps, TagTone } from "./components/StyledTag";
|
|
211
211
|
|
|
212
212
|
export {
|
|
213
213
|
default as StyledFieldErrors,
|
package/src/preset/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "./recipes/separator";
|
|
26
26
|
import { stackRecipe } from "./recipes/stack";
|
|
27
27
|
import { stripedRecipe } from "./recipes/striped";
|
|
28
|
+
import { tagRecipe } from "./recipes/tag";
|
|
28
29
|
import { textRecipe } from "./recipes/text";
|
|
29
30
|
import { tooltipRecipe } from "./recipes/tooltip";
|
|
30
31
|
|
|
@@ -83,6 +84,7 @@ const recipes = {
|
|
|
83
84
|
separatorVerticalRecipe,
|
|
84
85
|
stackRecipe,
|
|
85
86
|
stripedRecipe,
|
|
87
|
+
tagRecipe,
|
|
86
88
|
textRecipe,
|
|
87
89
|
tooltipRecipe,
|
|
88
90
|
};
|
|
@@ -135,7 +137,7 @@ const staticCssAlignment = [
|
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
139
|
* The @stonedogcode/style Panda preset: colour tokens, breakpoints, keyframes, and
|
|
138
|
-
* the
|
|
140
|
+
* the 23 recipes the component library is built on.
|
|
139
141
|
*
|
|
140
142
|
* Deliberately does NOT set `globalCss`, `preflight`, `include`, or `outdir` —
|
|
141
143
|
* those are application decisions, and a preset that quietly restyles `body` is
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defineRecipe } from "@pandacss/dev";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small label, in one of six tones.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this is a recipe and not `css()` in the component
|
|
7
|
+
*
|
|
8
|
+
* `StyledTag` painted itself with an inline `css()` call, which was right while
|
|
9
|
+
* it had exactly one appearance. A tone chosen at runtime is a different
|
|
10
|
+
* problem: Panda's extractor reads source text, so `tone={STATUS_COLOR[status]}`
|
|
11
|
+
* resolves to nothing and emits no rule — while the class name still lands in
|
|
12
|
+
* the DOM. The result is an unstyled tag, with no build error and no console
|
|
13
|
+
* warning.
|
|
14
|
+
*
|
|
15
|
+
* Recipes are the escape from that, because `staticCssRecipes` in
|
|
16
|
+
* `preset/index.ts` forces **every variant of every recipe** into the
|
|
17
|
+
* stylesheet. So a tone computed from a status map is covered for free, which is
|
|
18
|
+
* how the consuming apps actually use this (NEH-721: HopperGuard drives 109 of
|
|
19
|
+
* its 155 tags from `STATUS_COLOR[item.status]` and friends).
|
|
20
|
+
*
|
|
21
|
+
* ## The tones reuse StyledAlert's vocabulary exactly
|
|
22
|
+
*
|
|
23
|
+
* `info` / `success` / `warning` / `error` are `AlertStatus`, and each pairs the
|
|
24
|
+
* **same tokens** the alert recipe pairs. Two status vocabularies in one package
|
|
25
|
+
* — one for banners and a different one for tags — is how a product ends up with
|
|
26
|
+
* a green that means "success" in one place and "active" in another.
|
|
27
|
+
*
|
|
28
|
+
* Two tones are additional rather than borrowed:
|
|
29
|
+
*
|
|
30
|
+
* - **`neutral`** is the historical default and stays the default, so every
|
|
31
|
+
* existing call site renders exactly as it did before this variant existed.
|
|
32
|
+
* - **`accent`** has no alert equivalent, because an alert is always *about*
|
|
33
|
+
* something being fine or not. A tag is often just a category — a label, a
|
|
34
|
+
* type, a group — and forcing those into `info` would make "informational"
|
|
35
|
+
* mean nothing.
|
|
36
|
+
*
|
|
37
|
+
* ## No border, unlike the alert
|
|
38
|
+
*
|
|
39
|
+
* The alert recipe pairs each background with a `border*` token. A tag is small
|
|
40
|
+
* and usually appears in groups; a 1px edge on each turns a row of five into
|
|
41
|
+
* visual noise, and the tinted background already separates it from the page.
|
|
42
|
+
* The border tokens stay available if a consumer disagrees.
|
|
43
|
+
*/
|
|
44
|
+
export const tagRecipe = defineRecipe({
|
|
45
|
+
className: "tag",
|
|
46
|
+
base: {
|
|
47
|
+
display: "inline-flex",
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
gap: "1",
|
|
50
|
+
paddingInline: "2",
|
|
51
|
+
/*
|
|
52
|
+
* Vertical padding is deliberately absent: the height comes from the line
|
|
53
|
+
* box and the horizontal padding, so a tag tracks the font scale instead of
|
|
54
|
+
* needing a re-tune whenever it moves.
|
|
55
|
+
*/
|
|
56
|
+
borderRadius: "md",
|
|
57
|
+
/*
|
|
58
|
+
* Not a tap target. A plain tag is not interactive, so the 48px floor does
|
|
59
|
+
* not apply to it — the remove BUTTON inside `StyledTag` is, and states its
|
|
60
|
+
* own.
|
|
61
|
+
*/
|
|
62
|
+
fontSize: "sm",
|
|
63
|
+
whiteSpace: "nowrap",
|
|
64
|
+
},
|
|
65
|
+
variants: {
|
|
66
|
+
tone: {
|
|
67
|
+
neutral: {
|
|
68
|
+
backgroundColor: "boxBgSecondary",
|
|
69
|
+
color: "textSecondary",
|
|
70
|
+
},
|
|
71
|
+
info: {
|
|
72
|
+
backgroundColor: "boxInfo",
|
|
73
|
+
color: "textMain",
|
|
74
|
+
},
|
|
75
|
+
success: {
|
|
76
|
+
backgroundColor: "boxSuccess",
|
|
77
|
+
color: "textSuccess",
|
|
78
|
+
},
|
|
79
|
+
warning: {
|
|
80
|
+
backgroundColor: "boxWarning",
|
|
81
|
+
color: "textWarning",
|
|
82
|
+
},
|
|
83
|
+
error: {
|
|
84
|
+
backgroundColor: "boxError",
|
|
85
|
+
color: "textError",
|
|
86
|
+
},
|
|
87
|
+
accent: {
|
|
88
|
+
backgroundColor: "boxBgAccent",
|
|
89
|
+
color: "textMain",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
defaultVariants: {
|
|
94
|
+
tone: "neutral",
|
|
95
|
+
},
|
|
96
|
+
});
|