@stonedogcode/style 0.16.0 → 0.17.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 +49 -0
- package/package.json +1 -1
- package/src/components/StyledFieldHelp.tsx +248 -0
- package/src/components/StyledTooltip.tsx +117 -23
- package/src/config/font-size.ts +25 -0
- package/src/index.ts +13 -0
- package/src/preset/semantic-variables.ts +22 -5
package/README.md
CHANGED
|
@@ -629,6 +629,55 @@ Scroll mode needs a height to scroll inside: `StyledScrollbar` is
|
|
|
629
629
|
height (`display: flex; flex-direction: column; height: …`). Unconstrained, the
|
|
630
630
|
rail simply grows — which is correct, and is not a bug.
|
|
631
631
|
|
|
632
|
+
## Form help — `StyledFieldHelp`
|
|
633
|
+
|
|
634
|
+
Permanent explanatory text for a field: below the label, above the control,
|
|
635
|
+
always visible.
|
|
636
|
+
|
|
637
|
+
```tsx
|
|
638
|
+
<StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
|
|
639
|
+
<StyledFieldHelp htmlFor="dose">
|
|
640
|
+
Milligrams per tablet, as printed on the bottle.
|
|
641
|
+
</StyledFieldHelp>
|
|
642
|
+
<StyledInputText id="dose" />
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
It is **text, and nothing else** — no trigger, no disclosure, no popover, no
|
|
646
|
+
preference. That is the design rather than a simplification, and it is what the
|
|
647
|
+
guarantees below rest on.
|
|
648
|
+
|
|
649
|
+
### What it guarantees
|
|
650
|
+
|
|
651
|
+
- **Zero tab stops.** Nothing here is focusable, so explaining a hundred fields
|
|
652
|
+
costs the keyboard nothing. The pattern this replaces put a help *button*
|
|
653
|
+
beside every explained control, which roughly doubled keyboard traversal on a
|
|
654
|
+
busy form and cannot be fixed while a per-control control remains: taking the
|
|
655
|
+
buttons out of the tab order loses the help for sighted keyboard users
|
|
656
|
+
instead.
|
|
657
|
+
- **The control's `aria-describedby` points at it**, so a screen reader
|
|
658
|
+
announces the words as the field's description rather than reading them as
|
|
659
|
+
stray prose further down the page. The component sets the attribute itself,
|
|
660
|
+
merging with any description the control already had and removing only its own
|
|
661
|
+
id when it unmounts — so a call site that forgets still gets the association.
|
|
662
|
+
`fieldHelpId("dose")` is `"dose-help"`, exported so a host can put the
|
|
663
|
+
attribute in server-rendered markup instead; the component notices and stands
|
|
664
|
+
down.
|
|
665
|
+
- **No pointer is involved**, which sidesteps WCAG 1.4.13 (Content on Hover or
|
|
666
|
+
Focus) rather than trying to satisfy it. Touch, mouse and keyboard all get the
|
|
667
|
+
same words with no gesture and no setting.
|
|
668
|
+
- **One tier below the app-wide text size, and never below the smallest tier**
|
|
669
|
+
the host offers. A reader who has already turned their text down is the one
|
|
670
|
+
with the least room to spare.
|
|
671
|
+
- **Contrast is measured, not assumed.** The colour is the emphasis token
|
|
672
|
+
`textMuted`, which is `currentColor`-relative, so it de-emphasises correctly
|
|
673
|
+
on a light theme and a dark one. The component tests composite the whole
|
|
674
|
+
ancestor chain — every translucent layer, not the page background — and assert
|
|
675
|
+
WCAG 1.4.3 AA against the surface the text really paints on. Measuring against
|
|
676
|
+
the page is how text on a tinted chip gets a confident, wrong pass.
|
|
677
|
+
|
|
678
|
+
`children` is typed `ReactNode` for formatting — a unit, a `<strong>`, a line
|
|
679
|
+
break. Putting a control in there defeats the only promise the component makes.
|
|
680
|
+
|
|
632
681
|
## Adopting a component as it is migrated
|
|
633
682
|
|
|
634
683
|
Components move out of HopperGuard into this package one at a time.
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from "react";
|
|
4
|
+
import { styled } from "styled-system/jsx";
|
|
5
|
+
import type { HTMLStyledProps } from "styled-system/types";
|
|
6
|
+
import { log } from "../config/logger";
|
|
7
|
+
import { useFontSizeProfile } from "../config/style-config";
|
|
8
|
+
import { fontSizeMap, stepDownFontSize } from "../config/font-size";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permanent help text for a form control: below the label, above the control,
|
|
12
|
+
* always visible, never interactive.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
|
|
16
|
+
* <StyledFieldHelp htmlFor="dose">
|
|
17
|
+
* Milligrams per tablet, as printed on the bottle.
|
|
18
|
+
* </StyledFieldHelp>
|
|
19
|
+
* <StyledInputText id="dose" />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## Why this exists rather than another tooltip (PRD-0037, NEH-972)
|
|
23
|
+
*
|
|
24
|
+
* The pattern this replaces is `StyledTooltip` plus its `HelpTrigger`, and the
|
|
25
|
+
* problem with it is not placement — NEH-769 fixed the placement and the
|
|
26
|
+
* pattern was still wrong. Four things fail at once:
|
|
27
|
+
*
|
|
28
|
+
* - **Hover-only help excludes touch entirely**, and is actively hostile to a
|
|
29
|
+
* reader with a tremor: they open tooltips by accident and cannot reliably
|
|
30
|
+
* move a pointer *into* one before it closes.
|
|
31
|
+
* - **`HelpTrigger` is a `<button>`, so every instance is a tab stop.** A
|
|
32
|
+
* screenshot of one production screen showed roughly twenty of them; ~160
|
|
33
|
+
* across the app. That roughly doubles keyboard traversal, and it cannot be
|
|
34
|
+
* fixed by taking them out of the tab order — a sighted keyboard user not
|
|
35
|
+
* running a screen reader would lose the help altogether. There is no fix
|
|
36
|
+
* for the tab-stop tax that keeps a per-control control.
|
|
37
|
+
* - **Twenty identical glyphs are not twenty helps, they are noise.** Older
|
|
38
|
+
* readers have measurably low tolerance for hidden information behind an
|
|
39
|
+
* abstract icon, so the pattern penalised exactly the audience it was for.
|
|
40
|
+
* - It needed a **preference** (`accessibility.clickForTooltips`) to be usable
|
|
41
|
+
* on a touch device, and a preference is a thing to get wrong.
|
|
42
|
+
*
|
|
43
|
+
* So this component has **no trigger, no state, no preference and no
|
|
44
|
+
* interaction**. It is text. That is the entire design, and every constraint
|
|
45
|
+
* below follows from it:
|
|
46
|
+
*
|
|
47
|
+
* - it adds **zero tab stops** — no `tabindex`, no focusable element, nothing
|
|
48
|
+
* Tab can land on;
|
|
49
|
+
* - it needs **no pointer**, so it sidesteps WCAG 1.4.13 (Content on Hover or
|
|
50
|
+
* Focus) rather than trying to satisfy dismissible/hoverable/persistent;
|
|
51
|
+
* - it is in the DOM from first paint, so a touch reader, a keyboard reader and
|
|
52
|
+
* a screen-reader user all get the same words with no gesture at all.
|
|
53
|
+
*
|
|
54
|
+
* ## It wires `aria-describedby` itself, and that is deliberate
|
|
55
|
+
*
|
|
56
|
+
* Text sitting near a control is not a description of it. Without
|
|
57
|
+
* `aria-describedby` a screen reader announces "Dose, edit text" and the help
|
|
58
|
+
* is stray prose somewhere else in the reading order — which is how a field
|
|
59
|
+
* ends up *looking* explained and being unexplained.
|
|
60
|
+
*
|
|
61
|
+
* Two things make the association hard to get wrong, because this pattern is
|
|
62
|
+
* about to be applied at well over a hundred call sites and the one that gets
|
|
63
|
+
* skipped is the one nobody notices:
|
|
64
|
+
*
|
|
65
|
+
* 1. **The id is derived, not generated.** `fieldHelpId("dose")` is
|
|
66
|
+
* `"dose-help"` — deterministic from the control's own id, so both sides can
|
|
67
|
+
* name it without passing a generated value around, and it is stable across
|
|
68
|
+
* server and client render.
|
|
69
|
+
* 2. **The component sets the attribute on the control** in an effect, merging
|
|
70
|
+
* with anything already there. A call site that forgets still gets the
|
|
71
|
+
* association.
|
|
72
|
+
*
|
|
73
|
+
* Set imperatively rather than by cloning the child, for the reason
|
|
74
|
+
* `StyledTooltip` records: `cloneElement` depends on every child component
|
|
75
|
+
* forwarding the prop, and a child that quietly drops it fails invisibly. It is
|
|
76
|
+
* not a wrapper for the same reason — a wrapper would have to own the control's
|
|
77
|
+
* markup, and this has to drop into a form whose markup already exists.
|
|
78
|
+
*
|
|
79
|
+
* `useEffect` rather than `useLayoutEffect`: nothing here affects layout, and
|
|
80
|
+
* the accessibility tree is read after hydration. A host may still write
|
|
81
|
+
* `aria-describedby={fieldHelpId("dose")}` on the control itself if it wants
|
|
82
|
+
* the association present in server-rendered HTML; the effect sees it is
|
|
83
|
+
* already there and leaves it alone.
|
|
84
|
+
*
|
|
85
|
+
* ## Size and colour
|
|
86
|
+
*
|
|
87
|
+
* **One tier below the app-wide text size, never below `xs`.** The size is an
|
|
88
|
+
* inline style rather than a Panda prop because Panda extracts styles by
|
|
89
|
+
* parsing source at BUILD time: a prop whose value is only known at runtime
|
|
90
|
+
* yields a class name with no rule behind it, and nothing errors.
|
|
91
|
+
* `StyledFormLabel` and `StyledText` reach for an inline style for exactly this
|
|
92
|
+
* reason. Reading the profile also matters — plain inheritance would pin the
|
|
93
|
+
* help to whatever the browser default is, which in a product whose body text
|
|
94
|
+
* is 1.375rem makes the help less than two-thirds the size of the text it
|
|
95
|
+
* explains.
|
|
96
|
+
*
|
|
97
|
+
* **Colour is `textMuted`**, the emphasis axis, which resolves relative to
|
|
98
|
+
* `currentColor` — so it de-emphasises against the surface it is actually on,
|
|
99
|
+
* light theme or dark, rather than picking a grey that is right on one of them.
|
|
100
|
+
* `StyledFieldHelp.contrast.ct.tsx` measures the rendered result against the
|
|
101
|
+
* **composited** background — every ancestor layer, not the page — and asserts
|
|
102
|
+
* WCAG 1.4.3 AA. Measuring against the page background is how a confidently
|
|
103
|
+
* wrong pass gets produced for text that sits on a tinted chip.
|
|
104
|
+
*
|
|
105
|
+
* The size step and the colour step are two signals, not one, so the help still
|
|
106
|
+
* reads as secondary for anyone who cannot see the colour difference.
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
const PandaFieldHelp = styled("p", {
|
|
110
|
+
base: {
|
|
111
|
+
display: "block",
|
|
112
|
+
// Longhands, never the `margin` shorthand. Panda emits atomic rules, and a
|
|
113
|
+
// shorthand competing with a longhand for the same box is decided by
|
|
114
|
+
// stylesheet order rather than by what was written.
|
|
115
|
+
marginTop: "0",
|
|
116
|
+
marginInline: "0",
|
|
117
|
+
// The gap before the control. `StyledFormLabel` supplies the gap above.
|
|
118
|
+
marginBottom: "0.5rem",
|
|
119
|
+
color: "textMuted",
|
|
120
|
+
// Prose, and prose that is being read carefully — a little more leading
|
|
121
|
+
// than the label above it.
|
|
122
|
+
lineHeight: "1.4",
|
|
123
|
+
fontWeight: "normal",
|
|
124
|
+
// No `fontSize`: it is resolved at runtime from the profile. See above.
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The `id` this component gives its help text, derived from the control's id.
|
|
130
|
+
*
|
|
131
|
+
* Exported so a call site can put the association in server-rendered HTML —
|
|
132
|
+
* `aria-describedby={fieldHelpId("dose")}` — and so a test can name the element
|
|
133
|
+
* without reaching into the DOM for it. Deterministic on purpose: a generated
|
|
134
|
+
* id (`useId`) cannot be named by the other half of the pair without threading
|
|
135
|
+
* a value between two siblings, and threading is what gets skipped.
|
|
136
|
+
*/
|
|
137
|
+
export function fieldHelpId(controlId: string): string {
|
|
138
|
+
return `${controlId}-help`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Split an `aria-describedby` attribute into its id tokens. */
|
|
142
|
+
function idTokens(value: string | null): string[] {
|
|
143
|
+
return value ? value.split(/\s+/).filter(Boolean) : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface StyledFieldHelpProps
|
|
147
|
+
extends Omit<HTMLStyledProps<"p">, "children"> {
|
|
148
|
+
/**
|
|
149
|
+
* The `id` of the control this describes.
|
|
150
|
+
*
|
|
151
|
+
* Required, and it is the whole point: without it this is prose near a
|
|
152
|
+
* control rather than the control's description. Named `htmlFor` to match
|
|
153
|
+
* `StyledFormLabel`, so the pair reads the same at a call site.
|
|
154
|
+
*/
|
|
155
|
+
htmlFor: string;
|
|
156
|
+
/**
|
|
157
|
+
* The help itself. **Text.** Anything focusable put in here defeats the one
|
|
158
|
+
* guarantee this component makes, so it is typed as `ReactNode` for
|
|
159
|
+
* formatting (`<strong>`, a unit, a line break) rather than for controls.
|
|
160
|
+
*/
|
|
161
|
+
children: React.ReactNode;
|
|
162
|
+
/** Override the derived id. Rarely wanted — see `fieldHelpId`. */
|
|
163
|
+
id?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const StyledFieldHelp: React.FC<StyledFieldHelpProps> = ({
|
|
167
|
+
htmlFor,
|
|
168
|
+
children,
|
|
169
|
+
id,
|
|
170
|
+
style,
|
|
171
|
+
fontSize,
|
|
172
|
+
...props
|
|
173
|
+
}) => {
|
|
174
|
+
// Unconditional and at the top: folding this into the expression below reads
|
|
175
|
+
// fine and is a hooks-order violation the moment `fontSize` is passed.
|
|
176
|
+
const profile = useFontSizeProfile();
|
|
177
|
+
const ref = useRef<HTMLParagraphElement | null>(null);
|
|
178
|
+
|
|
179
|
+
const helpId = id ?? fieldHelpId(htmlFor);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const node = ref.current;
|
|
183
|
+
if (!node) return;
|
|
184
|
+
|
|
185
|
+
// The element's own document, not the global one: a component test mounts
|
|
186
|
+
// inside an iframe, and a host may portal into another window.
|
|
187
|
+
const control = node.ownerDocument.getElementById(htmlFor);
|
|
188
|
+
if (!control) {
|
|
189
|
+
// Not thrown. A missing control is a call-site bug, but the help text is
|
|
190
|
+
// still readable on screen and throwing would take the whole form down
|
|
191
|
+
// over an attribute. The host hears about it through its own logger.
|
|
192
|
+
log.warn(
|
|
193
|
+
"StyledFieldHelp: no element has this id, so the help is not announced as the field's description",
|
|
194
|
+
{ htmlFor, helpId },
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tokens = idTokens(control.getAttribute("aria-describedby"));
|
|
200
|
+
// Already named — the call site wired it statically. Leave it be, or the
|
|
201
|
+
// id lands twice and a screen reader reads the description twice.
|
|
202
|
+
if (tokens.includes(helpId)) return;
|
|
203
|
+
|
|
204
|
+
control.setAttribute("aria-describedby", [...tokens, helpId].join(" "));
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
// Read the attribute again rather than restoring the value captured
|
|
208
|
+
// above. Something else may have added its own id in the meantime — an
|
|
209
|
+
// error summary is the obvious one — and restoring a stale string would
|
|
210
|
+
// silently drop it.
|
|
211
|
+
const remaining = idTokens(
|
|
212
|
+
control.getAttribute("aria-describedby"),
|
|
213
|
+
).filter((token) => token !== helpId);
|
|
214
|
+
if (remaining.length > 0) {
|
|
215
|
+
control.setAttribute("aria-describedby", remaining.join(" "));
|
|
216
|
+
} else {
|
|
217
|
+
control.removeAttribute("aria-describedby");
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}, [htmlFor, helpId]);
|
|
221
|
+
|
|
222
|
+
// Applied only when the caller named no size, so their Panda `fontSize` class
|
|
223
|
+
// is not beaten by an inline declaration.
|
|
224
|
+
const sized = fontSize
|
|
225
|
+
? undefined
|
|
226
|
+
: fontSizeMap[stepDownFontSize(profile)] ?? fontSizeMap.sm;
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<PandaFieldHelp
|
|
230
|
+
ref={ref}
|
|
231
|
+
id={helpId}
|
|
232
|
+
fontSize={fontSize}
|
|
233
|
+
// A stable hook for the app's own end-to-end assertion that help is in
|
|
234
|
+
// the DOM with no pointer interaction (PRD-0037's success criteria), and
|
|
235
|
+
// for finding the call sites during the migration.
|
|
236
|
+
data-field-help="true"
|
|
237
|
+
style={{ ...(sized ? { fontSize: sized } : {}), ...style }}
|
|
238
|
+
{...props}
|
|
239
|
+
>
|
|
240
|
+
{children}
|
|
241
|
+
</PandaFieldHelp>
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
StyledFieldHelp.displayName = "StyledFieldHelp";
|
|
246
|
+
|
|
247
|
+
export default StyledFieldHelp;
|
|
248
|
+
export { StyledFieldHelp };
|
|
@@ -44,7 +44,6 @@ const HelpTrigger = styled("button", {
|
|
|
44
44
|
// is hard to hit is a help control that does not get used.
|
|
45
45
|
minWidth: "48px",
|
|
46
46
|
minHeight: "48px",
|
|
47
|
-
marginLeft: "4px",
|
|
48
47
|
borderRadius: "9999px",
|
|
49
48
|
borderWidth: "1px",
|
|
50
49
|
borderStyle: "solid",
|
|
@@ -53,6 +52,17 @@ const HelpTrigger = styled("button", {
|
|
|
53
52
|
lineHeight: "1",
|
|
54
53
|
verticalAlign: "middle",
|
|
55
54
|
},
|
|
55
|
+
variants: {
|
|
56
|
+
// Which side of the children the control sits on — see helpGoesFirst
|
|
57
|
+
// below for how that is decided. The gap has to follow the side, or the
|
|
58
|
+
// control touches its subject on one side and floats away from it on the
|
|
59
|
+
// other, which is exactly the ambiguity this fix is about.
|
|
60
|
+
side: {
|
|
61
|
+
before: { marginRight: "4px" },
|
|
62
|
+
after: { marginLeft: "4px" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultVariants: { side: "after" },
|
|
56
66
|
});
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -111,7 +121,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
111
121
|
"aria-label": ariaLabel,
|
|
112
122
|
variant,
|
|
113
123
|
trigger = "hover",
|
|
114
|
-
helpLabel
|
|
124
|
+
helpLabel,
|
|
115
125
|
...rest
|
|
116
126
|
}) => {
|
|
117
127
|
// Caller's variant, else the app-wide one, else `solid` — and anything the
|
|
@@ -182,6 +192,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
182
192
|
const canHover = useCanHover();
|
|
183
193
|
const isClick = trigger === "click" || !canHover;
|
|
184
194
|
|
|
195
|
+
|
|
185
196
|
// The child may be any component (StyledIconButton, a link, a bare span), so
|
|
186
197
|
// whether it is focusable can only be known from the rendered DOM — React
|
|
187
198
|
// cannot see inside a child component's output. Starts as "yes" so the common
|
|
@@ -233,9 +244,36 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
233
244
|
// a duplicated one is a new bug.
|
|
234
245
|
const [needsFallbackName, setNeedsFallbackName] = useState(false);
|
|
235
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The child's own visible text, used to name the help control after the
|
|
249
|
+
* thing it explains (NEH-769).
|
|
250
|
+
*
|
|
251
|
+
* A screen carrying twenty tooltips carried twenty buttons all called "More
|
|
252
|
+
* information", which names nothing: a reader tabbing through hears the same
|
|
253
|
+
* four words twenty times and cannot tell which one answers their question.
|
|
254
|
+
* "Help: Require PIN" is the same control with a name that distinguishes it.
|
|
255
|
+
*
|
|
256
|
+
* Measured from the DOM rather than read from `children` because the child
|
|
257
|
+
* may be any component — React cannot see the text inside a child component's
|
|
258
|
+
* output, only the element it was handed.
|
|
259
|
+
*/
|
|
260
|
+
const [subjectLabel, setSubjectLabel] = useState("");
|
|
261
|
+
|
|
236
262
|
useLayoutEffect(() => {
|
|
237
263
|
const node = triggerRef.current;
|
|
238
|
-
const
|
|
264
|
+
const help = helpRef.current;
|
|
265
|
+
|
|
266
|
+
// The help control is itself a `button`, so it matches FOCUSABLE_SELECTOR
|
|
267
|
+
// and must be excluded from every question asked about the CHILD. This was
|
|
268
|
+
// already wrong before the control could be rendered first — with a
|
|
269
|
+
// non-focusable child the query returned the help button, so
|
|
270
|
+
// aria-describedby landed on the button that already names itself instead
|
|
271
|
+
// of on the thing being described. Once the control renders first it would
|
|
272
|
+
// have matched every time (NEH-769).
|
|
273
|
+
const found =
|
|
274
|
+
Array.from(node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []).find(
|
|
275
|
+
(candidate) => candidate !== help,
|
|
276
|
+
) ?? null;
|
|
239
277
|
// Same-value setState is a no-op in React, so this cannot loop.
|
|
240
278
|
setFocusableChild((prev) => (prev === found ? prev : found));
|
|
241
279
|
setHasFocusableChild(found !== null);
|
|
@@ -251,6 +289,20 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
251
289
|
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
252
290
|
|
|
253
291
|
if (!node) return;
|
|
292
|
+
|
|
293
|
+
// The child's own text — the help control's "?" deliberately excluded, or
|
|
294
|
+
// every subject would be named "… ?" and a text-free child would look as
|
|
295
|
+
// though it had text.
|
|
296
|
+
const ownText = Array.from(node.childNodes)
|
|
297
|
+
.filter((child) => child !== help)
|
|
298
|
+
.map((child) => child.textContent ?? "")
|
|
299
|
+
.join(" ")
|
|
300
|
+
.replace(/\s+/g, " ")
|
|
301
|
+
.trim();
|
|
302
|
+
// Long enough to distinguish twenty controls, short enough that a screen
|
|
303
|
+
// reader does not read a paragraph before the reader can act on it.
|
|
304
|
+
setSubjectLabel(ownText.length > 80 ? `${ownText.slice(0, 80).trimEnd()}…` : ownText);
|
|
305
|
+
|
|
254
306
|
// parentElement, not the node itself: closest() would match our own
|
|
255
307
|
// aria-label once we set one, and the answer would flip every render.
|
|
256
308
|
const namedByAncestor = Boolean(
|
|
@@ -259,12 +311,12 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
259
311
|
// Text content names an element for free; so does a labelled descendant
|
|
260
312
|
// (an icon carrying its own aria-label, an <img alt>).
|
|
261
313
|
const namedByContent =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
node.
|
|
265
|
-
);
|
|
314
|
+
ownText.length > 0 ||
|
|
315
|
+
Array.from(
|
|
316
|
+
node.querySelectorAll('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
|
|
317
|
+
).some((el) => el !== help);
|
|
266
318
|
setNeedsFallbackName(!namedByAncestor && !namedByContent);
|
|
267
|
-
}, [children]);
|
|
319
|
+
}, [children, isClick]);
|
|
268
320
|
|
|
269
321
|
// aria-describedby has to sit on whatever actually receives focus, or a screen
|
|
270
322
|
// reader announces the control with no description. Set imperatively rather
|
|
@@ -482,6 +534,51 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
482
534
|
// would produce "[object Object]" in the accessibility tree.
|
|
483
535
|
const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
|
|
484
536
|
|
|
537
|
+
/**
|
|
538
|
+
* `Label → ? → Input` — the help control goes after the label and before the
|
|
539
|
+
* control it explains, never after it (NEH-769).
|
|
540
|
+
*
|
|
541
|
+
* Not aesthetic. A screen-magnifier user reads linearly at high zoom, so a
|
|
542
|
+
* `?` placed after a long input is pushed off the visible viewport entirely;
|
|
543
|
+
* before the control, the reader meets the concept, can ask what it means,
|
|
544
|
+
* and only then enters data.
|
|
545
|
+
*
|
|
546
|
+
* Consumers wrap two shapes and both have to obey that rule, which is why
|
|
547
|
+
* neither a fixed "always before" nor a fixed "always after" is right:
|
|
548
|
+
*
|
|
549
|
+
* `<Tooltip><Label/></Tooltip> <Input/>` the input is OUTSIDE us, so
|
|
550
|
+
* the control goes AFTER → Label ? | Input
|
|
551
|
+
* `<Tooltip><Row><Label/><Toggle/></Row></Tooltip>`
|
|
552
|
+
* the control is INSIDE us, so
|
|
553
|
+
* it goes BEFORE → ? Label Toggle
|
|
554
|
+
*
|
|
555
|
+
* So the side keys on whether the children contain something focusable —
|
|
556
|
+
* which the component already measures for its own tab-stop logic. It is
|
|
557
|
+
* measured in a layout effect, so it settles before paint rather than
|
|
558
|
+
* flickering into place.
|
|
559
|
+
*/
|
|
560
|
+
const helpGoesFirst = hasFocusableChild;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Explicit label wins; otherwise name the control after its subject. Only
|
|
564
|
+
* when there is no text at all does it fall back to the old generic name.
|
|
565
|
+
*/
|
|
566
|
+
const resolvedHelpLabel =
|
|
567
|
+
helpLabel ?? (subjectLabel ? `Help: ${subjectLabel}` : "More information");
|
|
568
|
+
|
|
569
|
+
const helpControl = isClick ? (
|
|
570
|
+
<HelpTrigger
|
|
571
|
+
ref={helpRef}
|
|
572
|
+
type="button"
|
|
573
|
+
side={helpGoesFirst ? "before" : "after"}
|
|
574
|
+
aria-label={resolvedHelpLabel}
|
|
575
|
+
aria-expanded={visible}
|
|
576
|
+
aria-controls={visible ? tooltipId : undefined}
|
|
577
|
+
onClick={() => setVisible((open) => !open)}
|
|
578
|
+
>
|
|
579
|
+
?
|
|
580
|
+
</HelpTrigger>
|
|
581
|
+
) : null;
|
|
485
582
|
|
|
486
583
|
return (
|
|
487
584
|
<>
|
|
@@ -519,24 +616,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
519
616
|
onMouseLeave={isClick ? undefined : hide}
|
|
520
617
|
onFocus={isClick ? undefined : show}
|
|
521
618
|
onBlur={isClick ? undefined : hide}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
619
|
+
// When something focusable is in play the description is set
|
|
620
|
+
// imperatively on THAT element instead (see the layout effect above) —
|
|
621
|
+
// it has to sit on whatever actually receives focus. This wrapper is
|
|
622
|
+
// the fallback for label-only children, and it applies in click mode
|
|
623
|
+
// too: help that is reachable but never announced is help a screen
|
|
624
|
+
// reader user does not know exists (NEH-769).
|
|
625
|
+
//
|
|
626
|
+
// `insideFocusable`, not `hasFocusableChild`, so a focusable ANCESTOR
|
|
627
|
+
// still owns the description rather than this wrapper (NEH-950).
|
|
628
|
+
aria-describedby={!insideFocusable && visible ? tooltipId : undefined}
|
|
525
629
|
{...rest}
|
|
526
630
|
>
|
|
631
|
+
{helpGoesFirst && helpControl}
|
|
527
632
|
{children}
|
|
528
|
-
{
|
|
529
|
-
<HelpTrigger
|
|
530
|
-
ref={helpRef}
|
|
531
|
-
type="button"
|
|
532
|
-
aria-label={helpLabel}
|
|
533
|
-
aria-expanded={visible}
|
|
534
|
-
aria-controls={visible ? tooltipId : undefined}
|
|
535
|
-
onClick={() => setVisible((open) => !open)}
|
|
536
|
-
>
|
|
537
|
-
?
|
|
538
|
-
</HelpTrigger>
|
|
539
|
-
)}
|
|
633
|
+
{!helpGoesFirst && helpControl}
|
|
540
634
|
</TooltipTrigger>
|
|
541
635
|
{visible && typeof document !== "undefined" &&
|
|
542
636
|
createPortal(
|
package/src/config/font-size.ts
CHANGED
|
@@ -147,3 +147,28 @@ export function stepUpFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
|
|
|
147
147
|
// clamp fails safe instead of returning undefined to a caller typed otherwise.
|
|
148
148
|
return next ?? size;
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The next size DOWN, clamped at the bottom of the scale.
|
|
153
|
+
*
|
|
154
|
+
* The counterpart to `stepUpFontSize`, added for `StyledFieldHelp` (NEH-972),
|
|
155
|
+
* and the clamp is the load-bearing half. Inline help is deliberately one tier
|
|
156
|
+
* below the text it accompanies — but "one tier below" must never mean "below
|
|
157
|
+
* the smallest tier the host offers", because the reader who has turned their
|
|
158
|
+
* text size all the way down is the reader with the least room to spare. At
|
|
159
|
+
* `xs` this returns `xs`, so help matches the body text rather than shrinking
|
|
160
|
+
* past it.
|
|
161
|
+
*
|
|
162
|
+
* Steps through `FONT_SIZE_ORDER`, so it moves through whatever scale the host
|
|
163
|
+
* has pinned its `--font-sizes-*` properties to rather than through a fixed set
|
|
164
|
+
* of pixel values.
|
|
165
|
+
*/
|
|
166
|
+
export function stepDownFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
|
|
167
|
+
const index = FONT_SIZE_ORDER.indexOf(size);
|
|
168
|
+
if (index === -1) return size;
|
|
169
|
+
const next = FONT_SIZE_ORDER[Math.max(index - steps, 0)];
|
|
170
|
+
// Clamped into range above, so this cannot miss — but staying total means a
|
|
171
|
+
// future change to the clamp fails safe rather than handing a caller
|
|
172
|
+
// `undefined` from a function typed otherwise. Same shape as stepUpFontSize.
|
|
173
|
+
return next ?? size;
|
|
174
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export {
|
|
|
48
48
|
getFontSizeLabel,
|
|
49
49
|
getFontSizeValue,
|
|
50
50
|
stepUpFontSize,
|
|
51
|
+
stepDownFontSize,
|
|
51
52
|
FONT_SIZE_ORDER,
|
|
52
53
|
} from "./config/font-size";
|
|
53
54
|
|
|
@@ -197,6 +198,18 @@ export type { StyledTooltipProps } from "./components/StyledTooltip";
|
|
|
197
198
|
export { default as StyledFormLabel } from "./components/StyledFormLabel";
|
|
198
199
|
export type { StyledFormLabelProps } from "./components/StyledFormLabel";
|
|
199
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Permanent inline help for a field — PRD-0037's replacement for the hover
|
|
203
|
+
* tooltip and its `?` button. `fieldHelpId` is exported so a host can put the
|
|
204
|
+
* `aria-describedby` association in server-rendered HTML.
|
|
205
|
+
*/
|
|
206
|
+
export {
|
|
207
|
+
default as StyledFieldHelp,
|
|
208
|
+
StyledFieldHelp as FieldHelp,
|
|
209
|
+
fieldHelpId,
|
|
210
|
+
} from "./components/StyledFieldHelp";
|
|
211
|
+
export type { StyledFieldHelpProps } from "./components/StyledFieldHelp";
|
|
212
|
+
|
|
200
213
|
// ---------------------------------------------------------------------------
|
|
201
214
|
// Components that were blocked on a runtime dependency until NEH-430 gave each
|
|
202
215
|
// a seam with a working default. None of them adds a dependency; the host
|
|
@@ -154,13 +154,30 @@ const COLOR_TOKENS: TokenMap = {
|
|
|
154
154
|
* with a sensible default so every project can adopt it immediately — applied
|
|
155
155
|
* to the case where a default is genuinely knowable.
|
|
156
156
|
*
|
|
157
|
-
* ##
|
|
157
|
+
* ## `textMuted` is measured. `textSubtle` is still only chosen (NEH-974)
|
|
158
158
|
*
|
|
159
159
|
* Alpha de-emphasis trades contrast for hierarchy, and past some point it
|
|
160
|
-
* trades away legibility
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
160
|
+
* trades away legibility, so these percentages want measuring rather than
|
|
161
|
+
* picking.
|
|
162
|
+
*
|
|
163
|
+
* This comment claimed both tiers were measured by a file called
|
|
164
|
+
* `emphasis-contrast.ct.tsx`, **and no such file has ever existed** — the whole
|
|
165
|
+
* repo contains exactly one reference to that name, this one. A documented
|
|
166
|
+
* guard nobody implemented is worse than an absent one: it is a guard everybody
|
|
167
|
+
* believes in, and it is what made "the values below are what passed" read as a
|
|
168
|
+
* measurement rather than as a guess.
|
|
169
|
+
*
|
|
170
|
+
* What is true today: `components/StyledFieldHelp.contrast.ct.tsx` measures
|
|
171
|
+
* **`textMuted`** in a real browser, composited over the surface it actually
|
|
172
|
+
* paints on rather than over the page, and asserts WCAG 1.4.3 AA (4.5:1). On
|
|
173
|
+
* the harness theme it clears comfortably — 11.7:1 on the page, 9.1:1 on an
|
|
174
|
+
* opaque card, 8.2:1 on a translucent chip over that card.
|
|
175
|
+
*
|
|
176
|
+
* **`textSubtle` at 64% is not measured anywhere.** It is the tier closer to
|
|
177
|
+
* the legibility floor, so it is the one that needed the check more. Tracked;
|
|
178
|
+
* do not restore the claim that it passed something.
|
|
179
|
+
*
|
|
180
|
+
* A host that wants a stronger or weaker step defines the property.
|
|
164
181
|
*/
|
|
165
182
|
const EMPHASIS_TOKENS: Record<string, [suffix: string, fallback: string]> = {
|
|
166
183
|
/** Secondary information: still read, just not first. */
|