@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,223 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { log } from "../config/logger";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { styled } from "styled-system/jsx";
|
|
6
|
+
import type { HTMLStyledProps } from "styled-system/types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A mark beside a wordmark, at one of three sizes.
|
|
10
|
+
*
|
|
11
|
+
* Copyright (C) 2026 StoneDogCode L.L.C. All rights reserved.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this exists
|
|
14
|
+
*
|
|
15
|
+
* HopperGuard carries **nine** logo files — `logos/{elder,hopperguard,kids}/
|
|
16
|
+
* logo-{large,medium,small}-*.tsx` — that are ~95% identical markup differing
|
|
17
|
+
* only in size constants and colour tokens. NEH-287 names the consequence: the
|
|
18
|
+
* per-size numbers drifted apart, so the logo's proportions were not stable
|
|
19
|
+
* across breakpoints, and a fix had to be applied nine times or not at all.
|
|
20
|
+
* Optima Filings Cloud was about to become the tenth.
|
|
21
|
+
*
|
|
22
|
+
* So the *structure* lives here once and the *content* is passed in. This
|
|
23
|
+
* package owns shape and knows nothing about any brand — no artwork, no
|
|
24
|
+
* colours, no product names. `logo` and `title` are both `ReactNode`, which is
|
|
25
|
+
* what lets HopperGuard pass its two-part `HOPPER`+`Guard` wordmark (two fonts,
|
|
26
|
+
* two colour tokens, one letter-spacing) while Optima passes a plain string.
|
|
27
|
+
*
|
|
28
|
+
* ## What it deliberately does NOT do
|
|
29
|
+
*
|
|
30
|
+
* **No trademark glyph.** The previous implementation nudged a `™` into place
|
|
31
|
+
* with hand-tuned pixel offsets that were different at every size — three magic
|
|
32
|
+
* pairs whose only job was to make one glyph land, and which a screen reader
|
|
33
|
+
* announced as "trade mark sign" on every page. NEH-287 moves that notice to
|
|
34
|
+
* the footer, where it is a stronger notice and read once. A caller that wants
|
|
35
|
+
* a symbol can put one in `title`; nothing here positions it.
|
|
36
|
+
*
|
|
37
|
+
* **No by-line.** Same issue: only one brand had one, and centring the column
|
|
38
|
+
* that held it pushed the wordmark visibly above the mark's optical centre.
|
|
39
|
+
* Provenance belongs beside the copyright, not in the chrome on every screen.
|
|
40
|
+
*
|
|
41
|
+
* With both gone the row is a mark and a single line of text, so
|
|
42
|
+
* `alignItems: center` centres it correctly with no compensating offset — which
|
|
43
|
+
* is the whole reason those offsets existed.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The three rungs. Named, not numeric, so a caller cannot invent a tenth. */
|
|
47
|
+
export const TITLE_LOGO_SIZES = ["small", "medium", "large"] as const;
|
|
48
|
+
export type TitleLogoSize = (typeof TITLE_LOGO_SIZES)[number];
|
|
49
|
+
|
|
50
|
+
export function isTitleLogoSize(value: unknown): value is TitleLogoSize {
|
|
51
|
+
return TITLE_LOGO_SIZES.includes(value as TitleLogoSize);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SizeMetrics {
|
|
55
|
+
/** Box the mark is given. Square: every mark here is a round or square badge. */
|
|
56
|
+
readonly mark: string;
|
|
57
|
+
/** Wordmark size. */
|
|
58
|
+
readonly title: string;
|
|
59
|
+
readonly subtitle: string;
|
|
60
|
+
readonly gap: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every value is real CSS, applied inline — NOT a Panda token key.
|
|
65
|
+
*
|
|
66
|
+
* These are consumed as `style={{ … }}` because Panda cannot statically resolve
|
|
67
|
+
* a runtime Record lookup (see the render below). A token key like `"lg"` would
|
|
68
|
+
* be meaningless as an inline value, so the type scale is reached through the
|
|
69
|
+
* host's own custom properties instead, exactly as `fontSizeMap` does: the host
|
|
70
|
+
* that defines `--font-sizes-lg` gets its scale, and the fallback covers a host
|
|
71
|
+
* that defines nothing.
|
|
72
|
+
*
|
|
73
|
+
* rem throughout, never px. A logo pinned in px ignores the browser's own
|
|
74
|
+
* font-size setting — the affordance people with low vision actually reach for
|
|
75
|
+
* — and this is the one piece of chrome on every page.
|
|
76
|
+
*/
|
|
77
|
+
export const TITLE_LOGO_METRICS: Record<TitleLogoSize, SizeMetrics> = {
|
|
78
|
+
small: {
|
|
79
|
+
mark: "2rem",
|
|
80
|
+
title: "var(--font-sizes-lg, 1.125rem)",
|
|
81
|
+
subtitle: "var(--font-sizes-xs, 0.75rem)",
|
|
82
|
+
gap: "0.5rem",
|
|
83
|
+
},
|
|
84
|
+
medium: {
|
|
85
|
+
mark: "3rem",
|
|
86
|
+
title: "var(--font-sizes-2xl, 1.5rem)",
|
|
87
|
+
subtitle: "var(--font-sizes-sm, 0.875rem)",
|
|
88
|
+
gap: "0.75rem",
|
|
89
|
+
},
|
|
90
|
+
large: {
|
|
91
|
+
mark: "4.5rem",
|
|
92
|
+
title: "var(--font-sizes-4xl, 2.25rem)",
|
|
93
|
+
subtitle: "var(--font-sizes-md, 1rem)",
|
|
94
|
+
gap: "1rem",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const Row = styled("span", {
|
|
99
|
+
base: {
|
|
100
|
+
display: "inline-flex",
|
|
101
|
+
alignItems: "center",
|
|
102
|
+
minWidth: 0,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const Mark = styled("span", {
|
|
107
|
+
base: {
|
|
108
|
+
display: "inline-flex",
|
|
109
|
+
alignItems: "center",
|
|
110
|
+
justifyContent: "center",
|
|
111
|
+
flexShrink: 0,
|
|
112
|
+
// Anything handed in — an <img>, an <svg>, a self-sizing component — is
|
|
113
|
+
// contained rather than allowed to set the row's height. Without this a
|
|
114
|
+
// logo with its own intrinsic size silently drives the header's height and
|
|
115
|
+
// the three rungs stop meaning anything.
|
|
116
|
+
"& > *": { maxWidth: "100%", maxHeight: "100%" },
|
|
117
|
+
"& img, & svg": { width: "100%", height: "100%", objectFit: "contain" },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const TextColumn = styled("span", {
|
|
122
|
+
base: {
|
|
123
|
+
display: "inline-flex",
|
|
124
|
+
flexDirection: "column",
|
|
125
|
+
justifyContent: "center",
|
|
126
|
+
minWidth: 0,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export interface TitleLogoProps extends Omit<HTMLStyledProps<"span">, "title"> {
|
|
131
|
+
/** The wordmark. A string, or nodes when the brand styles it in parts. */
|
|
132
|
+
title: React.ReactNode;
|
|
133
|
+
/** The mark. Omit for a text-only lockup. */
|
|
134
|
+
logo?: React.ReactNode | undefined;
|
|
135
|
+
/** Optional second line. Prefer the footer for provenance — see the header. */
|
|
136
|
+
subtitle?: React.ReactNode | undefined;
|
|
137
|
+
size?: TitleLogoSize | undefined;
|
|
138
|
+
/**
|
|
139
|
+
* Accessible name for the lockup as a whole.
|
|
140
|
+
*
|
|
141
|
+
* Given, the row becomes a single labelled image to assistive tech and the
|
|
142
|
+
* decorative mark is hidden — otherwise a screen reader announces the mark
|
|
143
|
+
* and the wordmark as two separate things, which is one thing too many for a
|
|
144
|
+
* logo. Defaults to `title` when that is a plain string.
|
|
145
|
+
*/
|
|
146
|
+
label?: string | undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const TitleLogo = React.forwardRef<HTMLSpanElement, TitleLogoProps>(
|
|
150
|
+
function TitleLogo(
|
|
151
|
+
{ title, logo, subtitle, size = "medium", label, ...rest },
|
|
152
|
+
ref,
|
|
153
|
+
) {
|
|
154
|
+
log.trace("TitleLogo rendered");
|
|
155
|
+
|
|
156
|
+
const metrics = TITLE_LOGO_METRICS[isTitleLogoSize(size) ? size : "medium"];
|
|
157
|
+
const accessibleName = label ?? (typeof title === "string" ? title : undefined);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Row
|
|
161
|
+
ref={ref}
|
|
162
|
+
// INLINE STYLE, not a Panda prop, and this is load-bearing.
|
|
163
|
+
//
|
|
164
|
+
// Panda finds styles by STATICALLY parsing source at the consumer's
|
|
165
|
+
// build. `gap={metrics.gap}` is a runtime lookup on a Record — Panda
|
|
166
|
+
// sees a non-literal, cannot resolve it, and emits no rule. The class
|
|
167
|
+
// still lands in the DOM, so it fails completely silently.
|
|
168
|
+
//
|
|
169
|
+
// Caught in optima-cloud-saas: the row rendered `gap: normal` and the
|
|
170
|
+
// mark at its natural 512px instead of 2rem, blowing the masthead out
|
|
171
|
+
// to 612px tall — while the literal styles in the `base` blocks below
|
|
172
|
+
// (display, alignItems, flexShrink, objectFit) all applied correctly.
|
|
173
|
+
// That split is the tell: literals extract, variables do not.
|
|
174
|
+
//
|
|
175
|
+
// Anything size-dependent is therefore inline. It also means this
|
|
176
|
+
// component cannot be broken by a consumer's `include` glob, which for
|
|
177
|
+
// a published design-system component is worth more than the styling
|
|
178
|
+
// indirection it gives up.
|
|
179
|
+
style={{ gap: metrics.gap, ...(rest.style ?? {}) }}
|
|
180
|
+
data-testid="title-logo"
|
|
181
|
+
data-size={size}
|
|
182
|
+
{...(accessibleName ? { role: "img", "aria-label": accessibleName } : {})}
|
|
183
|
+
{...rest}
|
|
184
|
+
>
|
|
185
|
+
{logo ? (
|
|
186
|
+
<Mark
|
|
187
|
+
data-testid="title-logo-mark"
|
|
188
|
+
style={{ width: metrics.mark, height: metrics.mark }}
|
|
189
|
+
// Hidden when the row already carries the name: announcing the mark
|
|
190
|
+
// separately would repeat it.
|
|
191
|
+
{...(accessibleName ? { "aria-hidden": true } : {})}
|
|
192
|
+
>
|
|
193
|
+
{logo}
|
|
194
|
+
</Mark>
|
|
195
|
+
) : null}
|
|
196
|
+
|
|
197
|
+
<TextColumn>
|
|
198
|
+
<styled.span
|
|
199
|
+
data-testid="title-logo-title"
|
|
200
|
+
style={{ fontSize: metrics.title }}
|
|
201
|
+
fontWeight="600"
|
|
202
|
+
lineHeight="1.1"
|
|
203
|
+
letterSpacing="-0.01em"
|
|
204
|
+
>
|
|
205
|
+
{title}
|
|
206
|
+
</styled.span>
|
|
207
|
+
{subtitle ? (
|
|
208
|
+
<styled.span
|
|
209
|
+
data-testid="title-logo-subtitle"
|
|
210
|
+
style={{ fontSize: metrics.subtitle }}
|
|
211
|
+
lineHeight="1.2"
|
|
212
|
+
opacity={0.75}
|
|
213
|
+
>
|
|
214
|
+
{subtitle}
|
|
215
|
+
</styled.span>
|
|
216
|
+
) : null}
|
|
217
|
+
</TextColumn>
|
|
218
|
+
</Row>
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
export default TitleLogo;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import StyledIcon, { type StyledIconProps } from "./StyledIcon";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a named icon component from any node.
|
|
8
|
+
*
|
|
9
|
+
* An icon set is a few hundred near-identical wrappers, and hand-writing them
|
|
10
|
+
* is how a set drifts — one forgets to forward `size`, another hardcodes a
|
|
11
|
+
* colour, a third omits `displayName` and shows up as `Unknown` in every stack
|
|
12
|
+
* trace. This makes each one a single line and forces them to agree.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { Home } from "lucide-react";
|
|
16
|
+
* export const StyledHome = createIcon("StyledHome", <Home />);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The artwork stays yours: nothing about the icon set reaches this package, so
|
|
20
|
+
* a commercially licensed set can be wrapped in a private package while the
|
|
21
|
+
* components that lay it out stay open.
|
|
22
|
+
*/
|
|
23
|
+
export function createIcon(
|
|
24
|
+
displayName: string,
|
|
25
|
+
node: React.ReactNode,
|
|
26
|
+
): React.FC<Omit<StyledIconProps, "icon" | "children">> {
|
|
27
|
+
const Icon: React.FC<Omit<StyledIconProps, "icon" | "children">> = (props) => (
|
|
28
|
+
<StyledIcon {...props} icon={node} />
|
|
29
|
+
);
|
|
30
|
+
Icon.displayName = displayName;
|
|
31
|
+
return Icon;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The same, for sets whose icons are components taking their own props —
|
|
36
|
+
* Lucide, Heroicons, react-icons, and anything else exporting one component per
|
|
37
|
+
* glyph.
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* import { Home } from "lucide-react";
|
|
41
|
+
* export const StyledHome = createIconFromComponent("StyledHome", Home);
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* The wrapped component is rendered with `width`/`height` at 100% so it fills
|
|
45
|
+
* the box `size` establishes rather than fighting it — most sets default to
|
|
46
|
+
* 24px and would otherwise ignore the size prop entirely.
|
|
47
|
+
*/
|
|
48
|
+
export function createIconFromComponent<P extends object>(
|
|
49
|
+
displayName: string,
|
|
50
|
+
Component: React.ComponentType<P>,
|
|
51
|
+
componentProps?: P,
|
|
52
|
+
): React.FC<Omit<StyledIconProps, "icon" | "children">> {
|
|
53
|
+
const Icon: React.FC<Omit<StyledIconProps, "icon" | "children">> = (props) => (
|
|
54
|
+
<StyledIcon
|
|
55
|
+
{...props}
|
|
56
|
+
icon={
|
|
57
|
+
<Component
|
|
58
|
+
{...({ width: "100%", height: "100%" } as unknown as P)}
|
|
59
|
+
{...(componentProps ?? ({} as P))}
|
|
60
|
+
/>
|
|
61
|
+
}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
Icon.displayName = displayName;
|
|
65
|
+
return Icon;
|
|
66
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import StyledButton, { type StyledButtonProps } from "./StyledButton";
|
|
5
|
+
import { useDensity } from "../config/style-config";
|
|
6
|
+
import { useIntentIcon, type IconIntent } from "../config/intent-icons";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds an "intent button" — Save, Delete, Edit and the rest.
|
|
10
|
+
*
|
|
11
|
+
* Each is a `StyledButton` that already knows three things a caller would
|
|
12
|
+
* otherwise repeat: which icon to draw, what the label says, and what the
|
|
13
|
+
* spinner says while it works. The point is not to save typing; it is that
|
|
14
|
+
* every Delete button in a product is the same Delete button.
|
|
15
|
+
*
|
|
16
|
+
* The icon comes from the **intent registry**, not from a prop, so this package
|
|
17
|
+
* ships no artwork and each host supplies its own set — Font Awesome in
|
|
18
|
+
* HopperGuard, Lucide in optima-filings. A per-call-site `icon` prop still
|
|
19
|
+
* wins for the rare one-off.
|
|
20
|
+
*
|
|
21
|
+
* ## Compact density drops the label — and that used to lose the name
|
|
22
|
+
*
|
|
23
|
+
* At `compact`, these render icon-only. In the originating app that produced a
|
|
24
|
+
* button with **no text and no `aria-label`**, so a screen reader announced it
|
|
25
|
+
* as "button" — the same defect as NEH-226, except this one appeared and
|
|
26
|
+
* disappeared with a user preference, which is worse: it passes review at
|
|
27
|
+
* `normal` and fails for the user who chose `compact`.
|
|
28
|
+
*
|
|
29
|
+
* So when the label is hidden it becomes the accessible name. The visible text
|
|
30
|
+
* and the announced name are the same string either way, which is the point of
|
|
31
|
+
* WCAG 2.5.3 (Label in Name).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface IntentButtonProps
|
|
35
|
+
extends Omit<StyledButtonProps, "leftIcon" | "children"> {
|
|
36
|
+
children?: React.ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Force icon-only, or force the label on. Defaults to icon-only at `compact`
|
|
39
|
+
* density.
|
|
40
|
+
*/
|
|
41
|
+
iconOnly?: boolean;
|
|
42
|
+
/** Override the registry for this one call site. */
|
|
43
|
+
icon?: React.ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface IntentButtonSpec {
|
|
47
|
+
/** Component name — shows in React DevTools and stack traces. */
|
|
48
|
+
displayName: string;
|
|
49
|
+
/** Which registry entry to draw. */
|
|
50
|
+
intent: IconIntent;
|
|
51
|
+
/** Visible text, and the accessible name when the label is hidden. */
|
|
52
|
+
defaultLabel: string;
|
|
53
|
+
/** What the spinner says while `loading`. Prefer naming the action. */
|
|
54
|
+
loadText?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Default hover/focus explanation.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately more than the label repeated: "Delete" is the label, "Delete
|
|
59
|
+
* the item" is what the tooltip adds. A caller's own `tooltip` overrides.
|
|
60
|
+
*/
|
|
61
|
+
tooltip?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createIntentButton({
|
|
65
|
+
displayName,
|
|
66
|
+
intent,
|
|
67
|
+
defaultLabel,
|
|
68
|
+
loadText,
|
|
69
|
+
tooltip: defaultTooltip,
|
|
70
|
+
}: IntentButtonSpec) {
|
|
71
|
+
const IntentButton = React.forwardRef<HTMLButtonElement, IntentButtonProps>(
|
|
72
|
+
function IntentButton(
|
|
73
|
+
{ children = defaultLabel, iconOnly, icon, tooltip = defaultTooltip, ...props },
|
|
74
|
+
ref,
|
|
75
|
+
) {
|
|
76
|
+
const density = useDensity();
|
|
77
|
+
const registered = useIntentIcon(intent);
|
|
78
|
+
const glyph = icon ?? registered;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bound to the USER's profile, not the resolved density step (NEH-251).
|
|
82
|
+
*
|
|
83
|
+
* The obvious reading of the ladder is "collapse the label when spacing
|
|
84
|
+
* gets tight", i.e. test `useDensityStep()`. That is wrong, and the
|
|
85
|
+
* counter-example is Optima: it bases at `compact`, so a user who has
|
|
86
|
+
* expressed no preference at all resolves to the `compact` rung and every
|
|
87
|
+
* intent button in the product would silently lose its text.
|
|
88
|
+
*
|
|
89
|
+
* Dropping a visible label is a change to what a control *says*. It
|
|
90
|
+
* should answer "did this user ask for tighter?", which is exactly what
|
|
91
|
+
* the profile means — it is relative to whatever the app chose. An app
|
|
92
|
+
* that wants dense spacing has not thereby asked for unlabelled buttons.
|
|
93
|
+
*/
|
|
94
|
+
const isIconOnly = iconOnly ?? density === "compact";
|
|
95
|
+
|
|
96
|
+
// The label as a string, for the accessible name. A node child cannot be
|
|
97
|
+
// flattened reliably, so fall back to the intent's own label rather than
|
|
98
|
+
// rendering `[object Object]` into aria-label.
|
|
99
|
+
const label = typeof children === "string" ? children : defaultLabel;
|
|
100
|
+
|
|
101
|
+
if (isIconOnly) {
|
|
102
|
+
return (
|
|
103
|
+
<StyledButton
|
|
104
|
+
ref={ref}
|
|
105
|
+
loadText={loadText}
|
|
106
|
+
// Without this the button has no text and no name at all — see the
|
|
107
|
+
// note above. An explicit aria-label from the caller still wins,
|
|
108
|
+
// because `props` is spread after.
|
|
109
|
+
aria-label={label}
|
|
110
|
+
tooltip={tooltip}
|
|
111
|
+
{...props}
|
|
112
|
+
>
|
|
113
|
+
{glyph}
|
|
114
|
+
</StyledButton>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<StyledButton
|
|
120
|
+
ref={ref}
|
|
121
|
+
loadText={loadText}
|
|
122
|
+
leftIcon={glyph}
|
|
123
|
+
tooltip={tooltip}
|
|
124
|
+
{...props}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</StyledButton>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
IntentButton.displayName = displayName;
|
|
133
|
+
return IntentButton;
|
|
134
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dictation seam.
|
|
3
|
+
*
|
|
4
|
+
* `StyledInputText` and `StyledInputTextArea` can render a microphone, but this
|
|
5
|
+
* package contains no speech code and never will. Recognition is a product
|
|
6
|
+
* decision with product consequences — which engine, whether audio may leave
|
|
7
|
+
* the device, what a regulated deployment is allowed to send where — and none
|
|
8
|
+
* of that belongs in a component library.
|
|
9
|
+
*
|
|
10
|
+
* So the host builds the behaviour and hands it over as one object. The input
|
|
11
|
+
* owns the *presentation*: where the button sits, how much padding the text
|
|
12
|
+
* needs so it does not run underneath, what it is called, what it announces
|
|
13
|
+
* while recording. The host owns everything else.
|
|
14
|
+
*
|
|
15
|
+
* HopperGuard's `useDictation()` already returns this shape, so its call sites
|
|
16
|
+
* pass the hook's result straight through. That is the intended fit: an adapter
|
|
17
|
+
* that needs a translation layer is usually a sign the seam is in the wrong
|
|
18
|
+
* place.
|
|
19
|
+
*
|
|
20
|
+
* ## Why an object rather than a set of props
|
|
21
|
+
*
|
|
22
|
+
* These fields are meaningless apart — `showRedo` with no `redo`, `isListening`
|
|
23
|
+
* on an input with no `onMicClick` — and passing one bundle makes the
|
|
24
|
+
* all-or-nothing nature obvious at the call site. It also means adding a
|
|
25
|
+
* capability later does not widen the component's prop surface.
|
|
26
|
+
*
|
|
27
|
+
* ## What the input does NOT do
|
|
28
|
+
*
|
|
29
|
+
* It does not decide whether dictation is *appropriate*. A password field, a
|
|
30
|
+
* PIN, a card number — the host omits the adapter, and no mic renders. Putting
|
|
31
|
+
* that judgement here would mean guessing from `type`, which is exactly the
|
|
32
|
+
* kind of guess that puts a microphone on a field that should never have one.
|
|
33
|
+
*
|
|
34
|
+
* Errors are the host's too. They arrive asynchronously and belong wherever
|
|
35
|
+
* that product shows problems — a toast, an inline message, a live region —
|
|
36
|
+
* which the input cannot know.
|
|
37
|
+
*/
|
|
38
|
+
export interface Dictation {
|
|
39
|
+
/**
|
|
40
|
+
* Whether this browser/engine can dictate at all. `false` renders no mic —
|
|
41
|
+
* a control that cannot work is worse than an absent one, because the user
|
|
42
|
+
* has to press it to find out.
|
|
43
|
+
*/
|
|
44
|
+
isSupported: boolean;
|
|
45
|
+
/** Recording now. Drives `aria-pressed` and the recording colour. */
|
|
46
|
+
isListening: boolean;
|
|
47
|
+
/** Offer "record again" — typically after a result the user may reject. */
|
|
48
|
+
showRedo?: boolean;
|
|
49
|
+
/** Start or stop recording. */
|
|
50
|
+
onMicClick: () => void;
|
|
51
|
+
/** Discard the last result and record again. Required when `showRedo`. */
|
|
52
|
+
redo?: () => void;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ask what a new recording should do to text that is already there.
|
|
56
|
+
*
|
|
57
|
+
* Only meaningful for multi-entry fields, where prose is written in passes.
|
|
58
|
+
* Overwriting someone's paragraph because they pressed the mic a second time
|
|
59
|
+
* is a data-loss bug, not a UX preference — so when the host raises this, the
|
|
60
|
+
* field stops and asks instead of guessing.
|
|
61
|
+
*
|
|
62
|
+
* Requires `chooseContinue` and `chooseStartOver`. While it is raised the
|
|
63
|
+
* redo button is suppressed: three competing choices about the same text is
|
|
64
|
+
* not a decision anyone can make quickly.
|
|
65
|
+
*/
|
|
66
|
+
continuePrompt?: boolean;
|
|
67
|
+
/** Append the new recording to what is already there. */
|
|
68
|
+
chooseContinue?: () => void;
|
|
69
|
+
/** Replace what is already there. */
|
|
70
|
+
chooseStartOver?: () => void;
|
|
71
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent buttons.
|
|
3
|
+
*
|
|
4
|
+
* One line each, because that is genuinely all they are: a `StyledButton` that
|
|
5
|
+
* knows its icon, its label and its loading text. The behaviour lives in
|
|
6
|
+
* `createIntentButton`; this file is the vocabulary.
|
|
7
|
+
*
|
|
8
|
+
* `loadText` is set only where the action takes long enough to show a spinner
|
|
9
|
+
* and where naming it helps — "Saving" tells the user more than "Loading".
|
|
10
|
+
*
|
|
11
|
+
* Not every wrapper in the originating app belongs here. Ones that call an API,
|
|
12
|
+
* read a feature flag or drive app state (`StyledColorButton`,
|
|
13
|
+
* `StyledDensityButton`, `StyledThemeButton`, `StyledVariantButton`,
|
|
14
|
+
* `StyledStyleResetButton`, `StyledHelpButton`, `StyledCopyButton`) are
|
|
15
|
+
* application components that happen to be buttons, and they stay in the app.
|
|
16
|
+
*/
|
|
17
|
+
import { createIntentButton } from "./create-intent-button";
|
|
18
|
+
|
|
19
|
+
export const StyledAddButton = createIntentButton({
|
|
20
|
+
displayName: "StyledAddButton",
|
|
21
|
+
intent: "add",
|
|
22
|
+
defaultLabel: "Add",
|
|
23
|
+
tooltip: "Add item",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const StyledSaveButton = createIntentButton({
|
|
27
|
+
displayName: "StyledSaveButton",
|
|
28
|
+
intent: "save",
|
|
29
|
+
defaultLabel: "Save",
|
|
30
|
+
loadText: "Saving",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const StyledDeleteButton = createIntentButton({
|
|
34
|
+
displayName: "StyledDeleteButton",
|
|
35
|
+
intent: "delete",
|
|
36
|
+
defaultLabel: "Delete",
|
|
37
|
+
loadText: "Deleting",
|
|
38
|
+
tooltip: "Delete the item",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const StyledEditButton = createIntentButton({
|
|
42
|
+
displayName: "StyledEditButton",
|
|
43
|
+
intent: "edit",
|
|
44
|
+
defaultLabel: "Edit",
|
|
45
|
+
tooltip: "Modify this item.",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const StyledCancelButton = createIntentButton({
|
|
49
|
+
displayName: "StyledCancelButton",
|
|
50
|
+
intent: "cancel",
|
|
51
|
+
defaultLabel: "Cancel",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const StyledCloneButton = createIntentButton({
|
|
55
|
+
displayName: "StyledCloneButton",
|
|
56
|
+
intent: "clone",
|
|
57
|
+
defaultLabel: "Clone",
|
|
58
|
+
tooltip: "Create a copy of the item.",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const StyledBackButton = createIntentButton({
|
|
62
|
+
displayName: "StyledBackButton",
|
|
63
|
+
intent: "back",
|
|
64
|
+
defaultLabel: "Back",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const StyledNextButton = createIntentButton({
|
|
68
|
+
displayName: "StyledNextButton",
|
|
69
|
+
intent: "next",
|
|
70
|
+
defaultLabel: "Next",
|
|
71
|
+
tooltip: "Proceed to next step.",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const StyledNewButton = createIntentButton({
|
|
75
|
+
displayName: "StyledNewButton",
|
|
76
|
+
intent: "new",
|
|
77
|
+
defaultLabel: "New",
|
|
78
|
+
tooltip: "Create a new item.",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const StyledRenameButton = createIntentButton({
|
|
82
|
+
displayName: "StyledRenameButton",
|
|
83
|
+
intent: "rename",
|
|
84
|
+
defaultLabel: "Rename",
|
|
85
|
+
tooltip: "Rename the item",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const StyledLoadButton = createIntentButton({
|
|
89
|
+
displayName: "StyledLoadButton",
|
|
90
|
+
intent: "load",
|
|
91
|
+
defaultLabel: "Load",
|
|
92
|
+
loadText: "Loading",
|
|
93
|
+
tooltip: "Load the item",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const StyledResumeButton = createIntentButton({
|
|
97
|
+
displayName: "StyledResumeButton",
|
|
98
|
+
intent: "resume",
|
|
99
|
+
defaultLabel: "Resume",
|
|
100
|
+
tooltip: "Resume activity",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const StyledPlayButton = createIntentButton({
|
|
104
|
+
displayName: "StyledPlayButton",
|
|
105
|
+
intent: "play",
|
|
106
|
+
defaultLabel: "Play",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// StyledFavoriteButton is deliberately absent. It is a stateful TOGGLE — its
|
|
110
|
+
// icon colour and its tooltip both flip on `isFavorite` ("Mark as favorite" /
|
|
111
|
+
// "Remove from favorites") — and the on-state colour is an app constant. That
|
|
112
|
+
// is a different component from "a button with a star on it", so it stays in
|
|
113
|
+
// the app rather than being bent into this factory.
|
|
114
|
+
|
|
115
|
+
export const StyledSettingsButton = createIntentButton({
|
|
116
|
+
displayName: "StyledSettingsButton",
|
|
117
|
+
intent: "settings",
|
|
118
|
+
defaultLabel: "Settings",
|
|
119
|
+
tooltip: "Changes settings",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const StyledHomeButton = createIntentButton({
|
|
123
|
+
displayName: "StyledHomeButton",
|
|
124
|
+
intent: "home",
|
|
125
|
+
defaultLabel: "Home",
|
|
126
|
+
tooltip: "Go to main page",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const StyledMenuButton = createIntentButton({
|
|
130
|
+
displayName: "StyledMenuButton",
|
|
131
|
+
intent: "menu",
|
|
132
|
+
defaultLabel: "Menu",
|
|
133
|
+
tooltip: "Open menu with additional options",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const StyledAnalyticsButton = createIntentButton({
|
|
137
|
+
displayName: "StyledAnalyticsButton",
|
|
138
|
+
intent: "analytics",
|
|
139
|
+
defaultLabel: "Analytics",
|
|
140
|
+
tooltip: "View data summary",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export const StyledEmojiButton = createIntentButton({
|
|
144
|
+
displayName: "StyledEmojiButton",
|
|
145
|
+
intent: "emoji",
|
|
146
|
+
defaultLabel: "Emoji",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const StyledUrlButton = createIntentButton({
|
|
150
|
+
displayName: "StyledUrlButton",
|
|
151
|
+
intent: "url",
|
|
152
|
+
defaultLabel: "From URL",
|
|
153
|
+
tooltip: "Retrieve colors from external location.",
|
|
154
|
+
});
|