@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
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.",
@@ -68,25 +68,80 @@ const PandaItemGroup = styled("div", {
68
68
  });
69
69
  const PandaItem = styled("div", { base: { position: "relative" } });
70
70
 
71
- /** Chakra's `Float` — the delete control at the item's top-end corner. */
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 they do not exist on a `styled("button")` and the type-check
84
- * rejects both. Their effect is reproduced as real styles.
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 PandaDeleteTrigger = styled("button", {
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(ownText.length > 80 ? `${ownText.slice(0, 80).trimEnd()}…` : ownText);
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
- 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;
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 && helpControl}
782
+ {helpGoesFirst && inlineHelpControl}
632
783
  {children}
633
- {!helpGoesFirst && helpControl}
784
+ {!helpGoesFirst && inlineHelpControl}
634
785
  </TooltipTrigger>
786
+ {helpControl && helpHost && createPortal(helpControl, helpHost)}
635
787
  {visible && typeof document !== "undefined" &&
636
788
  createPortal(
637
789
  <TooltipContent