@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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { css, cx } from "styled-system/css";
|
|
5
|
+
import StyledFieldErrors, { type FieldError } from "./StyledFieldErrors";
|
|
6
|
+
|
|
7
|
+
export interface StyledFormProps
|
|
8
|
+
extends React.FormHTMLAttributes<HTMLFormElement> {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* Validation failures to summarise above the fields.
|
|
12
|
+
*
|
|
13
|
+
* `FieldError[]`, not `z.ZodIssue[]` — which is the whole reason this
|
|
14
|
+
* component can live here. A zod host passes `result.error.issues`
|
|
15
|
+
* unchanged; see `StyledFieldErrors`.
|
|
16
|
+
*/
|
|
17
|
+
errors?: ReadonlyArray<FieldError>;
|
|
18
|
+
/** Heading for the error summary. */
|
|
19
|
+
errorsTitle?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A form with a validation summary above its fields.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <StyledForm errors={issues} onSubmit={handleSubmit}>
|
|
27
|
+
* <StyledInputText … />
|
|
28
|
+
* </StyledForm>
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* ## It renders a real `<form>`, which the original did not
|
|
32
|
+
*
|
|
33
|
+
* The component this replaces rendered a `StyledBox` — a `<div>`. That is not
|
|
34
|
+
* a cosmetic difference:
|
|
35
|
+
*
|
|
36
|
+
* - **Enter does not submit a div.** Pressing Enter in a text input submits the
|
|
37
|
+
* form it belongs to; in a div it does nothing, so every such form needed a
|
|
38
|
+
* pointer.
|
|
39
|
+
* - **Assistive technology loses the form role**, and with it the ability to
|
|
40
|
+
* navigate by form.
|
|
41
|
+
* - **`required`, `type="email"` and friends do nothing** without a form to
|
|
42
|
+
* validate against, which is the native validation the seam strategy for this
|
|
43
|
+
* component leans on.
|
|
44
|
+
*
|
|
45
|
+
* The summary is rendered *before* the fields deliberately: a summary below the
|
|
46
|
+
* inputs is one a keyboard user reaches only after passing everything it is
|
|
47
|
+
* telling them about.
|
|
48
|
+
*/
|
|
49
|
+
export const StyledForm = React.forwardRef<HTMLFormElement, StyledFormProps>(
|
|
50
|
+
function StyledForm(
|
|
51
|
+
{ children, errors, errorsTitle, className, ...rest },
|
|
52
|
+
ref,
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
<form
|
|
56
|
+
ref={ref}
|
|
57
|
+
className={cx(
|
|
58
|
+
css({ display: "flex", flexDirection: "column", gap: "3" }),
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
{...rest}
|
|
62
|
+
>
|
|
63
|
+
{errors !== undefined && errors.length > 0 && (
|
|
64
|
+
<StyledFieldErrors
|
|
65
|
+
errors={errors}
|
|
66
|
+
{...(errorsTitle !== undefined ? { title: errorsTitle } : {})}
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
{children}
|
|
70
|
+
</form>
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export default StyledForm;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { buttonRecipe } from "styled-system/recipes";
|
|
5
|
+
import { 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
|
+
export interface StyledLinkProps
|
|
21
|
+
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
22
|
+
/** Where the link goes. */
|
|
23
|
+
href: string;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Marks the destination as outside this application.
|
|
27
|
+
*
|
|
28
|
+
* Two things follow, and both matter: the link renders through a plain `<a>`
|
|
29
|
+
* rather than the host's router (a client-side router cannot navigate to
|
|
30
|
+
* another origin, and handing it one is how a "link that does nothing" bug
|
|
31
|
+
* starts), and it gains a visible external indicator.
|
|
32
|
+
*/
|
|
33
|
+
isExternal?: boolean;
|
|
34
|
+
/** Open in a new browsing context. */
|
|
35
|
+
newWindow?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Renders the link inert.
|
|
38
|
+
*
|
|
39
|
+
* There is no `disabled` attribute for an anchor, so this removes `href` —
|
|
40
|
+
* which is what actually stops activation and takes the element out of the
|
|
41
|
+
* tab order — and states `aria-disabled` for assistive technology. Setting
|
|
42
|
+
* only `aria-disabled` would leave a fully working link that merely claims
|
|
43
|
+
* not to be.
|
|
44
|
+
*/
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
leftIcon?: React.ReactNode;
|
|
47
|
+
rightIcon?: React.ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* The external-destination indicator.
|
|
50
|
+
*
|
|
51
|
+
* Defaults to a text glyph, for the reason `StyledAlert` uses one: this
|
|
52
|
+
* package ships no artwork, and a character inherits `currentColor` and the
|
|
53
|
+
* font scale, so it cannot end up a different colour or size from the label
|
|
54
|
+
* beside it. Pass a node to substitute an icon, or `null` for none.
|
|
55
|
+
*/
|
|
56
|
+
externalIndicator?: React.ReactNode;
|
|
57
|
+
variant?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The default external-destination glyph — "↗", north-east arrow. */
|
|
61
|
+
const EXTERNAL_GLYPH = "↗";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A link.
|
|
65
|
+
*
|
|
66
|
+
* ```tsx
|
|
67
|
+
* <StyledLink href="/settings">Settings</StyledLink>
|
|
68
|
+
* <StyledLink href="https://example.com" isExternal newWindow>Docs</StyledLink>
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* In-app destinations render through the host's `linkComponent` (a plain `<a>`
|
|
72
|
+
* unless configured); external ones always render a plain `<a>`.
|
|
73
|
+
*/
|
|
74
|
+
export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
|
|
75
|
+
function StyledLink(
|
|
76
|
+
{
|
|
77
|
+
href,
|
|
78
|
+
children,
|
|
79
|
+
isExternal,
|
|
80
|
+
newWindow,
|
|
81
|
+
disabled,
|
|
82
|
+
leftIcon,
|
|
83
|
+
rightIcon,
|
|
84
|
+
externalIndicator,
|
|
85
|
+
variant,
|
|
86
|
+
className,
|
|
87
|
+
...rest
|
|
88
|
+
},
|
|
89
|
+
ref,
|
|
90
|
+
) {
|
|
91
|
+
const HostLink = useLinkComponent();
|
|
92
|
+
const resolved = useResolvedVariant(variant ?? "link", LINK_VARIANTS);
|
|
93
|
+
const classes = cx(buttonRecipe({ variant: resolved }), className);
|
|
94
|
+
|
|
95
|
+
const indicator =
|
|
96
|
+
externalIndicator === undefined ? EXTERNAL_GLYPH : externalIndicator;
|
|
97
|
+
|
|
98
|
+
const content = (
|
|
99
|
+
<>
|
|
100
|
+
{leftIcon !== undefined && leftIcon !== null && (
|
|
101
|
+
<span aria-hidden="true">{leftIcon}</span>
|
|
102
|
+
)}
|
|
103
|
+
<span>{children}</span>
|
|
104
|
+
{rightIcon !== undefined && rightIcon !== null && (
|
|
105
|
+
<span aria-hidden="true">{rightIcon}</span>
|
|
106
|
+
)}
|
|
107
|
+
{isExternal && indicator !== null && (
|
|
108
|
+
// `aria-hidden` because the accessible name already carries the
|
|
109
|
+
// destination, and because "opens in a new window" is conveyed by the
|
|
110
|
+
// visible glyph for sighted users and by nothing useful when read
|
|
111
|
+
// aloud as "north east arrow".
|
|
112
|
+
<span aria-hidden="true">{indicator}</span>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Shared by both branches. `href` is omitted entirely when disabled rather
|
|
118
|
+
// than set to "#": "#" is a live link to the top of the page, so it stays
|
|
119
|
+
// focusable and activating it scrolls — a disabled control that does
|
|
120
|
+
// something is worse than one that looks enabled.
|
|
121
|
+
const common = {
|
|
122
|
+
...(disabled ? {} : { href }),
|
|
123
|
+
"aria-disabled": disabled ? true : undefined,
|
|
124
|
+
className: classes,
|
|
125
|
+
...(newWindow
|
|
126
|
+
? {
|
|
127
|
+
target: "_blank",
|
|
128
|
+
// Both, and not only for security. `noopener` severs
|
|
129
|
+
// `window.opener` (tabnabbing); `noreferrer` also suppresses the
|
|
130
|
+
// Referer header. They are separate protections and older engines
|
|
131
|
+
// implement only one.
|
|
132
|
+
rel: "noopener noreferrer",
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
...rest,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// An external destination never goes through the host's router: a
|
|
139
|
+
// client-side router cannot navigate off-origin, and several will
|
|
140
|
+
// intercept the click and do nothing at all.
|
|
141
|
+
if (isExternal || disabled) {
|
|
142
|
+
return (
|
|
143
|
+
<a ref={ref} {...common}>
|
|
144
|
+
{content}
|
|
145
|
+
</a>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<HostLink ref={ref} href={href} {...common}>
|
|
151
|
+
{content}
|
|
152
|
+
</HostLink>
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
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,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;
|
|
@@ -6,7 +6,7 @@ import { styled } from "styled-system/jsx";
|
|
|
6
6
|
import { css, cx } from "styled-system/css";
|
|
7
7
|
import StyledTooltip from "./StyledTooltip";
|
|
8
8
|
import { useFontSizeProfile } from "../config/style-config";
|
|
9
|
-
import { fontSizeMap } from "../config/font-size";
|
|
9
|
+
import { fontSizeMap, resolveFontSizeKey } from "../config/font-size";
|
|
10
10
|
import type { AllowedTextVariant } from "../config/types";
|
|
11
11
|
import { textRecipe } from "styled-system/recipes";
|
|
12
12
|
|
|
@@ -114,15 +114,7 @@ const StyledText = React.forwardRef<HTMLSpanElement, StyledTextProps>((props, re
|
|
|
114
114
|
} = props;
|
|
115
115
|
const fontSizeProfile = useFontSizeProfile();
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
if (size) {
|
|
119
|
-
finalSize = size;
|
|
120
|
-
} else if (fixedSize) {
|
|
121
|
-
finalSize = "md";
|
|
122
|
-
} else {
|
|
123
|
-
finalSize = fontSizeProfile;
|
|
124
|
-
}
|
|
125
|
-
|
|
117
|
+
const finalSize = resolveFontSizeKey({ size, fixedSize, profile: fontSizeProfile });
|
|
126
118
|
const fontSize = fontSizeMap[finalSize] || fontSizeMap.md;
|
|
127
119
|
|
|
128
120
|
const extraStyles: React.CSSProperties = {};
|
package/src/config/font-size.ts
CHANGED
|
@@ -62,6 +62,42 @@ export function getFontSizeLabel(size: string): string {
|
|
|
62
62
|
return fontSizeLabelMap[size] ?? size;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Which scale step a piece of text ends up at: caller → `fixedSize` → profile.
|
|
67
|
+
*
|
|
68
|
+
* The same precedence shape as `useResolvedVariant`, and here for the same
|
|
69
|
+
* reason — but it is a *pure function* rather than a branch inside `StyledText`
|
|
70
|
+
* specifically so the unit tier can assert it (NEH-406).
|
|
71
|
+
*
|
|
72
|
+
* That mattered more than it looks. The rule was only ever checked through a
|
|
73
|
+
* rendered `font-size`, and **jsdom cannot see one of these values at all**:
|
|
74
|
+
* every `fontSizeMap` entry is a `var(--font-sizes-*, …)` reference, jsdom's
|
|
75
|
+
* CSS parser rejects it against the `font-size` grammar, and the declaration is
|
|
76
|
+
* dropped — the element ends up with no `style` attribute whatsoever. So
|
|
77
|
+
* `toHaveStyle({ fontSize: <anything> })` compared "" with "" and passed for
|
|
78
|
+
* every possible expectation, including one asserting a size that had not been
|
|
79
|
+
* true since the scale moved.
|
|
80
|
+
*
|
|
81
|
+
* Splitting the rule out gives each tier a question it can actually answer:
|
|
82
|
+
* *which step wins* here, and *what does it measure* in the browser tier.
|
|
83
|
+
*
|
|
84
|
+
* `fixedSize` pins to `md` — used where a label must not grow with the profile,
|
|
85
|
+
* e.g. inside a fixed-height control it would otherwise clip.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveFontSizeKey({
|
|
88
|
+
size,
|
|
89
|
+
fixedSize,
|
|
90
|
+
profile,
|
|
91
|
+
}: {
|
|
92
|
+
size?: string | undefined;
|
|
93
|
+
fixedSize?: boolean | undefined;
|
|
94
|
+
profile?: string | undefined;
|
|
95
|
+
}): string {
|
|
96
|
+
if (size) return size;
|
|
97
|
+
if (fixedSize) return "md";
|
|
98
|
+
return profile ?? "md";
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
/**
|
|
66
102
|
* The literal fallback inside a `fontSizeMap` entry, e.g. `"1rem"`.
|
|
67
103
|
*
|