@stonedogcode/style 0.9.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/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +699 -0
- package/package.json +95 -0
- package/src/components/DictationControls.tsx +141 -0
- package/src/components/DictationPrompt.tsx +78 -0
- package/src/components/StyledBox.tsx +174 -0
- package/src/components/StyledButton.tsx +144 -0
- package/src/components/StyledCollapsible.tsx +127 -0
- package/src/components/StyledDefinitionList.tsx +134 -0
- package/src/components/StyledFieldset.tsx +157 -0
- package/src/components/StyledFlex.tsx +13 -0
- package/src/components/StyledFooter.tsx +399 -0
- package/src/components/StyledFormLabel.tsx +141 -0
- package/src/components/StyledGrid.tsx +109 -0
- package/src/components/StyledGridItem.tsx +19 -0
- package/src/components/StyledHStack.tsx +145 -0
- package/src/components/StyledHeading.tsx +79 -0
- package/src/components/StyledHrRule.tsx +33 -0
- package/src/components/StyledIcon.tsx +172 -0
- package/src/components/StyledIconButton.tsx +135 -0
- package/src/components/StyledInputBool.tsx +81 -0
- package/src/components/StyledInputRadio.tsx +141 -0
- package/src/components/StyledInputSelect.tsx +115 -0
- package/src/components/StyledInputSlider.tsx +83 -0
- package/src/components/StyledInputText.tsx +146 -0
- package/src/components/StyledInputTextArea.tsx +119 -0
- package/src/components/StyledInputToggle.tsx +224 -0
- package/src/components/StyledList.tsx +188 -0
- package/src/components/StyledScrollbar.tsx +53 -0
- package/src/components/StyledSearch.tsx +78 -0
- package/src/components/StyledSeparator.tsx +38 -0
- package/src/components/StyledSidebar.tsx +555 -0
- package/src/components/StyledSimpleGrid.tsx +99 -0
- package/src/components/StyledSparkLine.tsx +119 -0
- package/src/components/StyledSpinner.tsx +91 -0
- package/src/components/StyledStack.tsx +62 -0
- package/src/components/StyledText.tsx +99 -0
- package/src/components/StyledTooltip.tsx +398 -0
- package/src/components/StyledVStack.tsx +143 -0
- package/src/components/TitleLogo.tsx +223 -0
- package/src/components/create-icon.tsx +66 -0
- package/src/components/create-intent-button.tsx +134 -0
- package/src/components/dictation.ts +71 -0
- package/src/components/intent-buttons.ts +154 -0
- package/src/config/can-hover.ts +75 -0
- package/src/config/density.ts +138 -0
- package/src/config/font-size.ts +113 -0
- package/src/config/intent-icons.tsx +116 -0
- package/src/config/logger.ts +60 -0
- package/src/config/style-config.tsx +263 -0
- package/src/config/types.ts +137 -0
- package/src/index.ts +259 -0
- package/src/preset/index.ts +243 -0
- package/src/preset/recipes/arrows.ts +29 -0
- package/src/preset/recipes/box.ts +122 -0
- package/src/preset/recipes/button.ts +161 -0
- package/src/preset/recipes/dl-list.ts +109 -0
- package/src/preset/recipes/drawer.ts +125 -0
- package/src/preset/recipes/form.ts +95 -0
- package/src/preset/recipes/icon-button.ts +161 -0
- package/src/preset/recipes/icon.ts +34 -0
- package/src/preset/recipes/input-bool.ts +184 -0
- package/src/preset/recipes/input-dropdown.ts +93 -0
- package/src/preset/recipes/input-radio.ts +158 -0
- package/src/preset/recipes/input-surface.ts +152 -0
- package/src/preset/recipes/input-text.ts +17 -0
- package/src/preset/recipes/list.ts +196 -0
- package/src/preset/recipes/menu.ts +28 -0
- package/src/preset/recipes/separator.ts +89 -0
- package/src/preset/recipes/stack.ts +89 -0
- package/src/preset/recipes/striped.ts +34 -0
- package/src/preset/recipes/text.ts +41 -0
- package/src/preset/recipes/tooltip.ts +77 -0
- package/src/preset/semantic-variables.ts +283 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useId, useState } from "react";
|
|
4
|
+
import { styled } from "styled-system/jsx";
|
|
5
|
+
import StyledBox from "./StyledBox";
|
|
6
|
+
import StyledText from "./StyledText";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The application footer: a bar that is always on screen, and a panel that
|
|
10
|
+
* opens to show version and service status (NEH-394).
|
|
11
|
+
*
|
|
12
|
+
* Shared by HopperGuard, rozcards.com and optimafilings.com, which is what
|
|
13
|
+
* every decision below follows from.
|
|
14
|
+
*
|
|
15
|
+
* ## It knows nothing about any product
|
|
16
|
+
*
|
|
17
|
+
* Copyright text, trademark legend, version strings, status content and the
|
|
18
|
+
* action buttons all arrive as props. In particular **the actions are a prop,
|
|
19
|
+
* not built in**: HopperGuard passes a row of controls, while rozcards and
|
|
20
|
+
* Optima pass a single dark-mode toggle. A component that knew what the buttons
|
|
21
|
+
* were could not be shared, which is the whole point.
|
|
22
|
+
*
|
|
23
|
+
* ## The toggle is its own control, and the actions are NOT inside it
|
|
24
|
+
*
|
|
25
|
+
* The obvious shape — make the whole bar the trigger — nests the action buttons
|
|
26
|
+
* inside a clickable region, so pressing "dark mode" also toggles the panel and
|
|
27
|
+
* screen readers announce a button inside a button. Here the trigger is one
|
|
28
|
+
* explicit control with `aria-expanded` / `aria-controls`, and the actions sit
|
|
29
|
+
* beside it as siblings.
|
|
30
|
+
*
|
|
31
|
+
* It opens on click, never hover: a panel that closes when the pointer drifts
|
|
32
|
+
* fails anyone whose hand is unsteady, and the harder they try the more it
|
|
33
|
+
* moves.
|
|
34
|
+
*
|
|
35
|
+
* ## No Chakra
|
|
36
|
+
*
|
|
37
|
+
* HopperGuard's version is built on a Chakra `Collapsible`. This package cannot
|
|
38
|
+
* take that dependency, so the disclosure is rebuilt here on a button and a
|
|
39
|
+
* region. It is deliberately NOT `<details>`/`<summary>` either — a `<summary>`
|
|
40
|
+
* would have to contain the action buttons to keep them on the collapsed bar,
|
|
41
|
+
* which is the nested-interactive problem again.
|
|
42
|
+
*
|
|
43
|
+
* ## Sizing lives in inline `style`
|
|
44
|
+
*
|
|
45
|
+
* Panda extracts LITERAL values only, so a metric passed as a prop resolves to
|
|
46
|
+
* no CSS while the class name still lands in the DOM. Anything dimensional here
|
|
47
|
+
* is an inline style reaching the host's scale through custom properties, which
|
|
48
|
+
* also makes it immune to a consumer's Panda `include` glob being wrong.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const Bar = styled("div", {
|
|
52
|
+
base: {
|
|
53
|
+
display: "flex",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
justifyContent: "space-between",
|
|
56
|
+
gap: 2,
|
|
57
|
+
width: "100%",
|
|
58
|
+
flexWrap: "wrap",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `minHeight`/`minWidth` are stated rather than left to emerge from padding, so
|
|
64
|
+
* no density rung or font scale can erode the target below the house floor of
|
|
65
|
+
* 48px (above WCAG 2.5.5 AAA's 44, deliberately — our readers mis-aim more).
|
|
66
|
+
*/
|
|
67
|
+
const ToggleButton = styled("button", {
|
|
68
|
+
base: {
|
|
69
|
+
display: "inline-flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
justifyContent: "center",
|
|
72
|
+
gap: 2,
|
|
73
|
+
minHeight: "48px",
|
|
74
|
+
minWidth: "48px",
|
|
75
|
+
paddingInline: 3,
|
|
76
|
+
borderRadius: "md",
|
|
77
|
+
border: "1px solid transparent",
|
|
78
|
+
backgroundColor: "transparent",
|
|
79
|
+
color: "textMain",
|
|
80
|
+
cursor: "pointer",
|
|
81
|
+
_hover: { textDecoration: "underline" },
|
|
82
|
+
_focusVisible: { outline: "2px solid", outlineOffset: "2px" },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Rotates rather than swapping glyphs, so there is one element to label. */
|
|
87
|
+
const Chevron = styled("span", {
|
|
88
|
+
base: {
|
|
89
|
+
display: "inline-block",
|
|
90
|
+
lineHeight: 1,
|
|
91
|
+
transition: "transform 150ms ease",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Links get their OWN line inside the panel rather than sharing the meta row.
|
|
97
|
+
*
|
|
98
|
+
* `width: 100%` is what forces the break: Panel wraps, so a full-width child
|
|
99
|
+
* necessarily starts a new line. HopperGuard's row is seven-plus links with
|
|
100
|
+
* separators, and inlining it beside the version chips leaves the two reading
|
|
101
|
+
* as one run-on sentence.
|
|
102
|
+
*/
|
|
103
|
+
const LinkRow = styled("div", {
|
|
104
|
+
base: {
|
|
105
|
+
display: "flex",
|
|
106
|
+
flexWrap: "wrap",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
gap: 2,
|
|
109
|
+
width: "100%",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const Panel = styled("div", {
|
|
114
|
+
base: {
|
|
115
|
+
display: "flex",
|
|
116
|
+
flexWrap: "wrap",
|
|
117
|
+
alignItems: "center",
|
|
118
|
+
gap: 4,
|
|
119
|
+
width: "100%",
|
|
120
|
+
paddingBlockStart: 2,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export interface StyledFooterVersion {
|
|
125
|
+
/** Human-facing release name, e.g. "Spring 2026". */
|
|
126
|
+
name?: string | undefined;
|
|
127
|
+
/** Build identifier, e.g. the package version. */
|
|
128
|
+
build?: string | undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface StyledFooterStatusBadge {
|
|
132
|
+
/**
|
|
133
|
+
* Where to load the badge image from.
|
|
134
|
+
*
|
|
135
|
+
* **A SAME-ORIGIN path, not the monitoring vendor's URL.** This is the whole
|
|
136
|
+
* reason the prop takes a URL rather than a monitor id, and it is not a
|
|
137
|
+
* preference:
|
|
138
|
+
*
|
|
139
|
+
* A cross-origin image that fails to load logs a console error, and nothing
|
|
140
|
+
* the element does can suppress it — `onError` can hide the picture but
|
|
141
|
+
* cannot un-log the request. HopperGuard's post-deploy smoke asserts zero
|
|
142
|
+
* console errors, so a monitoring host having a bad day **withholds a
|
|
143
|
+
* production release tag**. That has already happened twice (NEH-387), which
|
|
144
|
+
* is why an embedded status page was removed from that app entirely.
|
|
145
|
+
*
|
|
146
|
+
* The shape that works is a route on your own origin that proxies the vendor
|
|
147
|
+
* and **always answers with an image** — a placeholder SVG on failure, never
|
|
148
|
+
* a 4xx/5xx. HopperGuard's `/api/status-badge` does exactly that, and reads
|
|
149
|
+
* its `UPTIMESIGNAL_MONITOR_ID` from server env, so the id never reaches the
|
|
150
|
+
* browser and this component never needs to know it.
|
|
151
|
+
*/
|
|
152
|
+
src: string;
|
|
153
|
+
/**
|
|
154
|
+
* Accessible name, used as the image's `alt`. Default: "Service status".
|
|
155
|
+
*
|
|
156
|
+
* Not empty by default: the badge carries information (a service is up or it
|
|
157
|
+
* is not), so it is not decorative, and `alt=""` would hide that from a
|
|
158
|
+
* screen reader entirely.
|
|
159
|
+
*/
|
|
160
|
+
label?: string | undefined;
|
|
161
|
+
/** Optional link to a fuller status page, wrapped around the badge. */
|
|
162
|
+
href?: string | undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface StyledFooterProps {
|
|
166
|
+
/** Always visible. The consumer's own line — no product is named here. */
|
|
167
|
+
copyright: string;
|
|
168
|
+
/**
|
|
169
|
+
* Always visible, on its OWN line beneath the copyright.
|
|
170
|
+
*
|
|
171
|
+
* Rendered as a block: `StyledText` is a `<span>`, and two inline siblings
|
|
172
|
+
* share a line however their container is styled — the defect NEH-388 fixed
|
|
173
|
+
* in HopperGuard's footer, reproduced here if this were left inline.
|
|
174
|
+
*/
|
|
175
|
+
legend?: string | undefined;
|
|
176
|
+
/** Always visible, at the end of the bar. Whatever controls the product has. */
|
|
177
|
+
actions?: React.ReactNode | undefined;
|
|
178
|
+
/** Shown in the panel when open. */
|
|
179
|
+
version?: StyledFooterVersion | undefined;
|
|
180
|
+
/**
|
|
181
|
+
* An uptime badge, shown in the panel when open.
|
|
182
|
+
*
|
|
183
|
+
* **Optional, and the footer is complete without it.** Every product using
|
|
184
|
+
* this today has a monitor, but a page that has none — a marketing site, a
|
|
185
|
+
* preview build, a product before its monitor exists — must still render a
|
|
186
|
+
* correct footer rather than a gap or a broken image. Omitting it renders
|
|
187
|
+
* nothing at all, and a test pins that.
|
|
188
|
+
*
|
|
189
|
+
* See `StyledFooterStatusBadge.src` for why this is a URL and not a monitor
|
|
190
|
+
* id. It is the difference between a badge and a withheld release tag.
|
|
191
|
+
*/
|
|
192
|
+
statusBadge?: StyledFooterStatusBadge | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* A row of links, shown in the panel when open, on its own line.
|
|
195
|
+
*
|
|
196
|
+
* **Optional, and two of the three products have none.** HopperGuard carries
|
|
197
|
+
* Privacy, Terms, its compliance pages and (only when signed in) Release
|
|
198
|
+
* Notes; rozcards and Optima pass nothing and no empty row is rendered.
|
|
199
|
+
*
|
|
200
|
+
* A slot rather than a `{href,label}[]`: HopperGuard interleaves separators,
|
|
201
|
+
* gates one entry on session state, and builds part of the list from config.
|
|
202
|
+
* A data-shaped prop would have to grow a predicate and a separator strategy
|
|
203
|
+
* to express that, and would still be wrong for the next consumer.
|
|
204
|
+
*/
|
|
205
|
+
links?: React.ReactNode | undefined;
|
|
206
|
+
/**
|
|
207
|
+
* Anything else for the panel — extra links, a build date, a region.
|
|
208
|
+
*
|
|
209
|
+
* The general escape hatch, kept alongside `statusBadge` rather than replaced
|
|
210
|
+
* by it. `statusBadge` exists because three products would otherwise each
|
|
211
|
+
* hand-roll the same `<img>` and each get the failure handling subtly
|
|
212
|
+
* different; this is for the cases it does not cover.
|
|
213
|
+
*/
|
|
214
|
+
status?: React.ReactNode | undefined;
|
|
215
|
+
/** Controlled open state. Omit to let the component own it. */
|
|
216
|
+
open?: boolean | undefined;
|
|
217
|
+
/** Initial state when uncontrolled. Closed by default: the bar is the point. */
|
|
218
|
+
defaultOpen?: boolean | undefined;
|
|
219
|
+
onOpenChange?: ((open: boolean) => void) | undefined;
|
|
220
|
+
/** Visible toggle text. Icons alone assume the reader already knows. */
|
|
221
|
+
showDetailsLabel?: string | undefined;
|
|
222
|
+
hideDetailsLabel?: string | undefined;
|
|
223
|
+
"data-testid"?: string | undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The badge, or nothing.
|
|
228
|
+
*
|
|
229
|
+
* Hides itself if the image fails rather than leaving a broken-image icon in
|
|
230
|
+
* the footer. Note what this does NOT do: it cannot prevent the browser logging
|
|
231
|
+
* the failed request. That is why `src` must be a same-origin route that always
|
|
232
|
+
* answers with an image — see `StyledFooterStatusBadge.src`. This handles the
|
|
233
|
+
* cosmetic half; the src choice handles the half that breaks deploys.
|
|
234
|
+
*/
|
|
235
|
+
function StatusBadge({ src, label, href }: StyledFooterStatusBadge) {
|
|
236
|
+
const [failed, setFailed] = useState(false);
|
|
237
|
+
if (failed) return null;
|
|
238
|
+
|
|
239
|
+
const img = (
|
|
240
|
+
<img
|
|
241
|
+
src={src}
|
|
242
|
+
alt={label ?? "Service status"}
|
|
243
|
+
// Height only: a badge's width varies with its text ("99.98% uptime" is
|
|
244
|
+
// wider than "up"), so constraining both would distort it. A literal in
|
|
245
|
+
// inline style rather than a Panda prop, because a consumer's `include`
|
|
246
|
+
// glob being wrong would otherwise leave this unsized.
|
|
247
|
+
style={{ height: "20px", display: "block" }}
|
|
248
|
+
onError={() => setFailed(true)}
|
|
249
|
+
data-testid="footer-status-badge"
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (!href) return img;
|
|
254
|
+
return (
|
|
255
|
+
<a href={href} target="_blank" rel="noreferrer noopener" data-testid="footer-status-link">
|
|
256
|
+
{img}
|
|
257
|
+
</a>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function StyledFooter({
|
|
262
|
+
copyright,
|
|
263
|
+
legend,
|
|
264
|
+
actions,
|
|
265
|
+
version,
|
|
266
|
+
statusBadge,
|
|
267
|
+
status,
|
|
268
|
+
links,
|
|
269
|
+
open,
|
|
270
|
+
defaultOpen = false,
|
|
271
|
+
onOpenChange,
|
|
272
|
+
showDetailsLabel = "Details",
|
|
273
|
+
hideDetailsLabel = "Hide details",
|
|
274
|
+
"data-testid": testId = "styled-footer",
|
|
275
|
+
}: StyledFooterProps) {
|
|
276
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
277
|
+
const panelId = useId();
|
|
278
|
+
|
|
279
|
+
// Controlled when `open` is supplied, uncontrolled otherwise — both, because
|
|
280
|
+
// HopperGuard drives this from app state while a marketing site does not want
|
|
281
|
+
// to hold state it has no other use for.
|
|
282
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
283
|
+
|
|
284
|
+
const toggle = useCallback(() => {
|
|
285
|
+
const next = !isOpen;
|
|
286
|
+
if (open === undefined) setUncontrolledOpen(next);
|
|
287
|
+
onOpenChange?.(next);
|
|
288
|
+
}, [isOpen, open, onOpenChange]);
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<StyledBox as="footer" width="100%" p={2} data-testid={testId}>
|
|
292
|
+
<Bar>
|
|
293
|
+
{/*
|
|
294
|
+
`flex: 1 1 auto` / `width: auto` are LOAD-BEARING, not tidying.
|
|
295
|
+
|
|
296
|
+
boxRecipe's base is `width: 100%` — right for the many places
|
|
297
|
+
StyledBox is a layout container, wrong for exactly this one. As a flex
|
|
298
|
+
item it claimed the whole line, and because the Bar wraps, the actions
|
|
299
|
+
and toggle were pushed to a SECOND ROW, where the Bar's
|
|
300
|
+
`space-between` no longer governs them and they bunched left. That is
|
|
301
|
+
the reported defect: on Optima the "Details" button sat under the
|
|
302
|
+
copyright with the action beside it, at a width where everything fits
|
|
303
|
+
on one row.
|
|
304
|
+
|
|
305
|
+
`minWidth: 0` lets a long copyright shrink instead of forcing the wrap
|
|
306
|
+
back — a flex item otherwise refuses to go below its content width,
|
|
307
|
+
and the second row returns at narrower viewports.
|
|
308
|
+
*/}
|
|
309
|
+
<StyledBox
|
|
310
|
+
p={0}
|
|
311
|
+
data-testid="footer-identity"
|
|
312
|
+
style={{ flex: "1 1 auto", width: "auto", minWidth: 0 }}
|
|
313
|
+
>
|
|
314
|
+
<StyledText size="lg" display="block" data-testid="footer-copyright">
|
|
315
|
+
{copyright}
|
|
316
|
+
</StyledText>
|
|
317
|
+
{legend && (
|
|
318
|
+
<StyledText
|
|
319
|
+
size="sm"
|
|
320
|
+
color="textSecondary"
|
|
321
|
+
display="block"
|
|
322
|
+
data-testid="footer-legend"
|
|
323
|
+
>
|
|
324
|
+
{legend}
|
|
325
|
+
</StyledText>
|
|
326
|
+
)}
|
|
327
|
+
</StyledBox>
|
|
328
|
+
|
|
329
|
+
{/*
|
|
330
|
+
`marginInlineStart: auto` pins this group right even when the
|
|
331
|
+
copyright is short — `space-between` alone only does that while the
|
|
332
|
+
left column is the sole other child, which stops being true the
|
|
333
|
+
moment anything else is added.
|
|
334
|
+
|
|
335
|
+
ACTIONS COME FIRST, TOGGLE LAST. The expander belongs at the far
|
|
336
|
+
right: it is the control that changes the footer's shape, and a
|
|
337
|
+
reader scanning right-to-left for "how do I see more" should find it
|
|
338
|
+
at the edge rather than between the product's own buttons. Previously
|
|
339
|
+
the toggle rendered before `actions`, so it could never be the
|
|
340
|
+
rightmost element however the row was laid out.
|
|
341
|
+
*/}
|
|
342
|
+
<Bar style={{ width: "auto", flex: "0 0 auto", marginInlineStart: "auto" }}>
|
|
343
|
+
{actions}
|
|
344
|
+
<ToggleButton
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={toggle}
|
|
347
|
+
aria-expanded={isOpen}
|
|
348
|
+
// Only while the panel EXISTS. It is unmounted when closed
|
|
349
|
+
// (deliberately — see the panel below), and aria-controls pointing
|
|
350
|
+
// at an absent id is invalid ARIA that some screen readers announce
|
|
351
|
+
// oddly. Spread rather than `aria-controls={undefined}` so the
|
|
352
|
+
// attribute is genuinely absent rather than present-and-empty.
|
|
353
|
+
{...(isOpen ? { "aria-controls": panelId } : {})}
|
|
354
|
+
data-testid="footer-toggle"
|
|
355
|
+
>
|
|
356
|
+
<Chevron
|
|
357
|
+
aria-hidden="true"
|
|
358
|
+
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
|
|
359
|
+
>
|
|
360
|
+
›
|
|
361
|
+
</Chevron>
|
|
362
|
+
{/* Visible, not just an aria-label: a label a sighted reader
|
|
363
|
+
cannot see is no label for the audience this serves. */}
|
|
364
|
+
<StyledText size="sm">
|
|
365
|
+
{isOpen ? hideDetailsLabel : showDetailsLabel}
|
|
366
|
+
</StyledText>
|
|
367
|
+
</ToggleButton>
|
|
368
|
+
</Bar>
|
|
369
|
+
</Bar>
|
|
370
|
+
|
|
371
|
+
{/* Unmounted rather than hidden: nothing in the panel should be tabbable,
|
|
372
|
+
announced, or fetching while it is closed. */}
|
|
373
|
+
{isOpen && (
|
|
374
|
+
<Panel id={panelId} data-testid="footer-panel">
|
|
375
|
+
{version?.name && (
|
|
376
|
+
<StyledText size="sm" data-testid="footer-version-name">
|
|
377
|
+
{version.name}
|
|
378
|
+
</StyledText>
|
|
379
|
+
)}
|
|
380
|
+
{version?.build && (
|
|
381
|
+
<StyledText size="sm" color="textSecondary" data-testid="footer-version-build">
|
|
382
|
+
{`build ${version.build}`}
|
|
383
|
+
</StyledText>
|
|
384
|
+
)}
|
|
385
|
+
{/* `key` on src: StatusBadge latches `failed` so a broken image does
|
|
386
|
+
not leave a torn icon in the footer, but that latch must not
|
|
387
|
+
outlive the URL that failed. Re-keying remounts it, which is the
|
|
388
|
+
cheapest correct reset — without it a badge that failed once
|
|
389
|
+
stays hidden after the src is fixed. */}
|
|
390
|
+
{statusBadge && <StatusBadge key={statusBadge.src} {...statusBadge} />}
|
|
391
|
+
{status}
|
|
392
|
+
{links && <LinkRow data-testid="footer-links">{links}</LinkRow>}
|
|
393
|
+
</Panel>
|
|
394
|
+
)}
|
|
395
|
+
</StyledBox>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export default StyledFooter;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { styled } from "styled-system/jsx";
|
|
5
|
+
import type { HTMLStyledProps } from "styled-system/types";
|
|
6
|
+
import { useFontSizeProfile } from "../config/style-config";
|
|
7
|
+
import { fontSizeMap } from "../config/font-size";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The label above a form control.
|
|
11
|
+
*
|
|
12
|
+
* Clicking it focuses the control, which is most of the point: it turns a
|
|
13
|
+
* 14×14px checkbox into a target the size of its own text, and that is the
|
|
14
|
+
* difference between usable and not for anyone with a tremor. Always pass
|
|
15
|
+
* `htmlFor` — without it the association is lost and so is the tap target.
|
|
16
|
+
*
|
|
17
|
+
* ## Three hardcoded colours were fixed on the way in
|
|
18
|
+
*
|
|
19
|
+
* The originating version painted itself with literals — a dark grey label, a
|
|
20
|
+
* `#e53e3e` required asterisk, a `#888` "(optional)". Each looks right on a
|
|
21
|
+
* light theme and wrong on every other, and none of them respond to dark mode
|
|
22
|
+
* or a high-contrast theme at all. They are now `textPrimary`, `textError` and
|
|
23
|
+
* `textSecondary`, which is the same defect class as NEH-165/166/171.
|
|
24
|
+
*
|
|
25
|
+
* The label colour was the interesting one. It read
|
|
26
|
+
* `var(--chakra-colors-gray-700, #2D3748)` — a **Chakra** custom property, in
|
|
27
|
+
* an app that finished removing Chakra. Nothing had defined that property for
|
|
28
|
+
* some time, so every label in the product was silently painted by the
|
|
29
|
+
* fallback. It was invisible because it *worked*: the fallback is a reasonable
|
|
30
|
+
* grey, so nothing looked broken, and the label simply sat outside theming.
|
|
31
|
+
*
|
|
32
|
+
* ## It follows the app-wide text size (NEH-233)
|
|
33
|
+
*
|
|
34
|
+
* The label used to declare a flat `fontSize: "1rem"`, which opted it out of
|
|
35
|
+
* the type scale. In a product whose default body text is 1.375rem that made
|
|
36
|
+
* the label the *smaller* text — the caption under its own field — and, worse,
|
|
37
|
+
* it did not move when a user raised their text-size setting. That setting is
|
|
38
|
+
* the accommodation a low-vision reader actually reaches for, so a control
|
|
39
|
+
* ignoring it is ignoring them.
|
|
40
|
+
*
|
|
41
|
+
* It now reads the profile the same way `StyledText` does. Note that plain
|
|
42
|
+
* inheritance would NOT have worked: nothing puts a font size on the DOM, so
|
|
43
|
+
* dropping the declaration just pinned the label to the browser's 16px — still
|
|
44
|
+
* fixed, and no longer even declared. The profile has to be read.
|
|
45
|
+
*
|
|
46
|
+
* Callers wanting a specific size still pass `fontSize`, which wins. That is
|
|
47
|
+
* the right way round: the deviation is visible at the call site instead of
|
|
48
|
+
* baked into every label in every consumer.
|
|
49
|
+
*
|
|
50
|
+
* **This visibly resizes existing labels** — that was the whole reason it was
|
|
51
|
+
* split out of the migration rather than smuggled into it.
|
|
52
|
+
*
|
|
53
|
+
* ## `required` and `optional` are not symmetric, deliberately
|
|
54
|
+
*
|
|
55
|
+
* `optional` renders visible text, because "(optional)" is information the
|
|
56
|
+
* reader needs and there is no other channel carrying it.
|
|
57
|
+
*
|
|
58
|
+
* `required` renders an asterisk marked `aria-hidden`. That is correct **only
|
|
59
|
+
* when the control itself is `required` or `aria-required`** — then assistive
|
|
60
|
+
* tech announces the requirement from the input, and an announced "star" on top
|
|
61
|
+
* of it is noise. If your control does not set that attribute, the asterisk is
|
|
62
|
+
* decoration and screen-reader users are told nothing, so set it. This is the
|
|
63
|
+
* one thing about this component worth checking at a call site.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
const PandaFormLabel = styled("label", {
|
|
67
|
+
base: {
|
|
68
|
+
display: "block",
|
|
69
|
+
fontWeight: "bold",
|
|
70
|
+
marginBottom: "0.5rem",
|
|
71
|
+
color: "textPrimary",
|
|
72
|
+
// No fontSize — the label inherits, so it follows the app-wide text size.
|
|
73
|
+
// See the NEH-233 note above.
|
|
74
|
+
lineHeight: "1.25",
|
|
75
|
+
cursor: "pointer",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export interface StyledFormLabelProps extends HTMLStyledProps<"label"> {
|
|
80
|
+
children: React.ReactNode;
|
|
81
|
+
/** The `id` of the control this labels. Pass it — see above. */
|
|
82
|
+
htmlFor?: string;
|
|
83
|
+
/** Append a muted "(optional)". */
|
|
84
|
+
optional?: boolean;
|
|
85
|
+
/** Append a red asterisk. Mark the control `required` too — see above. */
|
|
86
|
+
required?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const StyledFormLabel: React.FC<StyledFormLabelProps> = ({
|
|
90
|
+
children,
|
|
91
|
+
htmlFor,
|
|
92
|
+
optional,
|
|
93
|
+
required,
|
|
94
|
+
fontSize,
|
|
95
|
+
style,
|
|
96
|
+
...props
|
|
97
|
+
}) => {
|
|
98
|
+
// Unconditional, at the top: inlining this into the expression below reads
|
|
99
|
+
// fine and is a hooks-order violation the moment `fontSize` is passed.
|
|
100
|
+
const profile = useFontSizeProfile();
|
|
101
|
+
|
|
102
|
+
// An inline style, not a Panda prop — and this is the part that is easy to
|
|
103
|
+
// get wrong twice. Panda extracts styles by parsing source at BUILD time, so
|
|
104
|
+
// a prop whose value is only known at runtime produces a class name with no
|
|
105
|
+
// rule behind it: the element renders at the browser default and nothing
|
|
106
|
+
// errors. `StyledText` reaches for an inline style for exactly this reason.
|
|
107
|
+
//
|
|
108
|
+
// Applied only when the caller named no size, so their Panda `fontSize` class
|
|
109
|
+
// is not beaten by an inline declaration.
|
|
110
|
+
const sized = fontSize ? undefined : fontSizeMap[profile] ?? fontSizeMap.md;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<PandaFormLabel
|
|
114
|
+
htmlFor={htmlFor}
|
|
115
|
+
fontSize={fontSize}
|
|
116
|
+
style={{ ...(sized ? { fontSize: sized } : {}), ...style }}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
{required && (
|
|
121
|
+
<styled.span color="textError" marginLeft="0.25em" aria-hidden="true">
|
|
122
|
+
*
|
|
123
|
+
</styled.span>
|
|
124
|
+
)}
|
|
125
|
+
{optional && (
|
|
126
|
+
<>
|
|
127
|
+
{/* A real space, not a margin. The margin separated the words visually
|
|
128
|
+
but not in the accessible name, which came out "Middle
|
|
129
|
+
name(optional)" — the space has to be a text node to be announced. */}{" "}
|
|
130
|
+
<styled.span fontWeight="normal" color="textSecondary" fontSize="0.95em">
|
|
131
|
+
(optional)
|
|
132
|
+
</styled.span>
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</PandaFormLabel>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
StyledFormLabel.displayName = "StyledFormLabel";
|
|
140
|
+
|
|
141
|
+
export default StyledFormLabel;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { styled, type HTMLStyledProps } from "styled-system/jsx";
|
|
2
|
+
import { cx, css } from "styled-system/css";
|
|
3
|
+
import { stripedRecipe } from "styled-system/recipes";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { log } from "../config/logger";
|
|
6
|
+
import type { ConditionalValue } from "styled-system/types";
|
|
7
|
+
|
|
8
|
+
const PandaGrid = styled("div", {
|
|
9
|
+
base: {
|
|
10
|
+
display: "grid",
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export interface StyledGridProps extends Omit<HTMLStyledProps<"div">, "columns"> {
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
isStriped?: boolean;
|
|
17
|
+
showGridLines?: boolean;
|
|
18
|
+
templateColumns?: ConditionalValue<string>;
|
|
19
|
+
templateRows?: ConditionalValue<string>;
|
|
20
|
+
templateAreas?: ConditionalValue<string>;
|
|
21
|
+
columns?: ConditionalValue<number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
|
|
25
|
+
({
|
|
26
|
+
isStriped,
|
|
27
|
+
showGridLines,
|
|
28
|
+
className,
|
|
29
|
+
templateColumns,
|
|
30
|
+
templateRows,
|
|
31
|
+
templateAreas,
|
|
32
|
+
columns, ...props }, ref) => {
|
|
33
|
+
const combinedClassName = cx(
|
|
34
|
+
isStriped ? stripedRecipe() : undefined,
|
|
35
|
+
showGridLines
|
|
36
|
+
? css({
|
|
37
|
+
"& > *": {
|
|
38
|
+
outline: "1px solid",
|
|
39
|
+
outlineColor: "borderBgPrimary",
|
|
40
|
+
backgroundColor: "boxBgPrimary",
|
|
41
|
+
borderRadius: "15px",
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
: undefined,
|
|
45
|
+
className,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Map shorthand props to CSS grid properties
|
|
49
|
+
const gridTemplateColumns = props.gridTemplateColumns ?? templateColumns;
|
|
50
|
+
const gridTemplateRows = props.gridTemplateRows ?? templateRows;
|
|
51
|
+
const gridTemplateAreas = props.gridTemplateAreas ?? templateAreas;
|
|
52
|
+
delete props.gridTemplateColumns;
|
|
53
|
+
delete props.gridTemplateRows;
|
|
54
|
+
delete props.gridTemplateAreas;
|
|
55
|
+
|
|
56
|
+
// Handle columns prop (responsive column count)
|
|
57
|
+
let resolvedColumns = gridTemplateColumns;
|
|
58
|
+
if (columns !== undefined && !resolvedColumns) {
|
|
59
|
+
if (typeof columns === "number") {
|
|
60
|
+
resolvedColumns = `repeat(${columns}, 1fr)`;
|
|
61
|
+
} else if (typeof columns === "object" && columns !== null) {
|
|
62
|
+
const colObj = columns as { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
|
|
63
|
+
// For responsive objects, we need to generate a responsive value
|
|
64
|
+
const responsive: Record<string, string> = {};
|
|
65
|
+
if (colObj.base !== undefined) responsive.base = `repeat(${colObj.base}, 1fr)`;
|
|
66
|
+
if (colObj.sm !== undefined) responsive.sm = `repeat(${colObj.sm}, 1fr)`;
|
|
67
|
+
if (colObj.md !== undefined) responsive.md = `repeat(${colObj.md}, 1fr)`;
|
|
68
|
+
if (colObj.lg !== undefined) responsive.lg = `repeat(${colObj.lg}, 1fr)`;
|
|
69
|
+
if (colObj.xl !== undefined) responsive.xl = `repeat(${colObj.xl}, 1fr)`;
|
|
70
|
+
resolvedColumns = responsive as ConditionalValue<string>;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const childrenDetails = React.Children.map(props.children, (child) => {
|
|
75
|
+
if (React.isValidElement(child)) {
|
|
76
|
+
const element = child as React.ReactElement<{ id?: string }>;
|
|
77
|
+
const params = element.props;
|
|
78
|
+
return {
|
|
79
|
+
key: child.key,
|
|
80
|
+
id: params?.id,
|
|
81
|
+
type: typeof child.type === 'string' ? child.type : (child.type as React.FunctionComponent).displayName || (child.type as React.FunctionComponent).name || 'Unknown',
|
|
82
|
+
isNull: child === null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return "Non-Element Child";
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
log.debug("[StyledGrid] Rendering Grid", {
|
|
89
|
+
childCount: React.Children.count(props.children),
|
|
90
|
+
showGridLines,
|
|
91
|
+
childrenDetails
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<PandaGrid
|
|
96
|
+
ref={ref}
|
|
97
|
+
className={combinedClassName}
|
|
98
|
+
gridTemplateColumns={resolvedColumns}
|
|
99
|
+
gridTemplateRows={gridTemplateRows}
|
|
100
|
+
gridTemplateAreas={gridTemplateAreas}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
StyledGrid.displayName = "StyledGrid";
|
|
108
|
+
|
|
109
|
+
export default StyledGrid;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { styled } from "styled-system/jsx";
|
|
2
|
+
import type { HTMLStyledProps } from "styled-system/types";
|
|
3
|
+
|
|
4
|
+
const PandaGridItem = styled("div", {
|
|
5
|
+
base: {
|
|
6
|
+
gridColumn: "auto",
|
|
7
|
+
gridRow: "auto",
|
|
8
|
+
minWidth: 0,
|
|
9
|
+
minHeight: 0,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type StyledGridItemProps = HTMLStyledProps<"div">;
|
|
14
|
+
|
|
15
|
+
const StyledGridItem = PandaGridItem;
|
|
16
|
+
|
|
17
|
+
StyledGridItem.displayName = "StyledGridItem";
|
|
18
|
+
|
|
19
|
+
export default StyledGridItem;
|