@stonedogcode/style 0.20.0 → 0.20.1
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/package.json
CHANGED
|
@@ -68,25 +68,80 @@ const PandaItemGroup = styled("div", {
|
|
|
68
68
|
});
|
|
69
69
|
const PandaItem = styled("div", { base: { position: "relative" } });
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Chakra's `Float` — the delete control at the item's top-end corner.
|
|
73
|
+
*
|
|
74
|
+
* The `translate(40%, -40%)` this used to carry is gone (NEH-1117). It centred
|
|
75
|
+
* the control ON the corner, so roughly half of it hung outside the preview it
|
|
76
|
+
* belonged to — measured in Chromium at every viewport, the control's top edge
|
|
77
|
+
* sat at **y = -7.2**, above the top of the page. That is tolerable while the
|
|
78
|
+
* control is 16px and merely untidy; at the 48px hit area below it would put
|
|
79
|
+
* two neighbouring previews' targets into the same few pixels, which is the
|
|
80
|
+
* sharper half of what the issue reports.
|
|
81
|
+
*
|
|
82
|
+
* So the hit area is anchored inside the tile instead, and the visible chip
|
|
83
|
+
* sits in its top-end corner — which lands the chip in very nearly the place
|
|
84
|
+
* it occupied before, without anything overhanging.
|
|
85
|
+
*/
|
|
72
86
|
const PandaFloat = styled("div", {
|
|
73
87
|
base: {
|
|
74
88
|
position: "absolute",
|
|
75
89
|
top: "0",
|
|
76
90
|
right: "0",
|
|
77
|
-
transform: "translate(40%, -40%)",
|
|
78
91
|
},
|
|
79
92
|
});
|
|
80
93
|
|
|
81
94
|
/**
|
|
95
|
+
* The hit area — and only the hit area.
|
|
96
|
+
*
|
|
82
97
|
* `boxSize="4"` and `layerStyle="fill.solid"` were Chakra props with no Panda
|
|
83
|
-
* equivalent
|
|
84
|
-
*
|
|
98
|
+
* equivalent; their effect is reproduced as real styles. What was NOT
|
|
99
|
+
* reproduced was a tap target: `boxSize="4"` is **16x16 CSS px**, a third of
|
|
100
|
+
* this package's stated 48x48 floor, and it came across the extraction
|
|
101
|
+
* unchanged (NEH-1116) because changing it changes HopperGuard's rendering.
|
|
102
|
+
* This is that change.
|
|
103
|
+
*
|
|
104
|
+
* The split is `StyledTag`'s, which solves the same tension: the BUTTON is the
|
|
105
|
+
* target and carries no appearance at all, and the chip inside it is what a
|
|
106
|
+
* reader sees. Sizing the visible circle to 48px instead would put a control
|
|
107
|
+
* half the width of the 96px preview on top of it.
|
|
108
|
+
*
|
|
109
|
+
* `StyledTag` needs negative block margin to stop the target growing its own
|
|
110
|
+
* tag; here the control is absolutely positioned, so it is already out of
|
|
111
|
+
* flow and cannot drag the preview's layout with it whatever size it is. The
|
|
112
|
+
* component test asserts the preview stays 96x96 rather than trusting that.
|
|
113
|
+
*/
|
|
114
|
+
const PandaDeleteTrigger = styled("button", {
|
|
115
|
+
base: {
|
|
116
|
+
display: "inline-flex",
|
|
117
|
+
// Top-end, not centred: the chip keeps the corner position it has always
|
|
118
|
+
// had, and the extra target grows inwards over the preview — which is
|
|
119
|
+
// decorative, and the only direction with room.
|
|
120
|
+
alignItems: "flex-start",
|
|
121
|
+
justifyContent: "flex-end",
|
|
122
|
+
// The house floor, stated rather than left to emerge from whatever glyph
|
|
123
|
+
// a consumer passes — see CLAUDE.md. 48 rather than WCAG 2.5.5 AAA's 44,
|
|
124
|
+
// because the standard is calibrated for the general population and this
|
|
125
|
+
// library's largest consumer serves an often-elderly, sometimes
|
|
126
|
+
// motor-impaired audience.
|
|
127
|
+
minWidth: "48px",
|
|
128
|
+
minHeight: "48px",
|
|
129
|
+
padding: "0",
|
|
130
|
+
background: "transparent",
|
|
131
|
+
border: "none",
|
|
132
|
+
cursor: "pointer",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a reader actually sees: the small round chip that used to BE the
|
|
138
|
+
* button. Same size, same tokens, same appearance — it is now a passenger
|
|
139
|
+
* inside a target big enough to hit.
|
|
85
140
|
*
|
|
86
141
|
* The colours are tokens, never literals, so the control follows the host's
|
|
87
142
|
* theme and colour mode.
|
|
88
143
|
*/
|
|
89
|
-
const
|
|
144
|
+
const PandaDeleteChip = styled("span", {
|
|
90
145
|
base: {
|
|
91
146
|
display: "inline-flex",
|
|
92
147
|
alignItems: "center",
|
|
@@ -94,7 +149,6 @@ const PandaDeleteTrigger = styled("button", {
|
|
|
94
149
|
width: "4",
|
|
95
150
|
height: "4",
|
|
96
151
|
borderRadius: "full",
|
|
97
|
-
cursor: "pointer",
|
|
98
152
|
lineHeight: "1",
|
|
99
153
|
bg: "boxBgPrimary",
|
|
100
154
|
color: "textPrimary",
|
|
@@ -207,9 +261,7 @@ const FileUploadPreviewOnly = ({
|
|
|
207
261
|
aria-label={`Remove ${file.name}`}
|
|
208
262
|
onClick={() => removeFile(file)}
|
|
209
263
|
>
|
|
210
|
-
{removeIcon
|
|
211
|
-
<span aria-hidden="true">{removeIcon}</span>
|
|
212
|
-
) : null}
|
|
264
|
+
<PandaDeleteChip aria-hidden="true">{removeIcon}</PandaDeleteChip>
|
|
213
265
|
</PandaDeleteTrigger>
|
|
214
266
|
</PandaFloat>
|
|
215
267
|
</PandaItem>
|
|
@@ -226,6 +226,59 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
226
226
|
*/
|
|
227
227
|
const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Whether the layout effect below has yet decided WHERE the click-mode help
|
|
231
|
+
* control belongs (NEH-965).
|
|
232
|
+
*
|
|
233
|
+
* The control is a real `<button>`, and until the trigger has been measured
|
|
234
|
+
* the component cannot know whether it is standing inside one. Rendering it
|
|
235
|
+
* optimistically and moving it afterwards is not an option: React validates
|
|
236
|
+
* DOM nesting at *render* time, against the React tree, so a single pass
|
|
237
|
+
* with `<button>` inside `<button>` warns and — on a server-rendered host
|
|
238
|
+
* like hopper-web — produces a hydration error, whatever the DOM ends up
|
|
239
|
+
* looking like a moment later.
|
|
240
|
+
*
|
|
241
|
+
* So the first pass renders no control at all and the second one puts it
|
|
242
|
+
* where it belongs. Both effects here are layout effects, so that settles
|
|
243
|
+
* before paint and before hydration compares anything: there is no frame in
|
|
244
|
+
* which a reader could see the control missing, and no server/client
|
|
245
|
+
* mismatch, because the server also renders nothing.
|
|
246
|
+
*/
|
|
247
|
+
const [helpPlacementSettled, setHelpPlacementSettled] = useState(false);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The element the click-mode help control is rendered into when the trigger
|
|
251
|
+
* sits inside something focusable (NEH-965).
|
|
252
|
+
*
|
|
253
|
+
* `HelpTrigger` renders beside the child, inside the trigger wrapper. When
|
|
254
|
+
* the tooltipped thing is an icon inside an icon button that lands the
|
|
255
|
+
* control *inside* that button:
|
|
256
|
+
*
|
|
257
|
+
* <button aria-label="Expand"> <- the consumer's control
|
|
258
|
+
* <div> <- TooltipTrigger
|
|
259
|
+
* <svg aria-hidden />
|
|
260
|
+
* <button aria-label="More information">?</button> <- invalid
|
|
261
|
+
*
|
|
262
|
+
* `<button>` cannot be a descendant of `<button>`, and this is not a
|
|
263
|
+
* preference anybody opted into: `isClick` is `trigger === "click" ||
|
|
264
|
+
* !canHover`, so it is the DEFAULT rendering on every phone and tablet.
|
|
265
|
+
*
|
|
266
|
+
* Simply not rendering the control would trade invalid HTML for an
|
|
267
|
+
* unreachable explanation — on a device that cannot hover there is no hover,
|
|
268
|
+
* and tapping the button activates it rather than explaining it. So the
|
|
269
|
+
* control moves *out* instead: a span inserted immediately after the
|
|
270
|
+
* focusable ancestor, portalled into. Valid HTML, still visible, still
|
|
271
|
+
* tappable, still in the tab sequence, and it scrolls with the page because
|
|
272
|
+
* it sits in normal flow rather than being positioned over anything.
|
|
273
|
+
*
|
|
274
|
+
* `inline-flex` on the host rather than `display: contents`: contents would
|
|
275
|
+
* let the control participate in the ancestor's parent layout directly, but
|
|
276
|
+
* it was removed from the accessibility tree by browsers this package's
|
|
277
|
+
* audience is still using, and an invisible help control is the bug we are
|
|
278
|
+
* fixing.
|
|
279
|
+
*/
|
|
280
|
+
const [helpHost, setHelpHost] = useState<HTMLElement | null>(null);
|
|
281
|
+
|
|
229
282
|
/**
|
|
230
283
|
* True when something else — a descendant or an ancestor — already puts this
|
|
231
284
|
* trigger's content in the tab sequence. When it does, the trigger must add
|
|
@@ -287,6 +340,10 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
287
340
|
? null
|
|
288
341
|
: node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
|
|
289
342
|
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
343
|
+
// Measured — the next render may place the help control (NEH-965). Set
|
|
344
|
+
// before the `!node` bail so a trigger that never mounted a node does not
|
|
345
|
+
// leave click mode permanently without its control.
|
|
346
|
+
setHelpPlacementSettled(true);
|
|
290
347
|
|
|
291
348
|
if (!node) return;
|
|
292
349
|
|
|
@@ -299,9 +356,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
299
356
|
.join(" ")
|
|
300
357
|
.replace(/\s+/g, " ")
|
|
301
358
|
.trim();
|
|
359
|
+
// With a focusable ancestor the help control is rendered OUTSIDE it
|
|
360
|
+
// (NEH-965), so the ancestor no longer supplies context by containment and
|
|
361
|
+
// the control has to carry the subject in its own name. "More information"
|
|
362
|
+
// sitting on its own next to a button called "Expand" names nothing —
|
|
363
|
+
// which is the twenty-identical-controls problem NEH-769 fixed for the
|
|
364
|
+
// inline case, reappearing one level up.
|
|
365
|
+
const ancestorText = ancestor
|
|
366
|
+
? (ancestor.getAttribute("aria-label") ?? ancestor.textContent ?? "")
|
|
367
|
+
.replace(/\s+/g, " ")
|
|
368
|
+
.trim()
|
|
369
|
+
: "";
|
|
370
|
+
const subject = ownText || ancestorText;
|
|
302
371
|
// Long enough to distinguish twenty controls, short enough that a screen
|
|
303
372
|
// reader does not read a paragraph before the reader can act on it.
|
|
304
|
-
setSubjectLabel(
|
|
373
|
+
setSubjectLabel(subject.length > 80 ? `${subject.slice(0, 80).trimEnd()}…` : subject);
|
|
305
374
|
|
|
306
375
|
// parentElement, not the node itself: closest() would match our own
|
|
307
376
|
// aria-label once we set one, and the answer would flip every render.
|
|
@@ -361,6 +430,67 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
361
430
|
};
|
|
362
431
|
}, [focusableAncestor, isClick, show, hide]);
|
|
363
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Insert the host for the portalled help control, immediately after the
|
|
435
|
+
* focusable ancestor (NEH-965). A layout effect, so the control is in place
|
|
436
|
+
* before paint.
|
|
437
|
+
*/
|
|
438
|
+
useLayoutEffect(() => {
|
|
439
|
+
if (!isClick || !focusableAncestor || typeof document === "undefined") {
|
|
440
|
+
setHelpHost(null);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const host = document.createElement("span");
|
|
444
|
+
// Named so a consumer reading the DOM can see whose node this is, and so a
|
|
445
|
+
// test can assert the control landed outside the ancestor rather than
|
|
446
|
+
// merely that it exists somewhere.
|
|
447
|
+
host.setAttribute("data-stonedog-tooltip-help-host", "");
|
|
448
|
+
host.style.display = "inline-flex";
|
|
449
|
+
host.style.verticalAlign = "middle";
|
|
450
|
+
focusableAncestor.after(host);
|
|
451
|
+
setHelpHost(host);
|
|
452
|
+
return () => {
|
|
453
|
+
host.remove();
|
|
454
|
+
setHelpHost(null);
|
|
455
|
+
};
|
|
456
|
+
}, [isClick, focusableAncestor]);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Keep the host beside its ancestor when React MOVES the ancestor.
|
|
460
|
+
*
|
|
461
|
+
* The host is a node this component inserts, not one React renders, so React
|
|
462
|
+
* does not move it with the ancestor: reordering a row of icon buttons
|
|
463
|
+
* leaves every help control behind at its old index. Measured before fixing,
|
|
464
|
+
* two buttons swapped:
|
|
465
|
+
*
|
|
466
|
+
* before BTN(Expand) HOST(Help: Expand) BTN(Collapse) HOST(Help: Collapse)
|
|
467
|
+
* after HOST(Help: Expand) BTN(Collapse) HOST(Help: Collapse) BTN(Expand)
|
|
468
|
+
*
|
|
469
|
+
* — every control now beside the wrong button, which for an accessibility
|
|
470
|
+
* affordance is worse than the nesting it replaced.
|
|
471
|
+
*
|
|
472
|
+
* Re-running the effect above cannot catch this. Its dependency is the
|
|
473
|
+
* ancestor NODE, and a moved node is the same node, so nothing changes and
|
|
474
|
+
* nothing re-runs. Hence a deliberately dependency-free layout effect: it
|
|
475
|
+
* re-asserts the placement on every commit, before paint, and costs two DOM
|
|
476
|
+
* property reads on a path that only exists in click mode inside a focusable
|
|
477
|
+
* ancestor.
|
|
478
|
+
*
|
|
479
|
+
* Scope worth stating rather than implying: this follows a move that
|
|
480
|
+
* RE-RENDERS this component, which is what a list reorder does. A subtree
|
|
481
|
+
* memoised so hard that React moves it without rendering it would not be
|
|
482
|
+
* followed. A `MutationObserver` on the parent would cover that too, and is
|
|
483
|
+
* deliberately not here — its callback is a microtask, so it could not be
|
|
484
|
+
* asserted in the tier that can see this at all, and an untested guard is
|
|
485
|
+
* the thing this package's rules are most emphatic about.
|
|
486
|
+
*/
|
|
487
|
+
useLayoutEffect(() => {
|
|
488
|
+
if (!helpHost || !focusableAncestor) return;
|
|
489
|
+
if (!focusableAncestor.isConnected) return;
|
|
490
|
+
if (helpHost.previousSibling === focusableAncestor) return;
|
|
491
|
+
focusableAncestor.after(helpHost);
|
|
492
|
+
});
|
|
493
|
+
|
|
364
494
|
useLayoutEffect(() => {
|
|
365
495
|
if (visible && triggerRef.current && tooltipRef.current) {
|
|
366
496
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
@@ -566,19 +696,40 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
566
696
|
const resolvedHelpLabel =
|
|
567
697
|
helpLabel ?? (subjectLabel ? `Help: ${subjectLabel}` : "More information");
|
|
568
698
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
699
|
+
/**
|
|
700
|
+
* The click-mode control — rendered only once its home is known, see
|
|
701
|
+
* `helpPlacementSettled`.
|
|
702
|
+
*
|
|
703
|
+
* `stopPropagation` is load-bearing rather than defensive. A React portal
|
|
704
|
+
* bubbles its events through the REACT tree, not the DOM tree, so once this
|
|
705
|
+
* control is portalled out of the icon button it is still, as far as React
|
|
706
|
+
* is concerned, inside it — and a press on "?" would fire the button's own
|
|
707
|
+
* `onClick`. That is precisely the collision this fix exists to remove, so
|
|
708
|
+
* it is stopped for the inline case too: pressing "?" asks for an
|
|
709
|
+
* explanation, and must never also do the thing being explained.
|
|
710
|
+
*/
|
|
711
|
+
const helpControl =
|
|
712
|
+
isClick && helpPlacementSettled ? (
|
|
713
|
+
<HelpTrigger
|
|
714
|
+
ref={helpRef}
|
|
715
|
+
type="button"
|
|
716
|
+
side={helpGoesFirst ? "before" : "after"}
|
|
717
|
+
aria-label={resolvedHelpLabel}
|
|
718
|
+
aria-expanded={visible}
|
|
719
|
+
aria-controls={visible ? tooltipId : undefined}
|
|
720
|
+
onMouseDown={(event: React.MouseEvent) => event.stopPropagation()}
|
|
721
|
+
onClick={(event: React.MouseEvent) => {
|
|
722
|
+
event.stopPropagation();
|
|
723
|
+
setVisible((open) => !open);
|
|
724
|
+
}}
|
|
725
|
+
>
|
|
726
|
+
?
|
|
727
|
+
</HelpTrigger>
|
|
728
|
+
) : null;
|
|
729
|
+
|
|
730
|
+
// Inside a focusable ancestor the control is portalled out to `helpHost`;
|
|
731
|
+
// everywhere else it stays where it has always been, beside the child.
|
|
732
|
+
const inlineHelpControl = focusableAncestor ? null : helpControl;
|
|
582
733
|
|
|
583
734
|
return (
|
|
584
735
|
<>
|
|
@@ -628,10 +779,11 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
628
779
|
aria-describedby={!insideFocusable && visible ? tooltipId : undefined}
|
|
629
780
|
{...rest}
|
|
630
781
|
>
|
|
631
|
-
{helpGoesFirst &&
|
|
782
|
+
{helpGoesFirst && inlineHelpControl}
|
|
632
783
|
{children}
|
|
633
|
-
{!helpGoesFirst &&
|
|
784
|
+
{!helpGoesFirst && inlineHelpControl}
|
|
634
785
|
</TooltipTrigger>
|
|
786
|
+
{helpControl && helpHost && createPortal(helpControl, helpHost)}
|
|
635
787
|
{visible && typeof document !== "undefined" &&
|
|
636
788
|
createPortal(
|
|
637
789
|
<TooltipContent
|