@stonedogcode/style 0.12.0 → 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/package.json +1 -1
- 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/config/link-component.tsx +69 -0
- package/src/config/style-config.tsx +39 -1
- package/src/index.ts +42 -0
- package/src/preset/index.ts +28 -0
- package/src/preset/recipes/input-bool.ts +111 -10
|
@@ -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,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { css, cx } from "styled-system/css";
|
|
5
|
+
|
|
6
|
+
export interface StyledTagProps
|
|
7
|
+
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect"> {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Show a remove control, and call this when it is activated.
|
|
11
|
+
*
|
|
12
|
+
* Omitting it renders a plain, non-interactive tag — which is what the great
|
|
13
|
+
* majority of call sites want. A tag that is merely a label should not be
|
|
14
|
+
* focusable, and should not offer a button that does nothing.
|
|
15
|
+
*/
|
|
16
|
+
onRemove?: (() => void) | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The remove button's accessible name.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to `Remove` — deliberately generic, because the component cannot
|
|
21
|
+
* see the label text as a string (children may be any node) and inventing
|
|
22
|
+
* "Remove {children}" from a React tree produces "Remove [object Object]" as
|
|
23
|
+
* often as it produces something useful. A call site with a plain text label
|
|
24
|
+
* should pass the specific form; a list of tags all announcing "Remove" is
|
|
25
|
+
* navigable but tedious.
|
|
26
|
+
*/
|
|
27
|
+
removeLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A small label, optionally removable.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <StyledTag>Draft</StyledTag>
|
|
35
|
+
* <StyledTag onRemove={() => drop(id)} removeLabel="Remove tag Draft">Draft</StyledTag>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ## Why this is a `<span>` and not a compound component
|
|
39
|
+
*
|
|
40
|
+
* The version this replaces was built on `@ark-ui/react`, which is what kept it
|
|
41
|
+
* out of this package — a dependency imposed on every consumer, one of them a
|
|
42
|
+
* proprietary SaaS and one AGPLv3.
|
|
43
|
+
*
|
|
44
|
+
* The judgement (NEH-430, and the same one the dropdown got) is that the
|
|
45
|
+
* library buys interactive/dismissible behaviour that a span with a close
|
|
46
|
+
* button also provides, and usually provides *more* accessibly, because there
|
|
47
|
+
* is no reimplemented focus management to get wrong. A host that genuinely
|
|
48
|
+
* needs the compound version composes it locally, at the one call site.
|
|
49
|
+
*/
|
|
50
|
+
export const StyledTag = React.forwardRef<HTMLSpanElement, StyledTagProps>(
|
|
51
|
+
function StyledTag(
|
|
52
|
+
{ children, onRemove, removeLabel = "Remove", className, ...rest },
|
|
53
|
+
ref,
|
|
54
|
+
) {
|
|
55
|
+
return (
|
|
56
|
+
<span
|
|
57
|
+
ref={ref}
|
|
58
|
+
className={cx(
|
|
59
|
+
css({
|
|
60
|
+
display: "inline-flex",
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
gap: "1",
|
|
63
|
+
paddingInline: "2",
|
|
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
|
+
)}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
79
|
+
<span>{children}</span>
|
|
80
|
+
{onRemove !== undefined && (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onRemove}
|
|
84
|
+
aria-label={removeLabel}
|
|
85
|
+
className={css({
|
|
86
|
+
display: "inline-flex",
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
justifyContent: "center",
|
|
89
|
+
// The house floor, stated rather than left to emerge from
|
|
90
|
+
// padding — see CLAUDE.md. A remove control inside a small tag is
|
|
91
|
+
// exactly where a hit area silently shrinks below it.
|
|
92
|
+
minWidth: "48px",
|
|
93
|
+
minHeight: "48px",
|
|
94
|
+
// The visible glyph stays small while the hit area does not, so
|
|
95
|
+
// the tag does not become 48px tall to hold its own button.
|
|
96
|
+
marginBlock: "-3",
|
|
97
|
+
marginInlineEnd: "-2",
|
|
98
|
+
background: "transparent",
|
|
99
|
+
border: "none",
|
|
100
|
+
cursor: "pointer",
|
|
101
|
+
color: "inherit",
|
|
102
|
+
})}
|
|
103
|
+
>
|
|
104
|
+
{/*
|
|
105
|
+
`aria-hidden` so the button announces its `aria-label` alone. A
|
|
106
|
+
multiplication sign, not a letter x: it is the correct glyph and
|
|
107
|
+
a screen reader that ignores the hiding reads "times" rather
|
|
108
|
+
than "x", which at least is not a letter of the label.
|
|
109
|
+
*/}
|
|
110
|
+
<span aria-hidden="true">×</span>
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
export default StyledTag;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The props any link implementation must accept.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately the native anchor's own surface plus a required `href`. That is
|
|
9
|
+
* not a compromise to keep the type simple — it is the boundary. A router's
|
|
10
|
+
* link component accepts these and adds its own (prefetch, scroll, replace);
|
|
11
|
+
* this package neither knows nor passes those, so the extra props stay the
|
|
12
|
+
* host's business and no routing concept leaks in here.
|
|
13
|
+
*
|
|
14
|
+
* `href` is a `string`, not `string | UrlObject`. Next.js accepts the object
|
|
15
|
+
* form, but naming it here would put a Next.js type in a package whose whole
|
|
16
|
+
* premise is that it has none — and a host that wants the object form can wrap
|
|
17
|
+
* its own component and take it there, which is exactly what the seam is for.
|
|
18
|
+
*/
|
|
19
|
+
export interface LinkComponentProps
|
|
20
|
+
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
21
|
+
href: string;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What `StyledLink` renders for an in-app destination.
|
|
27
|
+
*
|
|
28
|
+
* A host swaps in its router's link — `next/link`, `react-router`'s `Link`,
|
|
29
|
+
* whatever it has — and gets client-side navigation and prefetching. A host
|
|
30
|
+
* that says nothing gets `DefaultLinkComponent` below.
|
|
31
|
+
*
|
|
32
|
+
* Typed as a component rather than an `ElementType` union so the intrinsic
|
|
33
|
+
* `"a"` string is not a valid value. Allowing it would mean two ways to say the
|
|
34
|
+
* same thing, and the one that looks simpler is the one that cannot be given a
|
|
35
|
+
* `displayName` or wrapped.
|
|
36
|
+
*
|
|
37
|
+
* **It must forward its ref to the underlying anchor.** `RefAttributes` is in
|
|
38
|
+
* the props type rather than left implicit because this package's peer range
|
|
39
|
+
* starts at React 18, where a `ref` handed to a plain function component is not
|
|
40
|
+
* a prop — React 18 drops it and logs "Function components cannot be given
|
|
41
|
+
* refs". Stating it in the type is what makes that a compile error for the host
|
|
42
|
+
* instead of a console warning nobody reads. `next/link` and react-router's
|
|
43
|
+
* `Link` both forward already, so the common cases need nothing.
|
|
44
|
+
*/
|
|
45
|
+
export type LinkComponent = React.ComponentType<
|
|
46
|
+
LinkComponentProps & React.RefAttributes<HTMLAnchorElement>
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A plain anchor, and the reason this seam is safe to leave unconfigured.
|
|
51
|
+
*
|
|
52
|
+
* This is the point of the whole arrangement (NEH-430): the default is not a
|
|
53
|
+
* placeholder that throws, warns, or renders nothing until someone wires a
|
|
54
|
+
* router. It is a **real, correct, accessible link** — it navigates, it opens in
|
|
55
|
+
* a new tab when told to, middle-click and "open in new window" work, and a
|
|
56
|
+
* screen reader announces it as a link. What a host gains by overriding is
|
|
57
|
+
* client-side navigation and prefetching: real benefits, and neither of them
|
|
58
|
+
* load-bearing for the link *working*.
|
|
59
|
+
*
|
|
60
|
+
* A seam whose default is broken is a required configuration step wearing a
|
|
61
|
+
* disguise, and it recreates exactly the adoption deadlock this issue set out
|
|
62
|
+
* to remove.
|
|
63
|
+
*/
|
|
64
|
+
export const DefaultLinkComponent: LinkComponent = React.forwardRef<
|
|
65
|
+
HTMLAnchorElement,
|
|
66
|
+
LinkComponentProps
|
|
67
|
+
>(function DefaultLinkComponent(props, ref) {
|
|
68
|
+
return <a ref={ref} {...props} />;
|
|
69
|
+
}) as LinkComponent;
|
|
@@ -5,6 +5,7 @@ import type { DensityProfile, FontSizeProfile, IconSize, ThemeVariant } from "./
|
|
|
5
5
|
import { resolveDensityStep, type DensityBase, type DensityStep } from "./density";
|
|
6
6
|
import { THEME_VARIANTS } from "./types";
|
|
7
7
|
import { IntentIconProvider, type IntentIcons } from "./intent-icons";
|
|
8
|
+
import { DefaultLinkComponent, type LinkComponent } from "./link-component";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Everything this component library needs to know about the host application.
|
|
@@ -69,6 +70,26 @@ export interface StyleConfig {
|
|
|
69
70
|
* (a business tool for a general audience) sets `"md"`.
|
|
70
71
|
*/
|
|
71
72
|
iconSize: IconSize;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What `StyledLink` renders for an in-app destination (NEH-430).
|
|
76
|
+
*
|
|
77
|
+
* This is a seam, not a dependency. `StyledLink` used to import `next/link`
|
|
78
|
+
* directly, which is why it could not live in this package at all — CLAUDE.md
|
|
79
|
+
* names `next/*` as forbidden, and three products with three framework
|
|
80
|
+
* choices cannot be made to share one.
|
|
81
|
+
*
|
|
82
|
+
* The default is a plain `<a>`: a real, working, accessible link, not a
|
|
83
|
+
* placeholder. A host passes its router's component to gain client-side
|
|
84
|
+
* navigation and prefetching, and gains nothing else — so an unconfigured
|
|
85
|
+
* host is not broken, merely un-optimised.
|
|
86
|
+
*
|
|
87
|
+
* Note this is the only field here whose value is a *component*. It sits on
|
|
88
|
+
* `StyleConfig` rather than being a `StyledLink` prop because the choice is
|
|
89
|
+
* app-wide by nature: passing it per call site is how ~40 links end up with
|
|
90
|
+
* two navigation behaviours and no way to retune either.
|
|
91
|
+
*/
|
|
92
|
+
linkComponent: LinkComponent;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/**
|
|
@@ -91,6 +112,10 @@ export const DEFAULT_STYLE_CONFIG: StyleConfig = {
|
|
|
91
112
|
// what the originating application already renders. Changing it would be an
|
|
92
113
|
// invisible, app-wide visual change to every existing consumer.
|
|
93
114
|
iconSize: "2x",
|
|
115
|
+
// A plain anchor. Unlike `iconSize` above this is genuinely the safe middle:
|
|
116
|
+
// it navigates correctly everywhere, and a host overriding it is opting into
|
|
117
|
+
// an improvement rather than repairing a default.
|
|
118
|
+
linkComponent: DefaultLinkComponent,
|
|
94
119
|
};
|
|
95
120
|
|
|
96
121
|
const StyleConfigContext = createContext<StyleConfig>(DEFAULT_STYLE_CONFIG);
|
|
@@ -143,6 +168,7 @@ export function StonedogStyleProvider({
|
|
|
143
168
|
iconSize,
|
|
144
169
|
density,
|
|
145
170
|
densityBase,
|
|
171
|
+
linkComponent,
|
|
146
172
|
icons,
|
|
147
173
|
}: StonedogStyleProviderProps) {
|
|
148
174
|
const value = useMemo<StyleConfig>(
|
|
@@ -153,8 +179,9 @@ export function StonedogStyleProvider({
|
|
|
153
179
|
iconSize: iconSize ?? DEFAULT_STYLE_CONFIG.iconSize,
|
|
154
180
|
density: density ?? DEFAULT_STYLE_CONFIG.density,
|
|
155
181
|
densityBase: densityBase ?? DEFAULT_STYLE_CONFIG.densityBase,
|
|
182
|
+
linkComponent: linkComponent ?? DEFAULT_STYLE_CONFIG.linkComponent,
|
|
156
183
|
}),
|
|
157
|
-
[fontSizeProfile, variant, iconSize, density, densityBase],
|
|
184
|
+
[fontSizeProfile, variant, iconSize, density, densityBase, linkComponent],
|
|
158
185
|
);
|
|
159
186
|
|
|
160
187
|
return (
|
|
@@ -196,6 +223,17 @@ export function useIconSize(): IconSize {
|
|
|
196
223
|
return useStyleConfig().iconSize;
|
|
197
224
|
}
|
|
198
225
|
|
|
226
|
+
/**
|
|
227
|
+
* The host's link implementation, or a plain `<a>` if it supplied none.
|
|
228
|
+
*
|
|
229
|
+
* Exported so an application component outside this package can render a link
|
|
230
|
+
* the same way `StyledLink` does, without reaching for the router directly and
|
|
231
|
+
* re-creating the coupling the seam removed.
|
|
232
|
+
*/
|
|
233
|
+
export function useLinkComponent(): LinkComponent {
|
|
234
|
+
return useStyleConfig().linkComponent;
|
|
235
|
+
}
|
|
236
|
+
|
|
199
237
|
/**
|
|
200
238
|
* Resolve a control's appearance: **the caller's, else the user's app-wide
|
|
201
239
|
* setting, else `solid`.**
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
useStyleConfig,
|
|
21
21
|
useFontSizeProfile,
|
|
22
22
|
useIconSize,
|
|
23
|
+
useLinkComponent,
|
|
23
24
|
useResolvedVariant,
|
|
24
25
|
DEFAULT_STYLE_CONFIG,
|
|
25
26
|
} from "./config/style-config";
|
|
@@ -28,6 +29,10 @@ export type {
|
|
|
28
29
|
StonedogStyleProviderProps,
|
|
29
30
|
} from "./config/style-config";
|
|
30
31
|
|
|
32
|
+
/** The link seam — see `config/link-component.tsx` and NEH-430. */
|
|
33
|
+
export { DefaultLinkComponent } from "./config/link-component";
|
|
34
|
+
export type { LinkComponent, LinkComponentProps } from "./config/link-component";
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* Deprecated `Hopper*` aliases — NEH-251. See `config/style-config.tsx`.
|
|
33
38
|
* Removed once every consumer has landed its rename PR.
|
|
@@ -192,6 +197,43 @@ export type { StyledTooltipProps } from "./components/StyledTooltip";
|
|
|
192
197
|
export { default as StyledFormLabel } from "./components/StyledFormLabel";
|
|
193
198
|
export type { StyledFormLabelProps } from "./components/StyledFormLabel";
|
|
194
199
|
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Components that were blocked on a runtime dependency until NEH-430 gave each
|
|
202
|
+
// a seam with a working default. None of them adds a dependency; the host
|
|
203
|
+
// supplies the framework-specific half, or takes the default and loses nothing
|
|
204
|
+
// that stops it working.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
export { default as StyledLink, StyledLink as Link } from "./components/StyledLink";
|
|
207
|
+
export type { StyledLinkProps } from "./components/StyledLink";
|
|
208
|
+
|
|
209
|
+
export { default as StyledTag, StyledTag as Tag } from "./components/StyledTag";
|
|
210
|
+
export type { StyledTagProps } from "./components/StyledTag";
|
|
211
|
+
|
|
212
|
+
export {
|
|
213
|
+
default as StyledFieldErrors,
|
|
214
|
+
StyledFieldErrors as FieldErrors,
|
|
215
|
+
} from "./components/StyledFieldErrors";
|
|
216
|
+
export type {
|
|
217
|
+
StyledFieldErrorsProps,
|
|
218
|
+
FieldError,
|
|
219
|
+
} from "./components/StyledFieldErrors";
|
|
220
|
+
|
|
221
|
+
export { default as StyledPage, StyledPage as Page } from "./components/StyledPage";
|
|
222
|
+
export type { StyledPageProps } from "./components/StyledPage";
|
|
223
|
+
|
|
224
|
+
export { default as StyledForm, StyledForm as Form } from "./components/StyledForm";
|
|
225
|
+
export type { StyledFormProps } from "./components/StyledForm";
|
|
226
|
+
|
|
227
|
+
export {
|
|
228
|
+
default as StyledConfetti,
|
|
229
|
+
StyledConfetti as Confetti,
|
|
230
|
+
} from "./components/StyledConfetti";
|
|
231
|
+
export type {
|
|
232
|
+
StyledConfettiProps,
|
|
233
|
+
CelebrateFn,
|
|
234
|
+
CelebrateOptions,
|
|
235
|
+
} from "./components/StyledConfetti";
|
|
236
|
+
|
|
195
237
|
export { default as StyledInputBool } from "./components/StyledInputBool";
|
|
196
238
|
export type { StyledInputBoolProps, InputBoolVariant } from "./components/StyledInputBool";
|
|
197
239
|
export { INPUT_BOOL_VARIANTS } from "./components/StyledInputBool";
|
package/src/preset/index.ts
CHANGED
|
@@ -195,6 +195,34 @@ export function stonedogStylePreset(options: StonedogStylePresetOptions = {}) {
|
|
|
195
195
|
"0%": { transform: "scaleX(0)" },
|
|
196
196
|
"100%": { transform: "scaleX(1)" },
|
|
197
197
|
},
|
|
198
|
+
/**
|
|
199
|
+
* One confetti particle's flight — `StyledConfetti`'s zero-dependency
|
|
200
|
+
* default (NEH-430).
|
|
201
|
+
*
|
|
202
|
+
* The three `--sd-confetti-*` properties are set inline, per
|
|
203
|
+
* particle, so one keyframe serves a whole burst travelling in every
|
|
204
|
+
* direction. A keyframe cannot randomise, and a hundred generated
|
|
205
|
+
* keyframes would be a hundred rules in every consumer's stylesheet.
|
|
206
|
+
*
|
|
207
|
+
* **These are NOT the theme namespace and must not be confused with
|
|
208
|
+
* it.** CLAUDE.md's rule — never write `var(--…)` for anything the
|
|
209
|
+
* HOST supplies — is about `--<prefix>-*` properties that a theme
|
|
210
|
+
* defines and `cssVarPrefix` re-points. These are component-internal,
|
|
211
|
+
* written and read in the same breath by the same component, and
|
|
212
|
+
* named `--sd-confetti-*` precisely so they cannot collide with a
|
|
213
|
+
* host's namespace. The particle's COLOUR still comes from a token.
|
|
214
|
+
*/
|
|
215
|
+
stonedogConfettiBurst: {
|
|
216
|
+
"0%": {
|
|
217
|
+
transform: "translate3d(0, 0, 0) rotate(0deg)",
|
|
218
|
+
opacity: "1",
|
|
219
|
+
},
|
|
220
|
+
"100%": {
|
|
221
|
+
transform:
|
|
222
|
+
"translate3d(var(--sd-confetti-dx, 0), var(--sd-confetti-dy, 0), 0) rotate(var(--sd-confetti-rot, 0deg))",
|
|
223
|
+
opacity: "0",
|
|
224
|
+
},
|
|
225
|
+
},
|
|
198
226
|
},
|
|
199
227
|
recipes,
|
|
200
228
|
},
|