@stonedogcode/style 0.10.1 → 0.13.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 +59 -7
- package/package.json +2 -2
- package/src/components/StyledAlert.tsx +120 -0
- package/src/components/StyledConfetti.tsx +274 -0
- package/src/components/StyledFieldErrors.tsx +94 -0
- package/src/components/StyledForm.tsx +75 -0
- package/src/components/StyledLink.tsx +157 -0
- package/src/components/StyledPage.tsx +274 -0
- package/src/components/StyledTag.tsx +118 -0
- package/src/components/StyledText.tsx +2 -10
- package/src/config/font-size.ts +36 -0
- package/src/config/link-component.tsx +69 -0
- package/src/config/style-config.tsx +39 -1
- package/src/index.ts +45 -0
- package/src/preset/index.ts +31 -1
- package/src/preset/recipes/alert.ts +95 -0
- package/src/preset/recipes/box.ts +6 -0
- package/src/preset/recipes/button.ts +12 -0
- package/src/preset/recipes/icon.ts +13 -1
- package/src/preset/recipes/input-bool.ts +111 -10
- package/src/preset/recipes/input-radio.ts +11 -1
- package/src/preset/recipes/menu.ts +9 -0
- package/src/preset/recipes/stack.ts +7 -0
- package/src/preset/semantic-variables.ts +190 -9
package/README.md
CHANGED
|
@@ -82,7 +82,8 @@ token reads one, and **a token whose property is undefined renders as nothing**
|
|
|
82
82
|
no fallback, no warning, no error. An app that skips this compiles, builds,
|
|
83
83
|
serves, and shows you a blank page.
|
|
84
84
|
|
|
85
|
-
There are **
|
|
85
|
+
There are **45** of them. Get the list at runtime rather than copying one — a
|
|
86
|
+
number in prose goes stale the day a token is added, and this one already had:
|
|
86
87
|
|
|
87
88
|
```ts
|
|
88
89
|
import { requiredCssCustomProperties } from "@stonedogcode/style/preset";
|
|
@@ -91,7 +92,57 @@ requiredCssCustomProperties(); // --hopper-* (default)
|
|
|
91
92
|
requiredCssCustomProperties("optima"); // --optima-*, if you set cssVarPrefix
|
|
92
93
|
```
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
### 8 colour tokens you do NOT have to define
|
|
96
|
+
|
|
97
|
+
`textMuted` and `textSubtle` are the **emphasis** axis — how important a piece
|
|
98
|
+
of text is, on whatever surface it sits. They are the one exception to the
|
|
99
|
+
no-fallback rule above, and they are not in `requiredCssCustomProperties()`.
|
|
100
|
+
|
|
101
|
+
They can have a default because *"like the surrounding text, but quieter"* has a
|
|
102
|
+
correct answer on every theme, which *"what colour is this surface?"* does not:
|
|
103
|
+
|
|
104
|
+
```css
|
|
105
|
+
color-mix(in srgb, currentColor 78%, transparent) /* textMuted */
|
|
106
|
+
color-mix(in srgb, currentColor 64%, transparent) /* textSubtle */
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`currentColor` inside a `color` declaration resolves to the **inherited** value,
|
|
110
|
+
so both follow whatever text they sit among — light theme, dark theme, or a
|
|
111
|
+
palette this package has never seen. Both clear WCAG AA against the surfaces
|
|
112
|
+
they pair with, measured in a browser rather than chosen by eye.
|
|
113
|
+
|
|
114
|
+
Define `--<prefix>-text-muted-text` / `--<prefix>-text-subtle-text` only if you
|
|
115
|
+
want a different step.
|
|
116
|
+
|
|
117
|
+
### ...and six more, for status
|
|
118
|
+
|
|
119
|
+
`boxSuccess` / `boxWarning` / `boxError` and their `borderSuccess` /
|
|
120
|
+
`borderWarning` / `borderError` are the chips `StyledAlert` paints on. They are
|
|
121
|
+
defaulted for the same reason and a slightly different one: **danger-red,
|
|
122
|
+
caution-amber and success-green are near-universal**, so a default is knowable
|
|
123
|
+
where "what colour is this surface?" is not.
|
|
124
|
+
|
|
125
|
+
The hue is fixed and the *lightness* is not, which is the split that matters —
|
|
126
|
+
red has to stay red to mean danger, but how light that red sits has to follow
|
|
127
|
+
the page:
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
color-mix(in srgb, #dc2626 14%, transparent) /* boxError — tints the page */
|
|
131
|
+
#dc2626 /* borderError — a solid hue */
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The borders are solid because a translucent one cannot clear WCAG 1.4.11 (3:1
|
|
135
|
+
for a non-text boundary): at 45% they measured 1.72–2.05:1 against a dark page.
|
|
136
|
+
A saturated mid-tone clears 3:1 at both ends, which is what lets one value serve
|
|
137
|
+
a light theme and a dark one.
|
|
138
|
+
|
|
139
|
+
Define `--<prefix>-box-{success,warning,error}-{bg,border}` to use your own.
|
|
140
|
+
|
|
141
|
+
**Do not reach for `textSecondary` when you mean "muted".** That is the *surface*
|
|
142
|
+
axis — it means "text on the secondary surface" — and using it for emphasis
|
|
143
|
+
collapses two levels onto one colour.
|
|
144
|
+
|
|
145
|
+
A complete starter theme — all 45, nothing elided. Dark, and every text/surface
|
|
95
146
|
pair clears WCAG AA (measured: worst 5.17:1, ten of thirteen pairs at AAA), so
|
|
96
147
|
it is a legitimate starting point rather than a placeholder. Replace the values;
|
|
97
148
|
keep every key.
|
|
@@ -115,6 +166,7 @@ keep every key.
|
|
|
115
166
|
--hopper-text-pop-text: #38bdf8;
|
|
116
167
|
--hopper-text-error-text: #f87171;
|
|
117
168
|
--hopper-text-warning-text: #fbbf24;
|
|
169
|
+
--hopper-text-success-text: #4ade80;
|
|
118
170
|
|
|
119
171
|
/* Borders */
|
|
120
172
|
--hopper-box-primary-border: #475569;
|
|
@@ -193,7 +245,7 @@ the three ways this goes wrong silently:
|
|
|
193
245
|
```bash
|
|
194
246
|
npx panda cssgen --outfile styled-system/styles.css
|
|
195
247
|
|
|
196
|
-
# 1. Did the preset load? Expect ~
|
|
248
|
+
# 1. Did the preset load? Expect ~45 matches, not 0.
|
|
197
249
|
grep -c 'var(--hopper-' styled-system/styles.css
|
|
198
250
|
|
|
199
251
|
# 2. Did Panda parse the package's source? Expect ~240 classes, not ~0.
|
|
@@ -278,7 +330,7 @@ stonedogStylePreset({ cssVarPrefix: "acme" }); // → var(--acme-box-primary-bg)
|
|
|
278
330
|
|
|
279
331
|
The rename is total — every token re-points, and no `--hopper-*` reference
|
|
280
332
|
survives anywhere in the generated CSS. Choose it **before** you write a theme,
|
|
281
|
-
because it changes all
|
|
333
|
+
because it changes all 45 property names you have to define.
|
|
282
334
|
|
|
283
335
|
## Adopting it in a new app — a worked example
|
|
284
336
|
|
|
@@ -361,7 +413,7 @@ export default defineConfig({
|
|
|
361
413
|
|
|
362
414
|
```tsx
|
|
363
415
|
// 4. Your root — theme first, then the provider
|
|
364
|
-
import "./theme.css"; // the
|
|
416
|
+
import "./theme.css"; // the 45 properties, from step 3 above
|
|
365
417
|
import { StonedogStyleProvider } from "@stonedogcode/style";
|
|
366
418
|
|
|
367
419
|
export function Root({ children }) {
|
|
@@ -579,7 +631,7 @@ rail simply grows — which is correct, and is not a bug.
|
|
|
579
631
|
|
|
580
632
|
## Adopting a component as it is migrated
|
|
581
633
|
|
|
582
|
-
Components move out of HopperGuard into this package one at a time
|
|
634
|
+
Components move out of HopperGuard into this package one at a time.
|
|
583
635
|
Each lands as its own release, so consumers adopt on their own schedule rather
|
|
584
636
|
than waiting for a big-bang switch.
|
|
585
637
|
|
|
@@ -640,7 +692,7 @@ import { StyledSpinner } from "@stonedogcode/style";
|
|
|
640
692
|
`optima-filings` is public and AGPLv3 and ships a public Docker image, so it
|
|
641
693
|
uses a **permissive icon set** (Lucide) through the icon seam rather than the
|
|
642
694
|
private Font Awesome package. Everything else is shared. Both Optima repos run
|
|
643
|
-
their own `--optima-*` namespace via `cssVarPrefix
|
|
695
|
+
their own `--optima-*` namespace via `cssVarPrefix`.
|
|
644
696
|
|
|
645
697
|
### Verify — the three checks that actually catch things
|
|
646
698
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "StoneDogCode L.L.C.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/stonedog-code/stonedog-style.git"
|
|
10
10
|
},
|
|
11
|
-
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer — which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of
|
|
11
|
+
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer — which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { alertRecipe } from "styled-system/recipes";
|
|
5
|
+
import { cx } from "styled-system/css";
|
|
6
|
+
|
|
7
|
+
/** The four things an alert can be. */
|
|
8
|
+
export type AlertStatus = "info" | "success" | "warning" | "error";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which ARIA role each status takes, and why they are not all the same.
|
|
12
|
+
*
|
|
13
|
+
* The difference is whether the announcement **interrupts**. `alert` is an
|
|
14
|
+
* assertive live region: a screen reader abandons what it was saying to read it.
|
|
15
|
+
* That is right for a failure the user must deal with and wrong for a
|
|
16
|
+
* confirmation — being interrupted mid-sentence to be told something worked is
|
|
17
|
+
* hostile, and doing it for every status trains people to ignore the important
|
|
18
|
+
* ones.
|
|
19
|
+
*
|
|
20
|
+
* The original component had **no role at all**, so none of the four was
|
|
21
|
+
* announced. An alert that is not announced is not an alert (NEH-421).
|
|
22
|
+
*/
|
|
23
|
+
const ROLE_FOR_STATUS: Record<AlertStatus, "alert" | "status"> = {
|
|
24
|
+
error: "alert",
|
|
25
|
+
warning: "alert",
|
|
26
|
+
info: "status",
|
|
27
|
+
success: "status",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The non-colour signal for each status — WCAG 1.4.1 (Use of Colour), Level A.
|
|
32
|
+
*
|
|
33
|
+
* The original conveyed status by background colour alone. The `Indicator` slot
|
|
34
|
+
* existed and every call site left it empty, so the whole distinction was
|
|
35
|
+
* invisible to anyone who cannot separate four pale washes — which includes
|
|
36
|
+
* roughly one man in twelve, and anyone on a bad screen in daylight.
|
|
37
|
+
*
|
|
38
|
+
* These are text characters rather than icons on purpose. This package ships no
|
|
39
|
+
* artwork by policy, and a glyph inherits `currentColor` and the font scale for
|
|
40
|
+
* free, so the signal survives a font-size change and cannot end up a different
|
|
41
|
+
* colour from the message beside it.
|
|
42
|
+
*
|
|
43
|
+
* They are `aria-hidden`: the role above already tells a screen reader what kind
|
|
44
|
+
* of message this is, and reading "warning sign" before the text would be the
|
|
45
|
+
* same information twice.
|
|
46
|
+
*/
|
|
47
|
+
const GLYPH_FOR_STATUS: Record<AlertStatus, string> = {
|
|
48
|
+
info: "i",
|
|
49
|
+
success: "✓",
|
|
50
|
+
warning: "!",
|
|
51
|
+
error: "✕",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface StyledAlertProps
|
|
55
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
56
|
+
/** Which kind of message this is. Decides colour, glyph AND announcement. */
|
|
57
|
+
status?: AlertStatus;
|
|
58
|
+
/** Optional heading, shown above the message in bold. */
|
|
59
|
+
title?: React.ReactNode;
|
|
60
|
+
/**
|
|
61
|
+
* Replace the built-in glyph.
|
|
62
|
+
*
|
|
63
|
+
* Pass a node to substitute your own icon, or `null` to render no indicator.
|
|
64
|
+
* `null` is deliberately possible and deliberately awkward to reach for: it
|
|
65
|
+
* puts the component back in breach of WCAG 1.4.1 unless the surrounding UI
|
|
66
|
+
* carries the signal some other way.
|
|
67
|
+
*/
|
|
68
|
+
indicator?: React.ReactNode;
|
|
69
|
+
children?: React.ReactNode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A status banner.
|
|
74
|
+
*
|
|
75
|
+
* ```tsx
|
|
76
|
+
* <StyledAlert status="error" title="Something went wrong">
|
|
77
|
+
* We could not save your changes.
|
|
78
|
+
* </StyledAlert>
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export const StyledAlert = React.forwardRef<HTMLDivElement, StyledAlertProps>(
|
|
82
|
+
function StyledAlert(
|
|
83
|
+
{ status = "info", title, indicator, children, className, ...rest },
|
|
84
|
+
ref,
|
|
85
|
+
) {
|
|
86
|
+
const classes = alertRecipe({ status });
|
|
87
|
+
const glyph = indicator === undefined ? GLYPH_FOR_STATUS[status] : indicator;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
ref={ref}
|
|
92
|
+
// `role` and `aria-live` together: the role carries the semantics, and
|
|
93
|
+
// the explicit `aria-live` is what makes an alert rendered into an
|
|
94
|
+
// already-present region announce when its CONTENT changes rather than
|
|
95
|
+
// only when it mounts. A banner that swaps "saving" for "failed" in
|
|
96
|
+
// place is the common case and the one that otherwise goes silent.
|
|
97
|
+
role={ROLE_FOR_STATUS[status]}
|
|
98
|
+
aria-live={ROLE_FOR_STATUS[status] === "alert" ? "assertive" : "polite"}
|
|
99
|
+
className={cx(classes.root, className)}
|
|
100
|
+
{...rest}
|
|
101
|
+
>
|
|
102
|
+
{glyph !== null && (
|
|
103
|
+
<span aria-hidden="true" className={classes.indicator}>
|
|
104
|
+
{glyph}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
<div className={classes.content}>
|
|
108
|
+
{title !== undefined && title !== null && (
|
|
109
|
+
<div className={classes.title}>{title}</div>
|
|
110
|
+
)}
|
|
111
|
+
{children !== undefined && children !== null && (
|
|
112
|
+
<div className={classes.description}>{children}</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export default StyledAlert;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { css, cx } from "styled-system/css";
|
|
5
|
+
|
|
6
|
+
/** What a celebration is asked for. */
|
|
7
|
+
export interface CelebrateOptions {
|
|
8
|
+
/** How many pieces to throw. */
|
|
9
|
+
particleCount: number;
|
|
10
|
+
/** Render these characters instead of coloured pieces. */
|
|
11
|
+
emojis?: ReadonlyArray<string> | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A host's own celebration.
|
|
16
|
+
*
|
|
17
|
+
* This is the seam that replaced a `js-confetti` import (NEH-430). A host that
|
|
18
|
+
* wants that library — or canvas-confetti, or a Lottie animation — passes a
|
|
19
|
+
* function; everyone else gets the CSS burst below.
|
|
20
|
+
*
|
|
21
|
+
* It may return a promise, in which case `onComplete` fires when it settles.
|
|
22
|
+
* A rejection is deliberately NOT propagated: a celebration that fails is not
|
|
23
|
+
* an error the user needs, and the surrounding flow (a save, a signup) has
|
|
24
|
+
* already succeeded by the time anything fires confetti.
|
|
25
|
+
*/
|
|
26
|
+
export type CelebrateFn = (
|
|
27
|
+
options: CelebrateOptions,
|
|
28
|
+
) => void | Promise<unknown>;
|
|
29
|
+
|
|
30
|
+
/** How long the default burst runs, in ms. Also the `onComplete` delay. */
|
|
31
|
+
const BURST_MS = 1200;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tokens the default burst cycles through.
|
|
35
|
+
*
|
|
36
|
+
* Theme tokens rather than literal colours, so a celebration is on-brand and
|
|
37
|
+
* follows dark mode — and so this component does not become the one place in
|
|
38
|
+
* the package that knows a hex value.
|
|
39
|
+
*/
|
|
40
|
+
const PARTICLE_TOKENS = [
|
|
41
|
+
"boxBgAccent",
|
|
42
|
+
"boxBgPrimary",
|
|
43
|
+
"boxBgSecondary",
|
|
44
|
+
"textAccent",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One pre-built class per particle colour.
|
|
49
|
+
*
|
|
50
|
+
* These are written out as four literal `css()` calls rather than generated in
|
|
51
|
+
* the render loop, and that is a requirement rather than a style preference:
|
|
52
|
+
* **Panda extracts styles by parsing source statically**, so
|
|
53
|
+
* `css({ backgroundColor: token })` — with `token` a variable — resolves to
|
|
54
|
+
* nothing and emits no rule, while the class name still lands in the DOM. The
|
|
55
|
+
* particles would be invisible, with no build error and nothing in the console.
|
|
56
|
+
* It is the same trap the CLAUDE.md note about `width={metrics.mark}` records.
|
|
57
|
+
*/
|
|
58
|
+
const PARTICLE_CLASS: Record<(typeof PARTICLE_TOKENS)[number], string> = {
|
|
59
|
+
boxBgAccent: css({ backgroundColor: "boxBgAccent" }),
|
|
60
|
+
boxBgPrimary: css({ backgroundColor: "boxBgPrimary" }),
|
|
61
|
+
boxBgSecondary: css({ backgroundColor: "boxBgSecondary" }),
|
|
62
|
+
textAccent: css({ backgroundColor: "textAccent" }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
interface Particle {
|
|
66
|
+
id: number;
|
|
67
|
+
dx: string;
|
|
68
|
+
dy: string;
|
|
69
|
+
rot: string;
|
|
70
|
+
delay: string;
|
|
71
|
+
token: (typeof PARTICLE_TOKENS)[number];
|
|
72
|
+
emoji: string | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildParticles(
|
|
76
|
+
count: number,
|
|
77
|
+
emojis: ReadonlyArray<string> | undefined,
|
|
78
|
+
): Particle[] {
|
|
79
|
+
const particles: Particle[] = [];
|
|
80
|
+
for (let i = 0; i < count; i += 1) {
|
|
81
|
+
// Upward-biased spread: real confetti is thrown up and falls, so a
|
|
82
|
+
// symmetric circle reads as an explosion rather than a celebration.
|
|
83
|
+
const angle = Math.PI + Math.random() * Math.PI;
|
|
84
|
+
const distance = 80 + Math.random() * 160;
|
|
85
|
+
particles.push({
|
|
86
|
+
id: i,
|
|
87
|
+
dx: `${Math.cos(angle) * distance}px`,
|
|
88
|
+
dy: `${Math.sin(angle) * distance}px`,
|
|
89
|
+
rot: `${Math.random() * 720 - 360}deg`,
|
|
90
|
+
delay: `${Math.random() * 150}ms`,
|
|
91
|
+
token: PARTICLE_TOKENS[i % PARTICLE_TOKENS.length]!,
|
|
92
|
+
emoji: emojis && emojis.length > 0 ? emojis[i % emojis.length] : undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return particles;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the user has asked for less motion. */
|
|
99
|
+
function prefersReducedMotion(): boolean {
|
|
100
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
101
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface StyledConfettiProps {
|
|
105
|
+
/** Rising edge fires the celebration. Falling edge re-arms it. */
|
|
106
|
+
trigger?: boolean;
|
|
107
|
+
particleCount?: number;
|
|
108
|
+
emojis?: ReadonlyArray<string> | undefined;
|
|
109
|
+
/** Swap in a host implementation — see `CelebrateFn`. */
|
|
110
|
+
celebrate?: CelebrateFn | undefined;
|
|
111
|
+
/** Fires when the celebration has finished, however it was rendered. */
|
|
112
|
+
onComplete?: (() => void) | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A celebration, fired by a rising edge on `trigger`.
|
|
117
|
+
*
|
|
118
|
+
* ```tsx
|
|
119
|
+
* <StyledConfetti trigger={saved} onComplete={() => setSaved(false)} />
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* ## The default is a real burst, not a no-op
|
|
123
|
+
*
|
|
124
|
+
* The component this replaces imported `js-confetti` — a dependency on every
|
|
125
|
+
* consumer for a decoration three call sites use. The seam is `celebrate`; the
|
|
126
|
+
* default is a CSS-only burst of themed particles, which needs no canvas, no
|
|
127
|
+
* library, and no host wiring.
|
|
128
|
+
*
|
|
129
|
+
* A no-op default was the other option the issue offered and would have been
|
|
130
|
+
* the weaker one: "nothing happens" is indistinguishable from "the seam is
|
|
131
|
+
* broken", and it is the reading someone reaches for first.
|
|
132
|
+
*
|
|
133
|
+
* ## It honours `prefers-reduced-motion`, and by skipping rather than shortening
|
|
134
|
+
*
|
|
135
|
+
* Confetti is purely decorative — it carries no information — which is exactly
|
|
136
|
+
* the category a reduced-motion preference is about. So when the preference is
|
|
137
|
+
* set the burst does not play at all, and **`onComplete` still fires**. That
|
|
138
|
+
* second half matters more than it looks: hosts commonly use `onComplete` to
|
|
139
|
+
* reset the trigger, so swallowing it would leave the flag stuck true and the
|
|
140
|
+
* celebration permanently armed.
|
|
141
|
+
*
|
|
142
|
+
* The check is deliberately made at fire time rather than subscribed to. A
|
|
143
|
+
* user changing the preference mid-burst is not worth a listener, and reading
|
|
144
|
+
* it during render would make the component's output differ between server and
|
|
145
|
+
* first client paint.
|
|
146
|
+
*/
|
|
147
|
+
export const StyledConfetti: React.FC<StyledConfettiProps> = ({
|
|
148
|
+
trigger = false,
|
|
149
|
+
particleCount = 60,
|
|
150
|
+
emojis,
|
|
151
|
+
celebrate,
|
|
152
|
+
onComplete,
|
|
153
|
+
}) => {
|
|
154
|
+
const [particles, setParticles] = React.useState<Particle[] | null>(null);
|
|
155
|
+
const hasFired = React.useRef(false);
|
|
156
|
+
|
|
157
|
+
// The callback is held in a ref so it is not a dependency of the effect
|
|
158
|
+
// below. A host writing `onComplete={() => setSaved(false)}` inline passes a
|
|
159
|
+
// new function every render, which as a dependency would re-run the effect
|
|
160
|
+
// and re-fire the burst on every parent render.
|
|
161
|
+
const onCompleteRef = React.useRef(onComplete);
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
onCompleteRef.current = onComplete;
|
|
164
|
+
}, [onComplete]);
|
|
165
|
+
|
|
166
|
+
React.useEffect(() => {
|
|
167
|
+
if (!trigger) {
|
|
168
|
+
// Falling edge re-arms, so the same component can celebrate twice.
|
|
169
|
+
hasFired.current = false;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (hasFired.current) return;
|
|
173
|
+
hasFired.current = true;
|
|
174
|
+
|
|
175
|
+
if (celebrate !== undefined) {
|
|
176
|
+
const result = celebrate({ particleCount, emojis });
|
|
177
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
178
|
+
// The SAME handler on both arms, rather than `.finally()`.
|
|
179
|
+
//
|
|
180
|
+
// Two things have to be true at once. A host implementation that
|
|
181
|
+
// rejects must still release the trigger — `onComplete` is what a host
|
|
182
|
+
// resets its flag in, so skipping it leaves the celebration armed for
|
|
183
|
+
// ever and nothing can fire again. And the rejection must be
|
|
184
|
+
// *consumed*: `.finally()` returns a promise that rejects onward, so it
|
|
185
|
+
// would satisfy the first requirement while emitting an unhandled
|
|
186
|
+
// rejection into the host's console for a decoration that failed. A
|
|
187
|
+
// two-armed `.then` does both.
|
|
188
|
+
void (result as Promise<unknown>).then(
|
|
189
|
+
() => onCompleteRef.current?.(),
|
|
190
|
+
() => onCompleteRef.current?.(),
|
|
191
|
+
);
|
|
192
|
+
} else {
|
|
193
|
+
onCompleteRef.current?.();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (prefersReducedMotion()) {
|
|
199
|
+
onCompleteRef.current?.();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setParticles(buildParticles(particleCount, emojis));
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
setParticles(null);
|
|
206
|
+
onCompleteRef.current?.();
|
|
207
|
+
}, BURST_MS);
|
|
208
|
+
|
|
209
|
+
return () => clearTimeout(timer);
|
|
210
|
+
}, [trigger, particleCount, emojis, celebrate]);
|
|
211
|
+
|
|
212
|
+
if (particles === null) return null;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
data-testid="styled-confetti"
|
|
217
|
+
// Decoration, and nothing else. `aria-hidden` because there is nothing
|
|
218
|
+
// here to announce, and `pointer-events: none` because a celebration
|
|
219
|
+
// that swallows the click on the button underneath it is a real bug.
|
|
220
|
+
aria-hidden="true"
|
|
221
|
+
className={css({
|
|
222
|
+
position: "fixed",
|
|
223
|
+
inset: "0",
|
|
224
|
+
pointerEvents: "none",
|
|
225
|
+
overflow: "hidden",
|
|
226
|
+
display: "grid",
|
|
227
|
+
placeItems: "center",
|
|
228
|
+
zIndex: "50",
|
|
229
|
+
})}
|
|
230
|
+
>
|
|
231
|
+
{particles.map((p) => (
|
|
232
|
+
<span
|
|
233
|
+
key={p.id}
|
|
234
|
+
data-testid="styled-confetti-particle"
|
|
235
|
+
className={cx(
|
|
236
|
+
css({
|
|
237
|
+
gridArea: "1 / 1",
|
|
238
|
+
width: "8px",
|
|
239
|
+
height: "8px",
|
|
240
|
+
borderRadius: "sm",
|
|
241
|
+
animation: "stonedogConfettiBurst 1.2s ease-out forwards",
|
|
242
|
+
}),
|
|
243
|
+
// Omitted for an emoji particle: the glyph is the decoration, and a
|
|
244
|
+
// coloured square behind it is not.
|
|
245
|
+
p.emoji === undefined ? PARTICLE_CLASS[p.token] : undefined,
|
|
246
|
+
)}
|
|
247
|
+
style={
|
|
248
|
+
{
|
|
249
|
+
// Per-particle values feeding the shared keyframe. See the
|
|
250
|
+
// keyframe's own comment for why these are custom properties and
|
|
251
|
+
// why they are not the theme namespace.
|
|
252
|
+
"--sd-confetti-dx": p.dx,
|
|
253
|
+
"--sd-confetti-dy": p.dy,
|
|
254
|
+
"--sd-confetti-rot": p.rot,
|
|
255
|
+
animationDelay: p.delay,
|
|
256
|
+
// An emoji particle is a glyph, so it must not also be a coloured
|
|
257
|
+
// square behind that glyph.
|
|
258
|
+
...(p.emoji !== undefined
|
|
259
|
+
? { fontSize: "1.5rem", width: "auto", height: "auto" }
|
|
260
|
+
: {}),
|
|
261
|
+
} as React.CSSProperties
|
|
262
|
+
}
|
|
263
|
+
{...(p.emoji === undefined
|
|
264
|
+
? { "data-particle-token": p.token }
|
|
265
|
+
: {})}
|
|
266
|
+
>
|
|
267
|
+
{p.emoji}
|
|
268
|
+
</span>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default StyledConfetti;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { css } from "styled-system/css";
|
|
5
|
+
import StyledAlert from "./StyledAlert";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One validation failure.
|
|
9
|
+
*
|
|
10
|
+
* `path` is the field it belongs to, as segments — `["address", "postcode"]`.
|
|
11
|
+
* An array rather than a dotted string because that is the shape every
|
|
12
|
+
* validator already produces, and joining is lossy in the one case that
|
|
13
|
+
* matters: a key containing a dot becomes indistinguishable from nesting.
|
|
14
|
+
*/
|
|
15
|
+
export interface FieldError {
|
|
16
|
+
path: ReadonlyArray<string | number>;
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StyledFieldErrorsProps {
|
|
21
|
+
errors: ReadonlyArray<FieldError>;
|
|
22
|
+
/** Heading above the list. */
|
|
23
|
+
title?: React.ReactNode;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A summary of validation failures.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <StyledFieldErrors errors={result.error.issues} />
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* ## Renamed from `StyledZodErrorDisplay`, and that is the whole point
|
|
35
|
+
*
|
|
36
|
+
* The component it replaces took `z.ZodIssue[]`, which put **zod in the
|
|
37
|
+
* dependency list of a design system** — imposed on every consumer, including
|
|
38
|
+
* ones that validate with something else or not at all (NEH-430).
|
|
39
|
+
*
|
|
40
|
+
* Nothing about rendering a list of field errors is zod-specific. `FieldError`
|
|
41
|
+
* is structurally what `ZodIssue` already is for these purposes, so a zod host
|
|
42
|
+
* passes `result.error.issues` **unchanged** — `ZodIssue` has both `path` and
|
|
43
|
+
* `message` — and a yup/valibot/hand-rolled host maps two fields. The rename is
|
|
44
|
+
* not cosmetic: `StyledZodErrorDisplay` is a name that tells every reader the
|
|
45
|
+
* package knows about zod, which is the thing being removed.
|
|
46
|
+
*
|
|
47
|
+
* ## Two behaviours that deliberately differ from the original
|
|
48
|
+
*
|
|
49
|
+
* **It is not dismissible.** The original carried a `dismissed` state and an
|
|
50
|
+
* effect resetting it whenever `errors` changed. A summary the user can dismiss
|
|
51
|
+
* while the errors are still there — and while the submit button still refuses
|
|
52
|
+
* — is a way to hide the explanation for a form that will not submit. If a host
|
|
53
|
+
* wants that, it can conditionally render this component, which is clearer at
|
|
54
|
+
* the call site than a hidden state inside it.
|
|
55
|
+
*
|
|
56
|
+
* **It paints from tokens, not from `red.*`.** The original used `red.50` /
|
|
57
|
+
* `red.200` / `red.900/30` and a `_dark` block, which is a literal palette
|
|
58
|
+
* colour: right in one theme, wrong in every other, and invisible to the
|
|
59
|
+
* contrast floor. This delegates to `StyledAlert status="error"`, so it inherits
|
|
60
|
+
* the error tokens, the `role="alert"` announcement, and the non-colour glyph.
|
|
61
|
+
*/
|
|
62
|
+
export const StyledFieldErrors = React.forwardRef<
|
|
63
|
+
HTMLDivElement,
|
|
64
|
+
StyledFieldErrorsProps
|
|
65
|
+
>(function StyledFieldErrors(
|
|
66
|
+
{ errors, title = "Please fix the following:", className },
|
|
67
|
+
ref,
|
|
68
|
+
) {
|
|
69
|
+
// Nothing to say, so say nothing. Rendering an empty alert would announce
|
|
70
|
+
// itself to a screen reader — `role="alert"` is an assertive live region —
|
|
71
|
+
// and interrupt the user to tell them about no problems.
|
|
72
|
+
if (errors.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<StyledAlert
|
|
76
|
+
ref={ref}
|
|
77
|
+
status="error"
|
|
78
|
+
title={title}
|
|
79
|
+
{...(className !== undefined ? { className } : {})}
|
|
80
|
+
>
|
|
81
|
+
<ul className={css({ listStyle: "disc", paddingInlineStart: "5" })}>
|
|
82
|
+
{errors.map((error, index) => (
|
|
83
|
+
// The path is part of the key because two fields commonly fail the
|
|
84
|
+
// same rule with the same message ("Required"), and a message-only
|
|
85
|
+
// key would collide. The index is the tail-breaker for the case where
|
|
86
|
+
// one field carries two failures.
|
|
87
|
+
<li key={`${error.path.join(".")}-${index}`}>{error.message}</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
</StyledAlert>
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export default StyledFieldErrors;
|