@stonedogcode/style 0.16.0 → 0.19.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/StyledCollapsible.tsx +14 -27
- package/src/components/StyledFieldHelp.tsx +248 -0
- package/src/components/StyledToaster.tsx +325 -0
- package/src/components/StyledTooltip.tsx +117 -23
- package/src/components/toaster-store.ts +334 -0
- package/src/components/useDisclosure.ts +139 -0
- package/src/config/font-size.ts +25 -0
- package/src/index.ts +37 -0
- package/src/preset/index.ts +5 -2
- package/src/preset/recipes/toast.ts +161 -0
- package/src/preset/semantic-variables.ts +22 -5
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { toastRecipe } from "styled-system/recipes";
|
|
6
|
+
import { cx } from "styled-system/css";
|
|
7
|
+
|
|
8
|
+
import StyledButton from "./StyledButton";
|
|
9
|
+
import StyledSpinner from "./StyledSpinner";
|
|
10
|
+
import StyledText from "./StyledText";
|
|
11
|
+
import type { Toast, ToasterStore, ToastType } from "./toaster-store";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Draws whatever is in a toaster store.
|
|
15
|
+
*
|
|
16
|
+
* Mount **one** of these, once, near the root of the application, and hand it
|
|
17
|
+
* the same store your `create()` calls go to. It renders nothing until there is
|
|
18
|
+
* something to show.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* export const toaster = createToaster();
|
|
22
|
+
* // …somewhere near the root:
|
|
23
|
+
* <StyledToaster toaster={toaster} />
|
|
24
|
+
* // …anywhere at all:
|
|
25
|
+
* toaster.create({ title: "Saved.", type: "success" });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## The three things that make this SSR-safe
|
|
29
|
+
*
|
|
30
|
+
* All three were failure modes before they were requirements, and none of them
|
|
31
|
+
* shows up in a client-only test:
|
|
32
|
+
*
|
|
33
|
+
* 1. **`getServerSnapshot`** — `useSyncExternalStore` throws during hydration
|
|
34
|
+
* without one. The store supplies a frozen empty array, the same reference
|
|
35
|
+
* every time, so React sees no change between the server render and the
|
|
36
|
+
* first client one.
|
|
37
|
+
* 2. **The portal waits for mount.** `createPortal(…, document.body)` is a
|
|
38
|
+
* `document is not defined` crash on the server. `mounted` below is false
|
|
39
|
+
* for the server render *and* for the first client render, which is what
|
|
40
|
+
* keeps the two identical — checking `typeof document` instead would make
|
|
41
|
+
* them differ and produce a hydration mismatch rather than a crash.
|
|
42
|
+
* 3. **The store refuses to queue on the server**, so a toast created during a
|
|
43
|
+
* render cannot leak into the next request. That one lives in the store; see
|
|
44
|
+
* its header.
|
|
45
|
+
*
|
|
46
|
+
* ## Timers start here, not in the store
|
|
47
|
+
*
|
|
48
|
+
* A toast created before this component mounts — during a redirect, a slow
|
|
49
|
+
* hydration, an early event handler — must still be seen. Because each toast's
|
|
50
|
+
* countdown is an effect *in the toast's own element*, it cannot start before
|
|
51
|
+
* that element exists, so an early toast waits rather than expiring unseen.
|
|
52
|
+
*/
|
|
53
|
+
export interface StyledToasterProps {
|
|
54
|
+
/** The store to draw. Create it with `createToaster()`. */
|
|
55
|
+
toaster: ToasterStore;
|
|
56
|
+
/**
|
|
57
|
+
* The glyph for each kind of toast.
|
|
58
|
+
*
|
|
59
|
+
* **This package ships no icon artwork**, deliberately — see CLAUDE.md. The
|
|
60
|
+
* defaults are text characters, which work everywhere and are nobody's
|
|
61
|
+
* favourite. Pass your own icon set here to replace them; pass `null` for a
|
|
62
|
+
* type to render no glyph at all.
|
|
63
|
+
*
|
|
64
|
+
* Whatever you pass is `aria-hidden`: the toast's role already tells a screen
|
|
65
|
+
* reader what kind of message it is, and reading "check mark" before the text
|
|
66
|
+
* is the same information twice.
|
|
67
|
+
*/
|
|
68
|
+
icons?: Partial<Record<ToastType, React.ReactNode>> | undefined;
|
|
69
|
+
/** The glyph inside the close control. Text by default, for the same reason. */
|
|
70
|
+
closeIcon?: React.ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* The close control's accessible name. It is a button whose only content is a
|
|
73
|
+
* glyph, so without a name it announces as "button" and nothing else.
|
|
74
|
+
*/
|
|
75
|
+
closeLabel?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Names the region for a screen reader listing landmarks.
|
|
78
|
+
*
|
|
79
|
+
* Not the toasts themselves — those announce individually as they arrive.
|
|
80
|
+
*/
|
|
81
|
+
regionLabel?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Text stand-ins for the artwork this package will not ship.
|
|
86
|
+
*
|
|
87
|
+
* `default` has none on purpose: it is the type for a message with no status,
|
|
88
|
+
* and inventing a glyph for "no particular kind" would say something the
|
|
89
|
+
* message does not.
|
|
90
|
+
*/
|
|
91
|
+
const DEFAULT_ICONS: Partial<Record<ToastType, React.ReactNode>> = {
|
|
92
|
+
success: "✓",
|
|
93
|
+
error: "✕",
|
|
94
|
+
warning: "!",
|
|
95
|
+
info: "i",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One toast, and the only place a dismissal timer exists.
|
|
100
|
+
*
|
|
101
|
+
* The timer is an effect keyed on `paused`, which gives pause-and-resume for
|
|
102
|
+
* free: pausing tears the effect down, and the cleanup subtracts the elapsed
|
|
103
|
+
* time from what is left, so resuming schedules the remainder rather than
|
|
104
|
+
* restarting the whole duration. Unmounting runs the same cleanup, so a toast
|
|
105
|
+
* removed mid-countdown cannot fire a state update into a component that is no
|
|
106
|
+
* longer there.
|
|
107
|
+
*/
|
|
108
|
+
function ToastItem({
|
|
109
|
+
toast,
|
|
110
|
+
paused,
|
|
111
|
+
onDismiss,
|
|
112
|
+
icons,
|
|
113
|
+
closeIcon,
|
|
114
|
+
closeLabel,
|
|
115
|
+
}: {
|
|
116
|
+
toast: Toast;
|
|
117
|
+
paused: boolean;
|
|
118
|
+
onDismiss: (id: string) => void;
|
|
119
|
+
icons: Partial<Record<ToastType, React.ReactNode>>;
|
|
120
|
+
closeIcon: React.ReactNode;
|
|
121
|
+
closeLabel: string;
|
|
122
|
+
}) {
|
|
123
|
+
/**
|
|
124
|
+
* Resolved PER TOAST, with this toast's type.
|
|
125
|
+
*
|
|
126
|
+
* The first version called `toastRecipe()` once for the whole region and
|
|
127
|
+
* shared the result, so every card came out as `toast__root--type_default`
|
|
128
|
+
* and no status accent was ever painted — the recipe was correct, its
|
|
129
|
+
* stylesheet was correct, and nothing rendered it. Neither the unit tier
|
|
130
|
+
* (which asserts roles and text) nor the token-contract test (which reads the
|
|
131
|
+
* stylesheet) could see it; the component test comparing three computed
|
|
132
|
+
* accent colours found all three identical.
|
|
133
|
+
*/
|
|
134
|
+
const classes = toastRecipe({ type: toast.type });
|
|
135
|
+
|
|
136
|
+
const remaining = useRef(toast.duration);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
// `loading` and anything given `Infinity` stay until something dismisses
|
|
140
|
+
// them. Scheduling a timeout for Infinity is not merely pointless — the
|
|
141
|
+
// value overflows a 32-bit delay and fires immediately, which would make
|
|
142
|
+
// "stays until dismissed" mean "vanishes at once".
|
|
143
|
+
if (paused || toast.dismissed || !Number.isFinite(remaining.current)) return;
|
|
144
|
+
|
|
145
|
+
const startedAt = Date.now();
|
|
146
|
+
const timer = setTimeout(() => onDismiss(toast.id), remaining.current);
|
|
147
|
+
|
|
148
|
+
return () => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
remaining.current -= Date.now() - startedAt;
|
|
151
|
+
};
|
|
152
|
+
}, [paused, toast.dismissed, toast.id, onDismiss]);
|
|
153
|
+
|
|
154
|
+
const glyph = icons[toast.type];
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
// `status` rather than `alert`: polite, so it waits for a gap in whatever
|
|
159
|
+
// the reader is already saying instead of cutting across it. An
|
|
160
|
+
// interruption is right for a fire alarm and wrong for "Saved."
|
|
161
|
+
//
|
|
162
|
+
// `aria-atomic` makes the whole toast read as one message. Without it a
|
|
163
|
+
// reader announces only the part of the subtree that changed, which for a
|
|
164
|
+
// toast updated in place is a fragment with no context.
|
|
165
|
+
role="status"
|
|
166
|
+
aria-atomic="true"
|
|
167
|
+
data-state={toast.dismissed ? "closed" : "open"}
|
|
168
|
+
data-type={toast.type}
|
|
169
|
+
className={classes.root}
|
|
170
|
+
>
|
|
171
|
+
{/*
|
|
172
|
+
`aria-hidden` on the whole indicator, spinner included.
|
|
173
|
+
|
|
174
|
+
The glyph is hidden because the toast's role has already told the reader
|
|
175
|
+
what kind of message this is, and "check mark, Saved." is the same thing
|
|
176
|
+
twice. The SPINNER is hidden for a sharper reason: `StyledSpinner`
|
|
177
|
+
carries its own `role="status"`, so rendering it bare nests one live
|
|
178
|
+
region inside another — the message is announced twice, and the outer
|
|
179
|
+
`aria-atomic` no longer describes one coherent thing. What tells a
|
|
180
|
+
reader the work is still going is the toast's own text ("Uploading…"),
|
|
181
|
+
which is the part worth reading anyway.
|
|
182
|
+
*/}
|
|
183
|
+
{(toast.type === "loading" || glyph != null) && (
|
|
184
|
+
<div className={classes.indicator} aria-hidden="true">
|
|
185
|
+
{toast.type === "loading" ? <StyledSpinner loadText="" /> : glyph}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<div className={classes.content}>
|
|
190
|
+
{toast.title != null && (
|
|
191
|
+
<StyledText className={classes.title}>{toast.title}</StyledText>
|
|
192
|
+
)}
|
|
193
|
+
{toast.description != null && (
|
|
194
|
+
<StyledText className={classes.description}>
|
|
195
|
+
{toast.description}
|
|
196
|
+
</StyledText>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{toast.action && (
|
|
201
|
+
<div className={classes.action}>
|
|
202
|
+
<StyledButton
|
|
203
|
+
onClick={() => {
|
|
204
|
+
toast.action?.onClick();
|
|
205
|
+
// A toast whose button has been pressed has done its job. Leaving
|
|
206
|
+
// it up invites a second press on an action that has already run.
|
|
207
|
+
onDismiss(toast.id);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{toast.action.label}
|
|
211
|
+
</StyledButton>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{toast.closable && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
aria-label={closeLabel}
|
|
219
|
+
className={classes.close}
|
|
220
|
+
onClick={() => onDismiss(toast.id)}
|
|
221
|
+
>
|
|
222
|
+
<span aria-hidden="true">{closeIcon}</span>
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const StyledToaster: React.FC<StyledToasterProps> = ({
|
|
230
|
+
toaster,
|
|
231
|
+
icons,
|
|
232
|
+
closeIcon = "✕",
|
|
233
|
+
closeLabel = "Dismiss notification",
|
|
234
|
+
regionLabel = "Notifications",
|
|
235
|
+
}) => {
|
|
236
|
+
const toasts = useSyncExternalStore(
|
|
237
|
+
toaster.subscribe,
|
|
238
|
+
toaster.getSnapshot,
|
|
239
|
+
toaster.getServerSnapshot,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const [mounted, setMounted] = useState(false);
|
|
243
|
+
useEffect(() => setMounted(true), []);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Pointer or keyboard inside the region, and the tab being hidden, all stop
|
|
247
|
+
* the clock. They are one boolean rather than three because the resume
|
|
248
|
+
* condition is "none of them", and three independent flags is how a toast
|
|
249
|
+
* ends up pinned forever by a hover the pointer left through a portal.
|
|
250
|
+
*/
|
|
251
|
+
const [hovered, setHovered] = useState(false);
|
|
252
|
+
const [focused, setFocused] = useState(false);
|
|
253
|
+
const [pageHidden, setPageHidden] = useState(false);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (typeof document === "undefined") return;
|
|
257
|
+
// A countdown that runs in a background tab is a message the user never had
|
|
258
|
+
// the chance to read. Chakra's store called this `pauseOnPageIdle` and had
|
|
259
|
+
// it on; this keeps that, without the option, because no consumer wanted
|
|
260
|
+
// the other behaviour.
|
|
261
|
+
const sync = () => setPageHidden(document.hidden);
|
|
262
|
+
sync();
|
|
263
|
+
document.addEventListener("visibilitychange", sync);
|
|
264
|
+
return () => document.removeEventListener("visibilitychange", sync);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
const paused = hovered || focused || pageHidden;
|
|
268
|
+
|
|
269
|
+
// Stable identity so it is not a fresh dependency on every render of every
|
|
270
|
+
// toast — each toast's timer effect lists it.
|
|
271
|
+
const onDismiss = React.useCallback(
|
|
272
|
+
(id: string) => toaster.remove(id),
|
|
273
|
+
[toaster],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Only the region slot is read here; every card resolves its own, above.
|
|
277
|
+
const classes = toastRecipe();
|
|
278
|
+
const resolvedIcons = icons ?? DEFAULT_ICONS;
|
|
279
|
+
|
|
280
|
+
if (!mounted) return null;
|
|
281
|
+
|
|
282
|
+
return createPortal(
|
|
283
|
+
<div
|
|
284
|
+
className={cx(classes.region)}
|
|
285
|
+
// The region is present from mount and stays, whether or not it holds
|
|
286
|
+
// anything. A live region created at the same moment as its content is
|
|
287
|
+
// announced inconsistently across screen readers; one that was already
|
|
288
|
+
// there is not.
|
|
289
|
+
aria-label={regionLabel}
|
|
290
|
+
onMouseEnter={() => setHovered(true)}
|
|
291
|
+
onMouseLeave={() => setHovered(false)}
|
|
292
|
+
onFocus={() => setFocused(true)}
|
|
293
|
+
onBlur={(event) => {
|
|
294
|
+
// Only when focus has actually left the region — moving between the
|
|
295
|
+
// action and the close button inside one toast fires blur too, and
|
|
296
|
+
// treating that as "focus left" would restart the countdown under the
|
|
297
|
+
// keyboard user's hands.
|
|
298
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
299
|
+
setFocused(false);
|
|
300
|
+
}
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{/*
|
|
304
|
+
Rendered oldest-last so the newest toast sits nearest the corner, which
|
|
305
|
+
is where the eye already is. The store keeps them newest-first because
|
|
306
|
+
that is the order its priority rules work in; the reversal is a
|
|
307
|
+
presentation decision and belongs here.
|
|
308
|
+
*/}
|
|
309
|
+
{[...toasts].reverse().map((toast) => (
|
|
310
|
+
<ToastItem
|
|
311
|
+
key={toast.id}
|
|
312
|
+
toast={toast}
|
|
313
|
+
paused={paused}
|
|
314
|
+
onDismiss={onDismiss}
|
|
315
|
+
icons={resolvedIcons}
|
|
316
|
+
closeIcon={closeIcon}
|
|
317
|
+
closeLabel={closeLabel}
|
|
318
|
+
/>
|
|
319
|
+
))}
|
|
320
|
+
</div>,
|
|
321
|
+
document.body,
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export default StyledToaster;
|
|
@@ -44,7 +44,6 @@ const HelpTrigger = styled("button", {
|
|
|
44
44
|
// is hard to hit is a help control that does not get used.
|
|
45
45
|
minWidth: "48px",
|
|
46
46
|
minHeight: "48px",
|
|
47
|
-
marginLeft: "4px",
|
|
48
47
|
borderRadius: "9999px",
|
|
49
48
|
borderWidth: "1px",
|
|
50
49
|
borderStyle: "solid",
|
|
@@ -53,6 +52,17 @@ const HelpTrigger = styled("button", {
|
|
|
53
52
|
lineHeight: "1",
|
|
54
53
|
verticalAlign: "middle",
|
|
55
54
|
},
|
|
55
|
+
variants: {
|
|
56
|
+
// Which side of the children the control sits on — see helpGoesFirst
|
|
57
|
+
// below for how that is decided. The gap has to follow the side, or the
|
|
58
|
+
// control touches its subject on one side and floats away from it on the
|
|
59
|
+
// other, which is exactly the ambiguity this fix is about.
|
|
60
|
+
side: {
|
|
61
|
+
before: { marginRight: "4px" },
|
|
62
|
+
after: { marginLeft: "4px" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultVariants: { side: "after" },
|
|
56
66
|
});
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -111,7 +121,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
111
121
|
"aria-label": ariaLabel,
|
|
112
122
|
variant,
|
|
113
123
|
trigger = "hover",
|
|
114
|
-
helpLabel
|
|
124
|
+
helpLabel,
|
|
115
125
|
...rest
|
|
116
126
|
}) => {
|
|
117
127
|
// Caller's variant, else the app-wide one, else `solid` — and anything the
|
|
@@ -182,6 +192,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
182
192
|
const canHover = useCanHover();
|
|
183
193
|
const isClick = trigger === "click" || !canHover;
|
|
184
194
|
|
|
195
|
+
|
|
185
196
|
// The child may be any component (StyledIconButton, a link, a bare span), so
|
|
186
197
|
// whether it is focusable can only be known from the rendered DOM — React
|
|
187
198
|
// cannot see inside a child component's output. Starts as "yes" so the common
|
|
@@ -233,9 +244,36 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
233
244
|
// a duplicated one is a new bug.
|
|
234
245
|
const [needsFallbackName, setNeedsFallbackName] = useState(false);
|
|
235
246
|
|
|
247
|
+
/**
|
|
248
|
+
* The child's own visible text, used to name the help control after the
|
|
249
|
+
* thing it explains (NEH-769).
|
|
250
|
+
*
|
|
251
|
+
* A screen carrying twenty tooltips carried twenty buttons all called "More
|
|
252
|
+
* information", which names nothing: a reader tabbing through hears the same
|
|
253
|
+
* four words twenty times and cannot tell which one answers their question.
|
|
254
|
+
* "Help: Require PIN" is the same control with a name that distinguishes it.
|
|
255
|
+
*
|
|
256
|
+
* Measured from the DOM rather than read from `children` because the child
|
|
257
|
+
* may be any component — React cannot see the text inside a child component's
|
|
258
|
+
* output, only the element it was handed.
|
|
259
|
+
*/
|
|
260
|
+
const [subjectLabel, setSubjectLabel] = useState("");
|
|
261
|
+
|
|
236
262
|
useLayoutEffect(() => {
|
|
237
263
|
const node = triggerRef.current;
|
|
238
|
-
const
|
|
264
|
+
const help = helpRef.current;
|
|
265
|
+
|
|
266
|
+
// The help control is itself a `button`, so it matches FOCUSABLE_SELECTOR
|
|
267
|
+
// and must be excluded from every question asked about the CHILD. This was
|
|
268
|
+
// already wrong before the control could be rendered first — with a
|
|
269
|
+
// non-focusable child the query returned the help button, so
|
|
270
|
+
// aria-describedby landed on the button that already names itself instead
|
|
271
|
+
// of on the thing being described. Once the control renders first it would
|
|
272
|
+
// have matched every time (NEH-769).
|
|
273
|
+
const found =
|
|
274
|
+
Array.from(node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []).find(
|
|
275
|
+
(candidate) => candidate !== help,
|
|
276
|
+
) ?? null;
|
|
239
277
|
// Same-value setState is a no-op in React, so this cannot loop.
|
|
240
278
|
setFocusableChild((prev) => (prev === found ? prev : found));
|
|
241
279
|
setHasFocusableChild(found !== null);
|
|
@@ -251,6 +289,20 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
251
289
|
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
252
290
|
|
|
253
291
|
if (!node) return;
|
|
292
|
+
|
|
293
|
+
// The child's own text — the help control's "?" deliberately excluded, or
|
|
294
|
+
// every subject would be named "… ?" and a text-free child would look as
|
|
295
|
+
// though it had text.
|
|
296
|
+
const ownText = Array.from(node.childNodes)
|
|
297
|
+
.filter((child) => child !== help)
|
|
298
|
+
.map((child) => child.textContent ?? "")
|
|
299
|
+
.join(" ")
|
|
300
|
+
.replace(/\s+/g, " ")
|
|
301
|
+
.trim();
|
|
302
|
+
// Long enough to distinguish twenty controls, short enough that a screen
|
|
303
|
+
// reader does not read a paragraph before the reader can act on it.
|
|
304
|
+
setSubjectLabel(ownText.length > 80 ? `${ownText.slice(0, 80).trimEnd()}…` : ownText);
|
|
305
|
+
|
|
254
306
|
// parentElement, not the node itself: closest() would match our own
|
|
255
307
|
// aria-label once we set one, and the answer would flip every render.
|
|
256
308
|
const namedByAncestor = Boolean(
|
|
@@ -259,12 +311,12 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
259
311
|
// Text content names an element for free; so does a labelled descendant
|
|
260
312
|
// (an icon carrying its own aria-label, an <img alt>).
|
|
261
313
|
const namedByContent =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
node.
|
|
265
|
-
);
|
|
314
|
+
ownText.length > 0 ||
|
|
315
|
+
Array.from(
|
|
316
|
+
node.querySelectorAll('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
|
|
317
|
+
).some((el) => el !== help);
|
|
266
318
|
setNeedsFallbackName(!namedByAncestor && !namedByContent);
|
|
267
|
-
}, [children]);
|
|
319
|
+
}, [children, isClick]);
|
|
268
320
|
|
|
269
321
|
// aria-describedby has to sit on whatever actually receives focus, or a screen
|
|
270
322
|
// reader announces the control with no description. Set imperatively rather
|
|
@@ -482,6 +534,51 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
482
534
|
// would produce "[object Object]" in the accessibility tree.
|
|
483
535
|
const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
|
|
484
536
|
|
|
537
|
+
/**
|
|
538
|
+
* `Label → ? → Input` — the help control goes after the label and before the
|
|
539
|
+
* control it explains, never after it (NEH-769).
|
|
540
|
+
*
|
|
541
|
+
* Not aesthetic. A screen-magnifier user reads linearly at high zoom, so a
|
|
542
|
+
* `?` placed after a long input is pushed off the visible viewport entirely;
|
|
543
|
+
* before the control, the reader meets the concept, can ask what it means,
|
|
544
|
+
* and only then enters data.
|
|
545
|
+
*
|
|
546
|
+
* Consumers wrap two shapes and both have to obey that rule, which is why
|
|
547
|
+
* neither a fixed "always before" nor a fixed "always after" is right:
|
|
548
|
+
*
|
|
549
|
+
* `<Tooltip><Label/></Tooltip> <Input/>` the input is OUTSIDE us, so
|
|
550
|
+
* the control goes AFTER → Label ? | Input
|
|
551
|
+
* `<Tooltip><Row><Label/><Toggle/></Row></Tooltip>`
|
|
552
|
+
* the control is INSIDE us, so
|
|
553
|
+
* it goes BEFORE → ? Label Toggle
|
|
554
|
+
*
|
|
555
|
+
* So the side keys on whether the children contain something focusable —
|
|
556
|
+
* which the component already measures for its own tab-stop logic. It is
|
|
557
|
+
* measured in a layout effect, so it settles before paint rather than
|
|
558
|
+
* flickering into place.
|
|
559
|
+
*/
|
|
560
|
+
const helpGoesFirst = hasFocusableChild;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Explicit label wins; otherwise name the control after its subject. Only
|
|
564
|
+
* when there is no text at all does it fall back to the old generic name.
|
|
565
|
+
*/
|
|
566
|
+
const resolvedHelpLabel =
|
|
567
|
+
helpLabel ?? (subjectLabel ? `Help: ${subjectLabel}` : "More information");
|
|
568
|
+
|
|
569
|
+
const helpControl = isClick ? (
|
|
570
|
+
<HelpTrigger
|
|
571
|
+
ref={helpRef}
|
|
572
|
+
type="button"
|
|
573
|
+
side={helpGoesFirst ? "before" : "after"}
|
|
574
|
+
aria-label={resolvedHelpLabel}
|
|
575
|
+
aria-expanded={visible}
|
|
576
|
+
aria-controls={visible ? tooltipId : undefined}
|
|
577
|
+
onClick={() => setVisible((open) => !open)}
|
|
578
|
+
>
|
|
579
|
+
?
|
|
580
|
+
</HelpTrigger>
|
|
581
|
+
) : null;
|
|
485
582
|
|
|
486
583
|
return (
|
|
487
584
|
<>
|
|
@@ -519,24 +616,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
519
616
|
onMouseLeave={isClick ? undefined : hide}
|
|
520
617
|
onFocus={isClick ? undefined : show}
|
|
521
618
|
onBlur={isClick ? undefined : hide}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
619
|
+
// When something focusable is in play the description is set
|
|
620
|
+
// imperatively on THAT element instead (see the layout effect above) —
|
|
621
|
+
// it has to sit on whatever actually receives focus. This wrapper is
|
|
622
|
+
// the fallback for label-only children, and it applies in click mode
|
|
623
|
+
// too: help that is reachable but never announced is help a screen
|
|
624
|
+
// reader user does not know exists (NEH-769).
|
|
625
|
+
//
|
|
626
|
+
// `insideFocusable`, not `hasFocusableChild`, so a focusable ANCESTOR
|
|
627
|
+
// still owns the description rather than this wrapper (NEH-950).
|
|
628
|
+
aria-describedby={!insideFocusable && visible ? tooltipId : undefined}
|
|
525
629
|
{...rest}
|
|
526
630
|
>
|
|
631
|
+
{helpGoesFirst && helpControl}
|
|
527
632
|
{children}
|
|
528
|
-
{
|
|
529
|
-
<HelpTrigger
|
|
530
|
-
ref={helpRef}
|
|
531
|
-
type="button"
|
|
532
|
-
aria-label={helpLabel}
|
|
533
|
-
aria-expanded={visible}
|
|
534
|
-
aria-controls={visible ? tooltipId : undefined}
|
|
535
|
-
onClick={() => setVisible((open) => !open)}
|
|
536
|
-
>
|
|
537
|
-
?
|
|
538
|
-
</HelpTrigger>
|
|
539
|
-
)}
|
|
633
|
+
{!helpGoesFirst && helpControl}
|
|
540
634
|
</TooltipTrigger>
|
|
541
635
|
{visible && typeof document !== "undefined" &&
|
|
542
636
|
createPortal(
|