@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 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 (22 recipes, 43 colour tokens); the component set
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.13.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 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.",
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 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.",
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 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
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 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.",
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
- const classes = cx(buttonRecipe({ variant: resolved }), className);
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
- { children, onRemove, removeLabel = "Remove", className, ...rest },
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
- className={cx(
59
- css({
60
- display: "inline-flex",
61
- alignItems: "center",
62
- gap: "1",
63
- paddingInline: "2",
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
- // Click mode owns its own dismissal. Hover mode needs none of this: it
273
- // closes when the pointer leaves. A panel opened by a deliberate press has
274
- // to be closable by a deliberate action, and Escape has to work, or a
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
- tabIndex={isClick || hasFocusableChild ? undefined : 0}
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 && !hasFocusableChild && needsFallbackName ? "button" : undefined}
510
+ role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
336
511
  aria-label={
337
- isClick || hasFocusableChild
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 && !hasFocusableChild && visible ? tooltipId : undefined
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,