@stonedogcode/style 0.15.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 +5 -5
- package/src/components/StyledFieldHelp.tsx +248 -0
- package/src/components/StyledPage.tsx +22 -0
- package/src/components/StyledTooltip.tsx +308 -39
- package/src/config/font-size.ts +25 -0
- package/src/index.ts +13 -0
- package/src/preset/index.ts +21 -0
- package/src/preset/recipes/button.ts +34 -4
- package/src/preset/recipes/form.ts +6 -1
- package/src/preset/recipes/icon-button.ts +36 -5
- package/src/preset/recipes/input-bool.ts +19 -3
- package/src/preset/recipes/input-radio.ts +11 -3
- package/src/preset/recipes/input-surface.ts +33 -6
- package/src/preset/recipes/list.ts +7 -1
- package/src/preset/recipes/tooltip.ts +30 -8
- package/src/preset/semantic-variables.ts +22 -5
- package/src/preset/z-layers.ts +101 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { log } from "../config/logger";
|
|
4
|
-
import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
|
|
4
|
+
import React, { useRef, useState, useLayoutEffect, useEffect, useCallback } from "react";
|
|
5
5
|
import { createPortal } from "react-dom";
|
|
6
6
|
import { styled } from "styled-system/jsx";
|
|
7
7
|
import StyledText from "./StyledText";
|
|
@@ -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
|
|
@@ -133,6 +143,42 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
133
143
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
134
144
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
135
145
|
const helpRef = useRef<HTMLButtonElement>(null);
|
|
146
|
+
/**
|
|
147
|
+
* Schedule the open.
|
|
148
|
+
*
|
|
149
|
+
* **Clearing first is the whole of NEH-818.** `show()` runs from four
|
|
150
|
+
* places — the trigger's mouseenter and focus, and the tooltip's own
|
|
151
|
+
* mouseenter — and more than one of them fires for a single gesture: a press
|
|
152
|
+
* both hovers and focuses the trigger, ~0ms apart. Assigning over
|
|
153
|
+
* `timeoutRef.current` left the earlier timer running with nothing holding
|
|
154
|
+
* its id, so `hide()` could cancel only the last one scheduled.
|
|
155
|
+
*
|
|
156
|
+
* The orphan then fired into a page the reader had already left, opening a
|
|
157
|
+
* tooltip that no departure event could ever close — measured as a live,
|
|
158
|
+
* opaque, click-eating overlay sitting over the dialog the press had just
|
|
159
|
+
* opened, gone only on reload.
|
|
160
|
+
*
|
|
161
|
+
* One timer at a time; the id is nulled when it fires so `hide()` never
|
|
162
|
+
* clears a stale one.
|
|
163
|
+
*
|
|
164
|
+
* Hoisted above the effects (and memoised) rather than declared beside the
|
|
165
|
+
* JSX: the ancestor-focus effect added for NEH-950 has to bind these as
|
|
166
|
+
* listeners, and a second copy of the timer discipline above is exactly how
|
|
167
|
+
* NEH-818 would come back.
|
|
168
|
+
*/
|
|
169
|
+
const show = useCallback(() => {
|
|
170
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
171
|
+
timeoutRef.current = setTimeout(() => {
|
|
172
|
+
timeoutRef.current = null;
|
|
173
|
+
setVisible(true);
|
|
174
|
+
}, delay);
|
|
175
|
+
}, [delay]);
|
|
176
|
+
const hide = useCallback(() => {
|
|
177
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
178
|
+
timeoutRef.current = null;
|
|
179
|
+
setVisible(false);
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
136
182
|
/**
|
|
137
183
|
* A hover trigger on a device that cannot hover is not a worse experience —
|
|
138
184
|
* it is an unreachable one. There is no hover event, and tapping the control
|
|
@@ -146,6 +192,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
146
192
|
const canHover = useCanHover();
|
|
147
193
|
const isClick = trigger === "click" || !canHover;
|
|
148
194
|
|
|
195
|
+
|
|
149
196
|
// The child may be any component (StyledIconButton, a link, a bare span), so
|
|
150
197
|
// whether it is focusable can only be known from the rendered DOM — React
|
|
151
198
|
// cannot see inside a child component's output. Starts as "yes" so the common
|
|
@@ -154,6 +201,38 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
154
201
|
const [focusableChild, setFocusableChild] = useState<HTMLElement | null>(null);
|
|
155
202
|
const [hasFocusableChild, setHasFocusableChild] = useState(true);
|
|
156
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The focusable element this trigger sits *inside*, if any (NEH-950).
|
|
206
|
+
*
|
|
207
|
+
* `hasFocusableChild` looks down and cannot see upwards, so it answers "no
|
|
208
|
+
* focusable child" for an icon that is decorative content inside a control
|
|
209
|
+
* that is already focusable and already named — and the trigger then took a
|
|
210
|
+
* `tabIndex` of its own. The result was the exact failure the conditional
|
|
211
|
+
* above exists to prevent, one level in: a second tab stop inside a button
|
|
212
|
+
* the reader has already passed, carrying no role and no name because
|
|
213
|
+
* `needsFallbackName` correctly declines to name it (the ancestor already
|
|
214
|
+
* has). Every icon in `stonedog-icons` that carries its own tooltip
|
|
215
|
+
* reproduced it, in every consumer.
|
|
216
|
+
*
|
|
217
|
+
* Deleting the `tabIndex` alone would have been a different WCAG failure
|
|
218
|
+
* rather than a fix — the tooltip must stay reachable by keyboard (2.1.1).
|
|
219
|
+
* So the ancestor becomes the trigger instead: it already owns the tab stop,
|
|
220
|
+
* and the effect below opens the tooltip when it takes focus, exactly as a
|
|
221
|
+
* focusable *child* already does by bubbling.
|
|
222
|
+
*
|
|
223
|
+
* Starts null, and the layout effect below can only ever find an ancestor
|
|
224
|
+
* when there is no focusable child — the two are mutually exclusive by
|
|
225
|
+
* construction, so nothing has to decide between them.
|
|
226
|
+
*/
|
|
227
|
+
const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True when something else — a descendant or an ancestor — already puts this
|
|
231
|
+
* trigger's content in the tab sequence. When it does, the trigger must add
|
|
232
|
+
* no stop of its own, and must not invent a role or a name for one.
|
|
233
|
+
*/
|
|
234
|
+
const insideFocusable = hasFocusableChild || focusableAncestor !== null;
|
|
235
|
+
|
|
157
236
|
// When the trigger KEEPS its tab stop it must have a role and a name (WCAG
|
|
158
237
|
// 2.2 4.1.2) — but only if nothing else already provides one. Borrowing the
|
|
159
238
|
// tooltip text unconditionally is what broke SharedWithIndicator, which names
|
|
@@ -165,14 +244,65 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
165
244
|
// a duplicated one is a new bug.
|
|
166
245
|
const [needsFallbackName, setNeedsFallbackName] = useState(false);
|
|
167
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
|
+
|
|
168
262
|
useLayoutEffect(() => {
|
|
169
263
|
const node = triggerRef.current;
|
|
170
|
-
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;
|
|
171
277
|
// Same-value setState is a no-op in React, so this cannot loop.
|
|
172
278
|
setFocusableChild((prev) => (prev === found ? prev : found));
|
|
173
279
|
setHasFocusableChild(found !== null);
|
|
174
280
|
|
|
281
|
+
// `parentElement.closest`, not `node.closest`: the trigger itself may be
|
|
282
|
+
// carrying the very `tabindex` this is deciding whether to keep, and
|
|
283
|
+
// matching ourselves would make the answer depend on the previous render.
|
|
284
|
+
// Only asked when there is no focusable child, because a child already
|
|
285
|
+
// settles the question and is the nearer trigger of the two.
|
|
286
|
+
const ancestor = found
|
|
287
|
+
? null
|
|
288
|
+
: node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
|
|
289
|
+
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
290
|
+
|
|
175
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
|
+
|
|
176
306
|
// parentElement, not the node itself: closest() would match our own
|
|
177
307
|
// aria-label once we set one, and the answer would flip every render.
|
|
178
308
|
const namedByAncestor = Boolean(
|
|
@@ -181,12 +311,12 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
181
311
|
// Text content names an element for free; so does a labelled descendant
|
|
182
312
|
// (an icon carrying its own aria-label, an <img alt>).
|
|
183
313
|
const namedByContent =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
node.
|
|
187
|
-
);
|
|
314
|
+
ownText.length > 0 ||
|
|
315
|
+
Array.from(
|
|
316
|
+
node.querySelectorAll('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
|
|
317
|
+
).some((el) => el !== help);
|
|
188
318
|
setNeedsFallbackName(!namedByAncestor && !namedByContent);
|
|
189
|
-
}, [children]);
|
|
319
|
+
}, [children, isClick]);
|
|
190
320
|
|
|
191
321
|
// aria-describedby has to sit on whatever actually receives focus, or a screen
|
|
192
322
|
// reader announces the control with no description. Set imperatively rather
|
|
@@ -194,7 +324,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
194
324
|
// component forwarding the prop, and a child that quietly drops it would fail
|
|
195
325
|
// invisibly.
|
|
196
326
|
useLayoutEffect(() => {
|
|
197
|
-
const node = focusableChild;
|
|
327
|
+
const node = focusableChild ?? focusableAncestor;
|
|
198
328
|
if (!node || !visible) return;
|
|
199
329
|
const previous = node.getAttribute("aria-describedby");
|
|
200
330
|
node.setAttribute(
|
|
@@ -205,7 +335,31 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
205
335
|
if (previous === null) node.removeAttribute("aria-describedby");
|
|
206
336
|
else node.setAttribute("aria-describedby", previous);
|
|
207
337
|
};
|
|
208
|
-
}, [focusableChild, visible, tooltipId]);
|
|
338
|
+
}, [focusableChild, focusableAncestor, visible, tooltipId]);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Open on the ANCESTOR's focus, when the trigger is inside one (NEH-950).
|
|
342
|
+
*
|
|
343
|
+
* A focusable *child* needs nothing here: `focusin`/`focusout` bubble, so the
|
|
344
|
+
* wrapper's own `onFocus`/`onBlur` already fire for it. An ancestor is the
|
|
345
|
+
* other direction, where nothing bubbles, so the listeners go on the ancestor
|
|
346
|
+
* itself.
|
|
347
|
+
*
|
|
348
|
+
* Without this the fix would trade WCAG 2.2 4.1.2 (a focusable element with
|
|
349
|
+
* no role and no name) for 2.1.1 — the explanation would be rendered and
|
|
350
|
+
* reachable by pointer only. Hover mode only: click mode never took a tab
|
|
351
|
+
* stop, so it has nothing to give back.
|
|
352
|
+
*/
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
const node = focusableAncestor;
|
|
355
|
+
if (isClick || !node) return;
|
|
356
|
+
node.addEventListener("focusin", show);
|
|
357
|
+
node.addEventListener("focusout", hide);
|
|
358
|
+
return () => {
|
|
359
|
+
node.removeEventListener("focusin", show);
|
|
360
|
+
node.removeEventListener("focusout", hide);
|
|
361
|
+
};
|
|
362
|
+
}, [focusableAncestor, isClick, show, hide]);
|
|
209
363
|
|
|
210
364
|
useLayoutEffect(() => {
|
|
211
365
|
if (visible && triggerRef.current && tooltipRef.current) {
|
|
@@ -269,9 +423,19 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
269
423
|
}
|
|
270
424
|
}, [visible, placement]);
|
|
271
425
|
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
426
|
+
// A pending open timer must not outlive the component. Nothing else clears
|
|
427
|
+
// it on unmount, so a trigger removed inside the delay window fired
|
|
428
|
+
// setVisible on a component React had already torn down.
|
|
429
|
+
useEffect(
|
|
430
|
+
() => () => {
|
|
431
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
432
|
+
timeoutRef.current = null;
|
|
433
|
+
},
|
|
434
|
+
[],
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Click mode's dismissal. A panel opened by a deliberate press has to be
|
|
438
|
+
// closable by a deliberate action — a press outside it, or Escape — or a
|
|
275
439
|
// keyboard user is stuck with it open.
|
|
276
440
|
useEffect(() => {
|
|
277
441
|
if (!isClick || !visible || typeof document === "undefined") return;
|
|
@@ -280,6 +444,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
280
444
|
if (event.key !== "Escape") return;
|
|
281
445
|
setVisible(false);
|
|
282
446
|
// Focus goes back to what opened it — never to the top of the document.
|
|
447
|
+
// Hover mode has no equivalent, because nothing was focused to open it.
|
|
283
448
|
helpRef.current?.focus();
|
|
284
449
|
};
|
|
285
450
|
const onPointerDown = (event: MouseEvent) => {
|
|
@@ -298,6 +463,69 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
298
463
|
};
|
|
299
464
|
}, [isClick, visible]);
|
|
300
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Hover mode's dismissal, which used to be nothing at all (NEH-818).
|
|
468
|
+
*
|
|
469
|
+
* `hide()` is reachable only from the trigger's own `onMouseLeave` /
|
|
470
|
+
* `onBlur`, so an open tooltip whose trigger never receives another
|
|
471
|
+
* departure event stays on the page for the life of the document — opaque,
|
|
472
|
+
* taking pointer events, over whatever opened on top of it. That is not a
|
|
473
|
+
* hypothetical ordering: a press both focuses the trigger and covers it, so
|
|
474
|
+
* blur cannot fire (focus stays put) and mouseleave has already been and
|
|
475
|
+
* gone.
|
|
476
|
+
*
|
|
477
|
+
* These two listeners are on `document`, so neither depends on the trigger
|
|
478
|
+
* being reachable — which is the property the trigger's own handlers lack.
|
|
479
|
+
*
|
|
480
|
+
* - **`pointermove`** closes it once the pointer is over neither the trigger
|
|
481
|
+
* nor the tooltip. It deliberately does not fire on the tooltip itself:
|
|
482
|
+
* WCAG 2.2 1.4.13 *Hoverable* requires the reader be able to move onto the
|
|
483
|
+
* revealed text without it vanishing, which is also why the portal keeps
|
|
484
|
+
* `pointer-events: auto`.
|
|
485
|
+
* - **Escape** satisfies 1.4.13 *Dismissible*, which hover mode did not meet
|
|
486
|
+
* before: content revealed on hover or focus must be dismissable without
|
|
487
|
+
* moving the pointer or focus, and a reader whose pointer is parked had no
|
|
488
|
+
* way to clear it.
|
|
489
|
+
*
|
|
490
|
+
* Bound only while a hover tooltip is actually open, so the common case
|
|
491
|
+
* costs nothing.
|
|
492
|
+
*/
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
if (isClick || !visible || typeof document === "undefined") return;
|
|
495
|
+
|
|
496
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
497
|
+
if (event.key !== "Escape") return;
|
|
498
|
+
// No focus move: in hover mode nothing was focused to open this, and
|
|
499
|
+
// stealing focus on Escape would be its own bug.
|
|
500
|
+
setVisible(false);
|
|
501
|
+
};
|
|
502
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
503
|
+
const target = event.target as Node | null;
|
|
504
|
+
if (!target) return;
|
|
505
|
+
if (triggerRef.current?.contains(target) || tooltipRef.current?.contains(target)) return;
|
|
506
|
+
// A tooltip revealed by FOCUS belongs to the focus, not to the pointer.
|
|
507
|
+
// WCAG 2.2 1.4.13 Persistent requires it to stay until its trigger is
|
|
508
|
+
// released, so taking it away because an unrelated mouse moved would
|
|
509
|
+
// trade one conformance failure for another — and would do it to a
|
|
510
|
+
// keyboard reader who never touched the mouse. Escape above is their
|
|
511
|
+
// dismissal.
|
|
512
|
+
if (
|
|
513
|
+
document.activeElement &&
|
|
514
|
+
triggerRef.current?.contains(document.activeElement)
|
|
515
|
+
) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
setVisible(false);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
document.addEventListener("keydown", onKeyDown);
|
|
522
|
+
document.addEventListener("pointermove", onPointerMove);
|
|
523
|
+
return () => {
|
|
524
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
525
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
526
|
+
};
|
|
527
|
+
}, [isClick, visible]);
|
|
528
|
+
|
|
301
529
|
if (!tooltip) {
|
|
302
530
|
return <>{children}</>;
|
|
303
531
|
}
|
|
@@ -306,13 +534,51 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
306
534
|
// would produce "[object Object]" in the accessibility tree.
|
|
307
535
|
const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
|
|
308
536
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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;
|
|
316
582
|
|
|
317
583
|
return (
|
|
318
584
|
<>
|
|
@@ -323,7 +589,13 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
323
589
|
// tooltip still fires without the wrapper taking focus itself. Adding
|
|
324
590
|
// tabIndex here regardless is what gave every tooltipped control two
|
|
325
591
|
// tab stops, the second of them silent (NEH-127).
|
|
326
|
-
|
|
592
|
+
//
|
|
593
|
+
// `insideFocusable`, not `hasFocusableChild`: an ANCESTOR owns the tab
|
|
594
|
+
// stop just as effectively as a descendant, and looking only downwards
|
|
595
|
+
// put the same silent second stop inside every icon button in the
|
|
596
|
+
// fleet (NEH-950). The ancestor-focus effect above is what keeps the
|
|
597
|
+
// tooltip reachable once the trigger stops taking focus itself.
|
|
598
|
+
tabIndex={isClick || insideFocusable ? undefined : 0}
|
|
327
599
|
// A focusable element needs a role and a name (WCAG 2.2 4.1.2). Applied
|
|
328
600
|
// only when the trigger keeps the tab stop AND nothing else names it —
|
|
329
601
|
// see needsFallbackName above for why the condition matters (NEH-151).
|
|
@@ -332,9 +604,9 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
332
604
|
// reveals content on focus, which is the closest standard role and what
|
|
333
605
|
// the ARIA tooltip pattern assumes of a trigger. A focusable generic
|
|
334
606
|
// with only a name still fails 4.1.2, which asks for both.
|
|
335
|
-
role={!isClick && !
|
|
607
|
+
role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
|
|
336
608
|
aria-label={
|
|
337
|
-
isClick ||
|
|
609
|
+
isClick || insideFocusable
|
|
338
610
|
? undefined
|
|
339
611
|
: ariaLabel ?? (needsFallbackName ? tooltipLabel : undefined)
|
|
340
612
|
}
|
|
@@ -344,24 +616,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
344
616
|
onMouseLeave={isClick ? undefined : hide}
|
|
345
617
|
onFocus={isClick ? undefined : show}
|
|
346
618
|
onBlur={isClick ? undefined : hide}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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}
|
|
350
629
|
{...rest}
|
|
351
630
|
>
|
|
631
|
+
{helpGoesFirst && helpControl}
|
|
352
632
|
{children}
|
|
353
|
-
{
|
|
354
|
-
<HelpTrigger
|
|
355
|
-
ref={helpRef}
|
|
356
|
-
type="button"
|
|
357
|
-
aria-label={helpLabel}
|
|
358
|
-
aria-expanded={visible}
|
|
359
|
-
aria-controls={visible ? tooltipId : undefined}
|
|
360
|
-
onClick={() => setVisible((open) => !open)}
|
|
361
|
-
>
|
|
362
|
-
?
|
|
363
|
-
</HelpTrigger>
|
|
364
|
-
)}
|
|
633
|
+
{!helpGoesFirst && helpControl}
|
|
365
634
|
</TooltipTrigger>
|
|
366
635
|
{visible && typeof document !== "undefined" &&
|
|
367
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
|
package/src/preset/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
createSemanticFonts,
|
|
37
37
|
createSemanticSizes,
|
|
38
38
|
} from "./semantic-variables";
|
|
39
|
+
import { createZIndexTokens } from "./z-layers";
|
|
39
40
|
|
|
40
41
|
export interface StonedogStylePresetOptions {
|
|
41
42
|
/**
|
|
@@ -184,6 +185,19 @@ export function stonedogStylePreset(options: StonedogStylePresetOptions = {}) {
|
|
|
184
185
|
*/
|
|
185
186
|
fonts: createSemanticFonts(cssVarPrefix),
|
|
186
187
|
fontWeights: createSemanticFontWeights(cssVarPrefix),
|
|
188
|
+
/**
|
|
189
|
+
* Named stacking layers (NEH-830). Neither this preset nor either
|
|
190
|
+
* base Panda preset defined any, so `drawerRecipe`'s `zIndex:
|
|
191
|
+
* "modal"` resolved to nothing and was emitted as the literal
|
|
192
|
+
* `z-index: modal` — invalid CSS the browser discards, leaving that
|
|
193
|
+
* panel with no z-index at all.
|
|
194
|
+
*
|
|
195
|
+
* Unlike the colour tokens these carry no custom property: a layer
|
|
196
|
+
* is not a brand decision and there is nothing for a theme to
|
|
197
|
+
* restyle. A HOST overrides the numbers in its own config; see
|
|
198
|
+
* z-layers.ts for why the names live here and the values do not.
|
|
199
|
+
*/
|
|
200
|
+
zIndex: createZIndexTokens(),
|
|
187
201
|
},
|
|
188
202
|
keyframes: {
|
|
189
203
|
spin: {
|
|
@@ -250,6 +264,13 @@ export {
|
|
|
250
264
|
requiredCssCustomProperties,
|
|
251
265
|
} from "./semantic-variables";
|
|
252
266
|
|
|
267
|
+
export {
|
|
268
|
+
Z_LAYERS,
|
|
269
|
+
createZIndexTokens,
|
|
270
|
+
zIndexTokenNames,
|
|
271
|
+
type ZLayerName,
|
|
272
|
+
} from "./z-layers";
|
|
273
|
+
|
|
253
274
|
export { recipes as stonedogStyleRecipes };
|
|
254
275
|
|
|
255
276
|
/* ------------------------------------------------------------------------- *
|
|
@@ -37,10 +37,20 @@ export const buttonRecipe = defineRecipe({
|
|
|
37
37
|
variant: {
|
|
38
38
|
solid: {
|
|
39
39
|
bg: "buttonBgAccent",
|
|
40
|
-
|
|
40
|
+
// The same pairing `outline` states below, and for the same reason
|
|
41
|
+
// (NEH-441, NEH-796). `textPrimary` is the colour that goes on
|
|
42
|
+
// `boxBgPrimary`, not on an accent surface: against optima's light
|
|
43
|
+
// theme that pairing measures 2.43:1, below WCAG AA, while
|
|
44
|
+
// `buttonTextAccent` measures 7.34:1. The dark theme happens to
|
|
45
|
+
// survive it, which is what let a default variant ship unreadable.
|
|
46
|
+
//
|
|
47
|
+
// `TEXT_BACKGROUND_PAIRS` in `semantic-variables.ts` is the contract
|
|
48
|
+
// being obeyed: `buttonTextAccent` sits on `buttonBgAccent`, and
|
|
49
|
+
// `buttonTextSecondary` on the `buttonBgSecondary` the hover repaints.
|
|
50
|
+
color: "buttonTextAccent",
|
|
41
51
|
_hover: {
|
|
42
52
|
bg: "buttonBgSecondary",
|
|
43
|
-
color: "
|
|
53
|
+
color: "buttonTextSecondary",
|
|
44
54
|
},
|
|
45
55
|
},
|
|
46
56
|
outline: {
|
|
@@ -77,7 +87,15 @@ export const buttonRecipe = defineRecipe({
|
|
|
77
87
|
border: "2px solid",
|
|
78
88
|
borderRadius: "xl",
|
|
79
89
|
bg: "buttonBgAccent",
|
|
80
|
-
|
|
90
|
+
// The same correction `solid` took in NEH-796, which this variant was
|
|
91
|
+
// missed by twice over (NEH-877): the pairing sweep that landed with it
|
|
92
|
+
// was scoped to this recipe, but it also skipped every variant named
|
|
93
|
+
// `glass` — on the reasoning that a translucent surface should inherit.
|
|
94
|
+
// This one is not translucent. It paints an opaque `buttonBgAccent` and
|
|
95
|
+
// blurs what is BEHIND it, so it owes a paired foreground like any
|
|
96
|
+
// other accent surface: 2.43:1 with `textPrimary` against optima's
|
|
97
|
+
// light theme, 7.34:1 with this.
|
|
98
|
+
color: "buttonTextAccent",
|
|
81
99
|
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
|
|
82
100
|
backdropFilter: "blur(12px)",
|
|
83
101
|
WebkitBackdropFilter: "blur(12px)",
|
|
@@ -86,6 +104,13 @@ export const buttonRecipe = defineRecipe({
|
|
|
86
104
|
transition: "all 0.3s ease",
|
|
87
105
|
_hover: {
|
|
88
106
|
bg: "buttonBgSecondary",
|
|
107
|
+
// Stated, because the hover repaints a DIFFERENT surface (NEH-877).
|
|
108
|
+
// Without it the label rides its base `buttonTextAccent` onto a
|
|
109
|
+
// secondary background — 1.06:1 in optima's light theme, white on
|
|
110
|
+
// near-white, i.e. the label vanishing on hover. This is the same
|
|
111
|
+
// move `solid` made in NEH-796 and the reason a base-colour fix has
|
|
112
|
+
// to look at every pseudo-state that repaints beneath it.
|
|
113
|
+
color: "buttonTextSecondary",
|
|
89
114
|
borderColor: "rgba(255,255,255,0.4)",
|
|
90
115
|
boxShadow: "0 8px 32px rgba(0,0,0,0.3), inset 0 0 20px rgba(255,255,255,0.1)",
|
|
91
116
|
transform: "translateY(-1px)",
|
|
@@ -126,7 +151,12 @@ export const buttonRecipe = defineRecipe({
|
|
|
126
151
|
},
|
|
127
152
|
},
|
|
128
153
|
selected: {
|
|
129
|
-
|
|
154
|
+
// `textAccent`, not `textPrimary` — the same mispairing `solid` had
|
|
155
|
+
// (NEH-796). `boxBgAccent` is an accent surface and the contract's
|
|
156
|
+
// partner for it is `textAccent`; `textPrimary` is the colour for
|
|
157
|
+
// `boxBgPrimary`, a different surface. Adjacent instance of the defect
|
|
158
|
+
// the issue names, in the same recipe, so it is fixed here.
|
|
159
|
+
color: "textAccent",
|
|
130
160
|
border: "3px dashed black",
|
|
131
161
|
borderRadius: "xl",
|
|
132
162
|
bg: "boxBgAccent",
|
|
@@ -15,7 +15,12 @@ export const formRecipe = defineRecipe({
|
|
|
15
15
|
variant: {
|
|
16
16
|
solid: {
|
|
17
17
|
bg: "boxBgAccent",
|
|
18
|
-
|
|
18
|
+
// `textAccent`, not `textPrimary` (NEH-877). `textPrimary` is the
|
|
19
|
+
// contract's partner for `boxBgPrimary`, a different surface: against
|
|
20
|
+
// optima's light theme, whose accent surface is a near-black graphite,
|
|
21
|
+
// that pairing measures 1.17:1 — a form whose every label is the exact
|
|
22
|
+
// colour of the panel behind it. `textAccent` measures 15.27:1.
|
|
23
|
+
color: "textAccent",
|
|
19
24
|
borderColor: "borderBgPrimary",
|
|
20
25
|
"& > li:not(:last-child)": {
|
|
21
26
|
borderBottom: "1px solid",
|