@stonedogcode/style 0.13.0 → 0.16.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 +1 -1
- package/package.json +5 -5
- package/src/components/StyledLink.tsx +99 -2
- package/src/components/StyledPage.tsx +22 -0
- package/src/components/StyledTag.tsx +57 -20
- package/src/components/StyledTooltip.tsx +192 -17
- package/src/index.ts +2 -2
- package/src/preset/index.ts +24 -1
- 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/tag.ts +96 -0
- package/src/preset/recipes/tooltip.ts +30 -8
- package/src/preset/z-layers.ts +101 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ the whole component set re-skins at runtime. No component here knows a colour.
|
|
|
12
12
|
|
|
13
13
|
## Status
|
|
14
14
|
|
|
15
|
-
Early. The preset is complete (
|
|
15
|
+
Early. The preset is complete (23 recipes, 43 colour tokens); the component set
|
|
16
16
|
is being extracted incrementally and currently covers the layout and typography
|
|
17
17
|
primitives. See [CLAUDE.md](./CLAUDE.md) for the architecture and the
|
|
18
18
|
contribution rules.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "StoneDogCode L.L.C.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/stonedog-code/stonedog-style.git"
|
|
10
10
|
},
|
|
11
|
-
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer
|
|
11
|
+
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer \u2014 which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./preset": "./src/preset/index.ts",
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
|
-
"//files": "The package ships SOURCE, so `src` has to go out whole
|
|
23
|
+
"//files": "The package ships SOURCE, so `src` has to go out whole \u2014 but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
26
|
"!src/**/__tests__/**",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"pretype-check": "npm run panda:prepare",
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
40
|
"lint": "eslint . --ext ts,tsx",
|
|
41
|
-
"//pretest": "panda:build, not panda:prepare
|
|
41
|
+
"//pretest": "panda:build, not panda:prepare \u2014 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
|
|
42
42
|
"pretest": "npm run panda:build",
|
|
43
43
|
"test": "jest",
|
|
44
44
|
"pretest:ct": "npm run panda:build",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
|
|
47
47
|
"test:coverage": "jest --coverage",
|
|
48
48
|
"gate": "npm run type-check && npm run lint && npm run test",
|
|
49
|
-
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively
|
|
49
|
+
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively \u2014 npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
|
|
50
50
|
"publish:stonedog-style": "bash scripts/publish-package.sh",
|
|
51
51
|
"version:bump:patch": "npm version patch --no-git-tag-version",
|
|
52
52
|
"version:bump:minor": "npm version minor --no-git-tag-version",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { buttonRecipe } from "styled-system/recipes";
|
|
5
|
-
import { cx } from "styled-system/css";
|
|
5
|
+
import { css, cx } from "styled-system/css";
|
|
6
6
|
import { useLinkComponent, useResolvedVariant } from "../config/style-config";
|
|
7
7
|
import { ALL_VARIANTS } from "../config/types";
|
|
8
8
|
|
|
@@ -17,6 +17,15 @@ import { ALL_VARIANTS } from "../config/types";
|
|
|
17
17
|
*/
|
|
18
18
|
const LINK_VARIANTS = ALL_VARIANTS;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How a link sits in its surroundings.
|
|
22
|
+
*
|
|
23
|
+
* `text` is the default and the safe one: a link in a sentence must not become
|
|
24
|
+
* a 48px control. `flow` is the layout participant. `control` is the tap
|
|
25
|
+
* target, and carries the house 48px floor.
|
|
26
|
+
*/
|
|
27
|
+
export type LinkPresentation = "text" | "flow" | "control";
|
|
28
|
+
|
|
20
29
|
export interface StyledLinkProps
|
|
21
30
|
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
22
31
|
/** Where the link goes. */
|
|
@@ -55,6 +64,61 @@ export interface StyledLinkProps
|
|
|
55
64
|
*/
|
|
56
65
|
externalIndicator?: React.ReactNode;
|
|
57
66
|
variant?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Render as a standalone control with a 48x48 tap target, rather than as
|
|
69
|
+
* text inside a sentence.
|
|
70
|
+
*
|
|
71
|
+
* **The default is inline, and that is deliberate rather than a shortcut.**
|
|
72
|
+
* `buttonRecipe`'s base states `min-height: 48px`, `display: inline-flex`
|
|
73
|
+
* and padding — correct for a control, and wrong for a link in a paragraph,
|
|
74
|
+
* where it forces a 48px line box and breaks the text flow. Measured at
|
|
75
|
+
* 48.375px before this prop existed.
|
|
76
|
+
*
|
|
77
|
+
* Inline is also what the standard expects: WCAG 2.5.5 and 2.5.8 both carve
|
|
78
|
+
* out targets that are "in a sentence or block of text", so a text link at
|
|
79
|
+
* text height is conformant. The floor applies to the standalone case, and
|
|
80
|
+
* `standalone` is how a nav item, a card action or a button-shaped link asks
|
|
81
|
+
* for it.
|
|
82
|
+
*
|
|
83
|
+
* @deprecated Use `presentation` instead — `standalone` maps to
|
|
84
|
+
* `presentation="control"`. It is kept because it is public API and consumers
|
|
85
|
+
* pass it today; it will be removed once they have moved.
|
|
86
|
+
*/
|
|
87
|
+
standalone?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* How the link sits in its surroundings. Three cases, because there are
|
|
90
|
+
* genuinely three (NEH-728).
|
|
91
|
+
*
|
|
92
|
+
* | | display | min-height | for |
|
|
93
|
+
* |---|---|---|---|
|
|
94
|
+
* | `text` (default) | `inline` | none | a link inside a sentence |
|
|
95
|
+
* | `flow` | `inline-flex` | none | a link that is a layout participant |
|
|
96
|
+
* | `control` | `inline-flex` | **48px** | a nav item, a card action |
|
|
97
|
+
*
|
|
98
|
+
* ## Why `flow` had to exist
|
|
99
|
+
*
|
|
100
|
+
* `text` and `control` look like they cover the space, and they do not. A
|
|
101
|
+
* link that is neither prose nor a tap target is extremely common — a row in
|
|
102
|
+
* a list, a cell in a grid, anything given a width by its parent — and
|
|
103
|
+
* HopperGuard had 67 of them (NEH-728).
|
|
104
|
+
*
|
|
105
|
+
* Neither of the other two can express it, and **both fail silently**:
|
|
106
|
+
*
|
|
107
|
+
* - `control` adds the 48px floor to links that are not tap targets, which
|
|
108
|
+
* changes layout everywhere it is wrong.
|
|
109
|
+
* - `text` sets `display: inline`, and on a non-replaced inline box **`width`
|
|
110
|
+
* does not apply** and **`margin-left: auto` does nothing** — so a `w` prop
|
|
111
|
+
* becomes a no-op and a right-hand icon loses its push-to-end. No build
|
|
112
|
+
* error, no type error, no warning; the link just renders wrong.
|
|
113
|
+
*
|
|
114
|
+
* `flow` is inline-flex without the floor: it takes a width, it lays its
|
|
115
|
+
* icons out, and it does not claim to be a 48px target when it is not.
|
|
116
|
+
*
|
|
117
|
+
* **Do not reach for `flow` to escape the tap-target floor on something that
|
|
118
|
+
* IS a control.** The floor is a house minimum, not a default to be routed
|
|
119
|
+
* around; `control` is the honest answer for anything a finger aims at.
|
|
120
|
+
*/
|
|
121
|
+
presentation?: LinkPresentation;
|
|
58
122
|
}
|
|
59
123
|
|
|
60
124
|
/** The default external-destination glyph — "↗", north-east arrow. */
|
|
@@ -83,6 +147,8 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
|
|
|
83
147
|
rightIcon,
|
|
84
148
|
externalIndicator,
|
|
85
149
|
variant,
|
|
150
|
+
standalone = false,
|
|
151
|
+
presentation,
|
|
86
152
|
className,
|
|
87
153
|
...rest
|
|
88
154
|
},
|
|
@@ -90,7 +156,38 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
|
|
|
90
156
|
) {
|
|
91
157
|
const HostLink = useLinkComponent();
|
|
92
158
|
const resolved = useResolvedVariant(variant ?? "link", LINK_VARIANTS);
|
|
93
|
-
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
* `presentation` wins; `standalone` is the deprecated spelling of
|
|
162
|
+
* `control`. Resolved in one place so there is no call site where the two
|
|
163
|
+
* disagree and the answer depends on which branch is read first.
|
|
164
|
+
*/
|
|
165
|
+
const mode: LinkPresentation =
|
|
166
|
+
presentation ?? (standalone ? "control" : "text");
|
|
167
|
+
|
|
168
|
+
// The variant still comes from `buttonRecipe`, so colour, underline and
|
|
169
|
+
// hover stay one definition shared with every other control. Only the BOX
|
|
170
|
+
// is overridden — the properties that make a control a control are exactly
|
|
171
|
+
// the ones that break a sentence.
|
|
172
|
+
const classes = cx(
|
|
173
|
+
buttonRecipe({ variant: resolved }),
|
|
174
|
+
mode === "control"
|
|
175
|
+
? undefined
|
|
176
|
+
: css({
|
|
177
|
+
/*
|
|
178
|
+
* `text` goes fully inline so it sits in a line box like any other
|
|
179
|
+
* word. `flow` stays a flex container: it is a layout participant,
|
|
180
|
+
* and on a non-replaced inline box `width` does not apply and
|
|
181
|
+
* `margin-left: auto` does nothing — so an inline `flow` would
|
|
182
|
+
* silently drop both (NEH-728).
|
|
183
|
+
*/
|
|
184
|
+
display: mode === "flow" ? "inline-flex" : "inline",
|
|
185
|
+
minHeight: "0",
|
|
186
|
+
minWidth: "0",
|
|
187
|
+
padding: "0",
|
|
188
|
+
}),
|
|
189
|
+
className,
|
|
190
|
+
);
|
|
94
191
|
|
|
95
192
|
const indicator =
|
|
96
193
|
externalIndicator === undefined ? EXTERNAL_GLYPH : externalIndicator;
|
|
@@ -173,6 +173,28 @@ export const StyledPage = React.forwardRef<HTMLDivElement, StyledPageProps>(
|
|
|
173
173
|
flexDirection: "column",
|
|
174
174
|
flex: "1",
|
|
175
175
|
width: "100%",
|
|
176
|
+
// BOTH `flex: 1` and `height: 100%`, and the second is not
|
|
177
|
+
// redundant (NEH-802).
|
|
178
|
+
//
|
|
179
|
+
// `flex: 1` covers the common case — the page is a flex item in a
|
|
180
|
+
// column, and takes the space the column offers. That is what the
|
|
181
|
+
// app shell does, so it is easy to conclude this is the only case
|
|
182
|
+
// and drop the height. It was dropped, and it broke a production
|
|
183
|
+
// dashboard.
|
|
184
|
+
//
|
|
185
|
+
// `flex: 1` is INERT inside a block-level parent. HopperGuard's
|
|
186
|
+
// `/dashboard` nests one page inside another through plain
|
|
187
|
+
// `display: block` wrappers, and there the page's height came
|
|
188
|
+
// entirely from `height: 100%`. Without it the inner page fell
|
|
189
|
+
// back to CONTENT height — 138px, of which a widget header took
|
|
190
|
+
// ~128, leaving its `1fr` body 10px of padding and the grid zero.
|
|
191
|
+
// The header rendered; every tile was clipped to nothing.
|
|
192
|
+
//
|
|
193
|
+
// The failure is silent in the worst way: nothing errors, nothing
|
|
194
|
+
// is unstyled, and the page looks deliberately empty. Only a
|
|
195
|
+
// computed-height walk up the DOM shows it, which is why no unit
|
|
196
|
+
// test or type-check can stand in for the assertion below.
|
|
197
|
+
height: "100%",
|
|
176
198
|
// See the contract note above: without this the overflow escapes
|
|
177
199
|
// the row rather than scrolling inside it.
|
|
178
200
|
minHeight: "0",
|
|
@@ -2,10 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { css, cx } from "styled-system/css";
|
|
5
|
+
import { tagRecipe } from "styled-system/recipes";
|
|
6
|
+
import type { AlertStatus } from "./StyledAlert";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A tag's tone.
|
|
10
|
+
*
|
|
11
|
+
* The four status names ARE `AlertStatus`, referenced rather than retyped, so
|
|
12
|
+
* the package cannot drift into two status vocabularies — a green that means
|
|
13
|
+
* "success" on a banner and "active" on a tag is the kind of divergence nobody
|
|
14
|
+
* notices until a product has both.
|
|
15
|
+
*
|
|
16
|
+
* `neutral` and `accent` extend it. `neutral` is the historical appearance and
|
|
17
|
+
* stays the default; `accent` exists because a tag is frequently just a
|
|
18
|
+
* *category* — a type, a group, a label — and forcing those into `info` would
|
|
19
|
+
* make "informational" mean nothing.
|
|
20
|
+
*/
|
|
21
|
+
export type TagTone = "neutral" | AlertStatus | "accent";
|
|
5
22
|
|
|
6
23
|
export interface StyledTagProps
|
|
7
24
|
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect"> {
|
|
8
25
|
children: React.ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* The tag's colour, carrying meaning.
|
|
28
|
+
*
|
|
29
|
+
* ## Colour must not be the only signal (WCAG 1.4.1, Level A)
|
|
30
|
+
*
|
|
31
|
+
* Usually it is not, and that is why there is no forced glyph here: a tag
|
|
32
|
+
* generally *is* its label, so `<StyledTag tone="success">Enabled</StyledTag>`
|
|
33
|
+
* says "enabled" in words and the colour merely reinforces it. `StyledAlert`
|
|
34
|
+
* needs a glyph because a banner's status is genuinely carried by its
|
|
35
|
+
* colouring; a tag's is carried by its text.
|
|
36
|
+
*
|
|
37
|
+
* **The exception is a tag whose label does not name its own state** — a
|
|
38
|
+
* feature name tinted green for on and grey for off, say. There the colour is
|
|
39
|
+
* the only signal and the criterion is unmet, so pass `indicator`.
|
|
40
|
+
*/
|
|
41
|
+
tone?: TagTone;
|
|
42
|
+
/**
|
|
43
|
+
* A non-colour signal rendered before the label.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately not defaulted per tone. See `tone` above: defaulting one would
|
|
46
|
+
* put a glyph on every tag in every consumer to fix the minority of cases
|
|
47
|
+
* where the label does not already say what the colour says.
|
|
48
|
+
*/
|
|
49
|
+
indicator?: React.ReactNode;
|
|
9
50
|
/**
|
|
10
51
|
* Show a remove control, and call this when it is activated.
|
|
11
52
|
*
|
|
@@ -49,33 +90,29 @@ export interface StyledTagProps
|
|
|
49
90
|
*/
|
|
50
91
|
export const StyledTag = React.forwardRef<HTMLSpanElement, StyledTagProps>(
|
|
51
92
|
function StyledTag(
|
|
52
|
-
{
|
|
93
|
+
{
|
|
94
|
+
children,
|
|
95
|
+
tone = "neutral",
|
|
96
|
+
indicator,
|
|
97
|
+
onRemove,
|
|
98
|
+
removeLabel = "Remove",
|
|
99
|
+
className,
|
|
100
|
+
...rest
|
|
101
|
+
},
|
|
53
102
|
ref,
|
|
54
103
|
) {
|
|
55
104
|
return (
|
|
56
105
|
<span
|
|
57
106
|
ref={ref}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Vertical padding is deliberately absent: the height comes from
|
|
65
|
-
// the line box and the horizontal padding, so a tag tracks the
|
|
66
|
-
// font scale instead of needing a re-tune whenever it moves.
|
|
67
|
-
borderRadius: "md",
|
|
68
|
-
backgroundColor: "boxBgSecondary",
|
|
69
|
-
color: "textSecondary",
|
|
70
|
-
// Not a tap target: a plain tag is not interactive, so the 48px
|
|
71
|
-
// floor does not apply to it. The remove BUTTON below is, and does.
|
|
72
|
-
fontSize: "sm",
|
|
73
|
-
whiteSpace: "nowrap",
|
|
74
|
-
}),
|
|
75
|
-
className,
|
|
76
|
-
)}
|
|
107
|
+
/*
|
|
108
|
+
* The recipe, not an inline `css()` — see `preset/recipes/tag.ts`. A
|
|
109
|
+
* tone computed at runtime (`tone={STATUS_COLOR[status]}`) is invisible
|
|
110
|
+
* to Panda's extractor, and `staticCssRecipes` is what covers it.
|
|
111
|
+
*/
|
|
112
|
+
className={cx(tagRecipe({ tone }), className)}
|
|
77
113
|
{...rest}
|
|
78
114
|
>
|
|
115
|
+
{indicator !== undefined && <span aria-hidden="true">{indicator}</span>}
|
|
79
116
|
<span>{children}</span>
|
|
80
117
|
{onRemove !== undefined && (
|
|
81
118
|
<button
|
|
@@ -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";
|
|
@@ -133,6 +133,42 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
133
133
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
134
134
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
135
135
|
const helpRef = useRef<HTMLButtonElement>(null);
|
|
136
|
+
/**
|
|
137
|
+
* Schedule the open.
|
|
138
|
+
*
|
|
139
|
+
* **Clearing first is the whole of NEH-818.** `show()` runs from four
|
|
140
|
+
* places — the trigger's mouseenter and focus, and the tooltip's own
|
|
141
|
+
* mouseenter — and more than one of them fires for a single gesture: a press
|
|
142
|
+
* both hovers and focuses the trigger, ~0ms apart. Assigning over
|
|
143
|
+
* `timeoutRef.current` left the earlier timer running with nothing holding
|
|
144
|
+
* its id, so `hide()` could cancel only the last one scheduled.
|
|
145
|
+
*
|
|
146
|
+
* The orphan then fired into a page the reader had already left, opening a
|
|
147
|
+
* tooltip that no departure event could ever close — measured as a live,
|
|
148
|
+
* opaque, click-eating overlay sitting over the dialog the press had just
|
|
149
|
+
* opened, gone only on reload.
|
|
150
|
+
*
|
|
151
|
+
* One timer at a time; the id is nulled when it fires so `hide()` never
|
|
152
|
+
* clears a stale one.
|
|
153
|
+
*
|
|
154
|
+
* Hoisted above the effects (and memoised) rather than declared beside the
|
|
155
|
+
* JSX: the ancestor-focus effect added for NEH-950 has to bind these as
|
|
156
|
+
* listeners, and a second copy of the timer discipline above is exactly how
|
|
157
|
+
* NEH-818 would come back.
|
|
158
|
+
*/
|
|
159
|
+
const show = useCallback(() => {
|
|
160
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
161
|
+
timeoutRef.current = setTimeout(() => {
|
|
162
|
+
timeoutRef.current = null;
|
|
163
|
+
setVisible(true);
|
|
164
|
+
}, delay);
|
|
165
|
+
}, [delay]);
|
|
166
|
+
const hide = useCallback(() => {
|
|
167
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
168
|
+
timeoutRef.current = null;
|
|
169
|
+
setVisible(false);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
136
172
|
/**
|
|
137
173
|
* A hover trigger on a device that cannot hover is not a worse experience —
|
|
138
174
|
* it is an unreachable one. There is no hover event, and tapping the control
|
|
@@ -154,6 +190,38 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
154
190
|
const [focusableChild, setFocusableChild] = useState<HTMLElement | null>(null);
|
|
155
191
|
const [hasFocusableChild, setHasFocusableChild] = useState(true);
|
|
156
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The focusable element this trigger sits *inside*, if any (NEH-950).
|
|
195
|
+
*
|
|
196
|
+
* `hasFocusableChild` looks down and cannot see upwards, so it answers "no
|
|
197
|
+
* focusable child" for an icon that is decorative content inside a control
|
|
198
|
+
* that is already focusable and already named — and the trigger then took a
|
|
199
|
+
* `tabIndex` of its own. The result was the exact failure the conditional
|
|
200
|
+
* above exists to prevent, one level in: a second tab stop inside a button
|
|
201
|
+
* the reader has already passed, carrying no role and no name because
|
|
202
|
+
* `needsFallbackName` correctly declines to name it (the ancestor already
|
|
203
|
+
* has). Every icon in `stonedog-icons` that carries its own tooltip
|
|
204
|
+
* reproduced it, in every consumer.
|
|
205
|
+
*
|
|
206
|
+
* Deleting the `tabIndex` alone would have been a different WCAG failure
|
|
207
|
+
* rather than a fix — the tooltip must stay reachable by keyboard (2.1.1).
|
|
208
|
+
* So the ancestor becomes the trigger instead: it already owns the tab stop,
|
|
209
|
+
* and the effect below opens the tooltip when it takes focus, exactly as a
|
|
210
|
+
* focusable *child* already does by bubbling.
|
|
211
|
+
*
|
|
212
|
+
* Starts null, and the layout effect below can only ever find an ancestor
|
|
213
|
+
* when there is no focusable child — the two are mutually exclusive by
|
|
214
|
+
* construction, so nothing has to decide between them.
|
|
215
|
+
*/
|
|
216
|
+
const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* True when something else — a descendant or an ancestor — already puts this
|
|
220
|
+
* trigger's content in the tab sequence. When it does, the trigger must add
|
|
221
|
+
* no stop of its own, and must not invent a role or a name for one.
|
|
222
|
+
*/
|
|
223
|
+
const insideFocusable = hasFocusableChild || focusableAncestor !== null;
|
|
224
|
+
|
|
157
225
|
// When the trigger KEEPS its tab stop it must have a role and a name (WCAG
|
|
158
226
|
// 2.2 4.1.2) — but only if nothing else already provides one. Borrowing the
|
|
159
227
|
// tooltip text unconditionally is what broke SharedWithIndicator, which names
|
|
@@ -172,6 +240,16 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
172
240
|
setFocusableChild((prev) => (prev === found ? prev : found));
|
|
173
241
|
setHasFocusableChild(found !== null);
|
|
174
242
|
|
|
243
|
+
// `parentElement.closest`, not `node.closest`: the trigger itself may be
|
|
244
|
+
// carrying the very `tabindex` this is deciding whether to keep, and
|
|
245
|
+
// matching ourselves would make the answer depend on the previous render.
|
|
246
|
+
// Only asked when there is no focusable child, because a child already
|
|
247
|
+
// settles the question and is the nearer trigger of the two.
|
|
248
|
+
const ancestor = found
|
|
249
|
+
? null
|
|
250
|
+
: node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
|
|
251
|
+
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
252
|
+
|
|
175
253
|
if (!node) return;
|
|
176
254
|
// parentElement, not the node itself: closest() would match our own
|
|
177
255
|
// aria-label once we set one, and the answer would flip every render.
|
|
@@ -194,7 +272,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
194
272
|
// component forwarding the prop, and a child that quietly drops it would fail
|
|
195
273
|
// invisibly.
|
|
196
274
|
useLayoutEffect(() => {
|
|
197
|
-
const node = focusableChild;
|
|
275
|
+
const node = focusableChild ?? focusableAncestor;
|
|
198
276
|
if (!node || !visible) return;
|
|
199
277
|
const previous = node.getAttribute("aria-describedby");
|
|
200
278
|
node.setAttribute(
|
|
@@ -205,7 +283,31 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
205
283
|
if (previous === null) node.removeAttribute("aria-describedby");
|
|
206
284
|
else node.setAttribute("aria-describedby", previous);
|
|
207
285
|
};
|
|
208
|
-
}, [focusableChild, visible, tooltipId]);
|
|
286
|
+
}, [focusableChild, focusableAncestor, visible, tooltipId]);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Open on the ANCESTOR's focus, when the trigger is inside one (NEH-950).
|
|
290
|
+
*
|
|
291
|
+
* A focusable *child* needs nothing here: `focusin`/`focusout` bubble, so the
|
|
292
|
+
* wrapper's own `onFocus`/`onBlur` already fire for it. An ancestor is the
|
|
293
|
+
* other direction, where nothing bubbles, so the listeners go on the ancestor
|
|
294
|
+
* itself.
|
|
295
|
+
*
|
|
296
|
+
* Without this the fix would trade WCAG 2.2 4.1.2 (a focusable element with
|
|
297
|
+
* no role and no name) for 2.1.1 — the explanation would be rendered and
|
|
298
|
+
* reachable by pointer only. Hover mode only: click mode never took a tab
|
|
299
|
+
* stop, so it has nothing to give back.
|
|
300
|
+
*/
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const node = focusableAncestor;
|
|
303
|
+
if (isClick || !node) return;
|
|
304
|
+
node.addEventListener("focusin", show);
|
|
305
|
+
node.addEventListener("focusout", hide);
|
|
306
|
+
return () => {
|
|
307
|
+
node.removeEventListener("focusin", show);
|
|
308
|
+
node.removeEventListener("focusout", hide);
|
|
309
|
+
};
|
|
310
|
+
}, [focusableAncestor, isClick, show, hide]);
|
|
209
311
|
|
|
210
312
|
useLayoutEffect(() => {
|
|
211
313
|
if (visible && triggerRef.current && tooltipRef.current) {
|
|
@@ -269,9 +371,19 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
269
371
|
}
|
|
270
372
|
}, [visible, placement]);
|
|
271
373
|
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
374
|
+
// A pending open timer must not outlive the component. Nothing else clears
|
|
375
|
+
// it on unmount, so a trigger removed inside the delay window fired
|
|
376
|
+
// setVisible on a component React had already torn down.
|
|
377
|
+
useEffect(
|
|
378
|
+
() => () => {
|
|
379
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
380
|
+
timeoutRef.current = null;
|
|
381
|
+
},
|
|
382
|
+
[],
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Click mode's dismissal. A panel opened by a deliberate press has to be
|
|
386
|
+
// closable by a deliberate action — a press outside it, or Escape — or a
|
|
275
387
|
// keyboard user is stuck with it open.
|
|
276
388
|
useEffect(() => {
|
|
277
389
|
if (!isClick || !visible || typeof document === "undefined") return;
|
|
@@ -280,6 +392,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
280
392
|
if (event.key !== "Escape") return;
|
|
281
393
|
setVisible(false);
|
|
282
394
|
// Focus goes back to what opened it — never to the top of the document.
|
|
395
|
+
// Hover mode has no equivalent, because nothing was focused to open it.
|
|
283
396
|
helpRef.current?.focus();
|
|
284
397
|
};
|
|
285
398
|
const onPointerDown = (event: MouseEvent) => {
|
|
@@ -298,6 +411,69 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
298
411
|
};
|
|
299
412
|
}, [isClick, visible]);
|
|
300
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Hover mode's dismissal, which used to be nothing at all (NEH-818).
|
|
416
|
+
*
|
|
417
|
+
* `hide()` is reachable only from the trigger's own `onMouseLeave` /
|
|
418
|
+
* `onBlur`, so an open tooltip whose trigger never receives another
|
|
419
|
+
* departure event stays on the page for the life of the document — opaque,
|
|
420
|
+
* taking pointer events, over whatever opened on top of it. That is not a
|
|
421
|
+
* hypothetical ordering: a press both focuses the trigger and covers it, so
|
|
422
|
+
* blur cannot fire (focus stays put) and mouseleave has already been and
|
|
423
|
+
* gone.
|
|
424
|
+
*
|
|
425
|
+
* These two listeners are on `document`, so neither depends on the trigger
|
|
426
|
+
* being reachable — which is the property the trigger's own handlers lack.
|
|
427
|
+
*
|
|
428
|
+
* - **`pointermove`** closes it once the pointer is over neither the trigger
|
|
429
|
+
* nor the tooltip. It deliberately does not fire on the tooltip itself:
|
|
430
|
+
* WCAG 2.2 1.4.13 *Hoverable* requires the reader be able to move onto the
|
|
431
|
+
* revealed text without it vanishing, which is also why the portal keeps
|
|
432
|
+
* `pointer-events: auto`.
|
|
433
|
+
* - **Escape** satisfies 1.4.13 *Dismissible*, which hover mode did not meet
|
|
434
|
+
* before: content revealed on hover or focus must be dismissable without
|
|
435
|
+
* moving the pointer or focus, and a reader whose pointer is parked had no
|
|
436
|
+
* way to clear it.
|
|
437
|
+
*
|
|
438
|
+
* Bound only while a hover tooltip is actually open, so the common case
|
|
439
|
+
* costs nothing.
|
|
440
|
+
*/
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
if (isClick || !visible || typeof document === "undefined") return;
|
|
443
|
+
|
|
444
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
445
|
+
if (event.key !== "Escape") return;
|
|
446
|
+
// No focus move: in hover mode nothing was focused to open this, and
|
|
447
|
+
// stealing focus on Escape would be its own bug.
|
|
448
|
+
setVisible(false);
|
|
449
|
+
};
|
|
450
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
451
|
+
const target = event.target as Node | null;
|
|
452
|
+
if (!target) return;
|
|
453
|
+
if (triggerRef.current?.contains(target) || tooltipRef.current?.contains(target)) return;
|
|
454
|
+
// A tooltip revealed by FOCUS belongs to the focus, not to the pointer.
|
|
455
|
+
// WCAG 2.2 1.4.13 Persistent requires it to stay until its trigger is
|
|
456
|
+
// released, so taking it away because an unrelated mouse moved would
|
|
457
|
+
// trade one conformance failure for another — and would do it to a
|
|
458
|
+
// keyboard reader who never touched the mouse. Escape above is their
|
|
459
|
+
// dismissal.
|
|
460
|
+
if (
|
|
461
|
+
document.activeElement &&
|
|
462
|
+
triggerRef.current?.contains(document.activeElement)
|
|
463
|
+
) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
setVisible(false);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
document.addEventListener("keydown", onKeyDown);
|
|
470
|
+
document.addEventListener("pointermove", onPointerMove);
|
|
471
|
+
return () => {
|
|
472
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
473
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
474
|
+
};
|
|
475
|
+
}, [isClick, visible]);
|
|
476
|
+
|
|
301
477
|
if (!tooltip) {
|
|
302
478
|
return <>{children}</>;
|
|
303
479
|
}
|
|
@@ -306,13 +482,6 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
306
482
|
// would produce "[object Object]" in the accessibility tree.
|
|
307
483
|
const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
|
|
308
484
|
|
|
309
|
-
const show = () => {
|
|
310
|
-
timeoutRef.current = setTimeout(() => setVisible(true), delay);
|
|
311
|
-
};
|
|
312
|
-
const hide = () => {
|
|
313
|
-
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
314
|
-
setVisible(false);
|
|
315
|
-
};
|
|
316
485
|
|
|
317
486
|
return (
|
|
318
487
|
<>
|
|
@@ -323,7 +492,13 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
323
492
|
// tooltip still fires without the wrapper taking focus itself. Adding
|
|
324
493
|
// tabIndex here regardless is what gave every tooltipped control two
|
|
325
494
|
// tab stops, the second of them silent (NEH-127).
|
|
326
|
-
|
|
495
|
+
//
|
|
496
|
+
// `insideFocusable`, not `hasFocusableChild`: an ANCESTOR owns the tab
|
|
497
|
+
// stop just as effectively as a descendant, and looking only downwards
|
|
498
|
+
// put the same silent second stop inside every icon button in the
|
|
499
|
+
// fleet (NEH-950). The ancestor-focus effect above is what keeps the
|
|
500
|
+
// tooltip reachable once the trigger stops taking focus itself.
|
|
501
|
+
tabIndex={isClick || insideFocusable ? undefined : 0}
|
|
327
502
|
// A focusable element needs a role and a name (WCAG 2.2 4.1.2). Applied
|
|
328
503
|
// only when the trigger keeps the tab stop AND nothing else names it —
|
|
329
504
|
// see needsFallbackName above for why the condition matters (NEH-151).
|
|
@@ -332,9 +507,9 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
332
507
|
// reveals content on focus, which is the closest standard role and what
|
|
333
508
|
// the ARIA tooltip pattern assumes of a trigger. A focusable generic
|
|
334
509
|
// with only a name still fails 4.1.2, which asks for both.
|
|
335
|
-
role={!isClick && !
|
|
510
|
+
role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
|
|
336
511
|
aria-label={
|
|
337
|
-
isClick ||
|
|
512
|
+
isClick || insideFocusable
|
|
338
513
|
? undefined
|
|
339
514
|
: ariaLabel ?? (needsFallbackName ? tooltipLabel : undefined)
|
|
340
515
|
}
|
|
@@ -345,7 +520,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
345
520
|
onFocus={isClick ? undefined : show}
|
|
346
521
|
onBlur={isClick ? undefined : hide}
|
|
347
522
|
aria-describedby={
|
|
348
|
-
!isClick && !
|
|
523
|
+
!isClick && !insideFocusable && visible ? tooltipId : undefined
|
|
349
524
|
}
|
|
350
525
|
{...rest}
|
|
351
526
|
>
|
package/src/index.ts
CHANGED
|
@@ -204,10 +204,10 @@ export type { StyledFormLabelProps } from "./components/StyledFormLabel";
|
|
|
204
204
|
// that stops it working.
|
|
205
205
|
// ---------------------------------------------------------------------------
|
|
206
206
|
export { default as StyledLink, StyledLink as Link } from "./components/StyledLink";
|
|
207
|
-
export type { StyledLinkProps } from "./components/StyledLink";
|
|
207
|
+
export type { StyledLinkProps, LinkPresentation } from "./components/StyledLink";
|
|
208
208
|
|
|
209
209
|
export { default as StyledTag, StyledTag as Tag } from "./components/StyledTag";
|
|
210
|
-
export type { StyledTagProps } from "./components/StyledTag";
|
|
210
|
+
export type { StyledTagProps, TagTone } from "./components/StyledTag";
|
|
211
211
|
|
|
212
212
|
export {
|
|
213
213
|
default as StyledFieldErrors,
|