@helloimjolopez-pathway/pathway-components 0.10.1 → 0.10.2

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.
@@ -0,0 +1,205 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * spinner.jsx: the React module Spinner never had, with NewCo's ring loader.
5
+ *
6
+ * WHY THIS EXISTS
7
+ * Spinner shipped as HTML/SVG/CSS only; the manifest said a .jsx wrapper was on
8
+ * the backlog. Jo asked for NewCo's spinner and loading states to be brought
9
+ * into the repo, and NewCo's loader is a RING, not the sunburst Amplify has. So
10
+ * both live here as variants rather than as two components: a caller picks the
11
+ * shape, and everything else about the contract is shared.
12
+ *
13
+ * WHERE THE NEWCO DESIGN CAME FROM
14
+ * The NewCo and Spired design concept. It is NOT in the newco-ds repo (checked
15
+ * local and origin, no spinner, skeleton or shimmer at any depth and no commit
16
+ * mentioning them), so the geometry and timings are transcribed from the
17
+ * concept: three arcs at 0.32, 0.19 and 0.09 of the circumference, offset 0,
18
+ * 0.4 and 0.68, turning at 1.4s linear. It reads as the mark's DNA, one long,
19
+ * one medium and one short pill, arranged in a ring.
20
+ *
21
+ * WHAT CHANGED COMING ACROSS, AND WHY IT IS BETTER
22
+ * The concept painted its three arcs with hardcoded NewCo purples: three rungs of a `purple` primitive ramp that
23
+ * Pathway does not have at all (and #6E64BE is in fact NewCo's own Brand/400).
24
+ * Here the arcs are currentColor at
25
+ * three stroke opacities, so the ring inherits whatever the tone and emphasis
26
+ * resolve to. That means it renders Amplify blue under Amplify and NewCo purple
27
+ * under NewCo with no branching, and it also answers every tone the sunburst
28
+ * does, which the hardcoded version could not.
29
+ */
30
+
31
+ /** The tones Spinner answers to. Mirrors spinner.html. */
32
+ export const TONES = [
33
+ "neutral", "brand", "info", "warning", "danger", "negative", "positive",
34
+ "accent-amethyst", "accent-jade", "accent-seabreeze",
35
+ ];
36
+ export const EMPHASES = ["light", "subtle", "base", "contrast", "bold"];
37
+
38
+ const c = (n) => `var(--semantic-color-${n})`;
39
+
40
+ /**
41
+ * tone + emphasis -> the ONE colour token the spinner takes.
42
+ *
43
+ * Only `neutral` has a real emphasis ramp. Every other tone resolves to a
44
+ * single token at all five emphases, and that is deliberate rather than
45
+ * unfinished: those families have no foreground ladder to ramp through. The
46
+ * prop is still accepted so a caller can pass emphasis uniformly without
47
+ * special-casing neutral.
48
+ */
49
+ const TONE = {
50
+ neutral: {
51
+ light: c("foreground-static-neutral-faint"),
52
+ subtle: c("foreground-static-neutral-base"),
53
+ base: c("foreground-static-neutral-base"),
54
+ contrast: c("foreground-static-neutral-strong"),
55
+ bold: c("foreground-static-neutral-bold"),
56
+ },
57
+ brand: c("foreground-action-primary-on-subtle-rest"),
58
+ info: c("foreground-static-info-on-subtle"),
59
+ warning: c("foreground-static-attention-on-subtle"),
60
+ danger: c("foreground-static-negative-on-subtle"),
61
+ negative: c("foreground-static-negative-on-subtle"),
62
+ positive: c("foreground-static-positive-on-subtle"),
63
+ "accent-amethyst": c("foreground-static-info-on-subtle"),
64
+ "accent-jade": c("foreground-static-accent-jade-on-subtle"),
65
+ "accent-seabreeze": c("foreground-static-accent-seabreeze-on-subtle"),
66
+ };
67
+
68
+ function colourFor(tone, emphasis) {
69
+ const entry = TONE[tone] ?? TONE.neutral;
70
+ if (typeof entry === "string") return entry;
71
+ return entry[emphasis] ?? entry.base;
72
+ }
73
+
74
+ /**
75
+ * Amplify's sunburst. Eight spokes on a 12x12 viewBox with an opacity ladder
76
+ * from 1.00 down to 0.12, which breaks the 8-fold rotational symmetry so that
77
+ * rotating the whole SVG reads as a head with a fading trail.
78
+ */
79
+ const SUNBURST = [
80
+ { d: "M6 1V3", opacity: 1.0 },
81
+ { d: "M8.1 3.9L9.55 2.45", opacity: 0.87 },
82
+ { d: "M9 6H11", opacity: 0.75 },
83
+ { d: "M8.1 8.1L9.55 9.55", opacity: 0.62 },
84
+ { d: "M6 9V11", opacity: 0.5 },
85
+ { d: "M2.45 9.55L3.9 8.1", opacity: 0.37 },
86
+ { d: "M1 6H3", opacity: 0.25 },
87
+ { d: "M2.45 2.45L3.9 3.9", opacity: 0.12 },
88
+ ];
89
+
90
+ /** NewCo's ring: one long, one medium, one short arc. */
91
+ const RING = [
92
+ { frac: 0.32, off: 0.0, opacity: 1 },
93
+ { frac: 0.19, off: 0.4, opacity: 0.7 },
94
+ { frac: 0.09, off: 0.68, opacity: 0.45 },
95
+ ];
96
+
97
+ /**
98
+ * @param {"ring"|"sunburst"} variant ring is NewCo's, sunburst is Amplify's
99
+ * @param {number|string} size px number or any CSS length; default 1em
100
+ * @param {string} tone one of TONES
101
+ * @param {string} emphasis one of EMPHASES; only neutral ramps
102
+ * @param {string} label announced by screen readers
103
+ */
104
+ export function Spinner({
105
+ variant = "ring",
106
+ size,
107
+ tone = "neutral",
108
+ emphasis = "base",
109
+ label = "Loading",
110
+ className,
111
+ style,
112
+ }) {
113
+ // Invalid values are surfaced loudly rather than rendering as `color: unset`,
114
+ // which is invisible and would look like the spinner simply failed to paint.
115
+ if (!TONES.includes(tone)) {
116
+ console.error(`[Spinner] Invalid tone "${tone}". Allowed: ${TONES.join(", ")}`);
117
+ }
118
+ if (!EMPHASES.includes(emphasis)) {
119
+ console.error(`[Spinner] Invalid emphasis "${emphasis}". Allowed: ${EMPHASES.join(", ")}`);
120
+ }
121
+
122
+ const box = size == null ? "1em" : typeof size === "number" ? `${size}px` : size;
123
+ const common = {
124
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
125
+ width: box, height: box, flexShrink: 0,
126
+ color: colourFor(tone, emphasis),
127
+ };
128
+
129
+ // 24 units of viewBox for the ring so the stroke maths stay whole numbers.
130
+ const V = 24, STROKE = 2.5;
131
+ const r = (V - STROKE) / 2;
132
+ const circ = 2 * Math.PI * r;
133
+
134
+ return (
135
+ <span
136
+ role="status"
137
+ aria-live="polite"
138
+ aria-label={label}
139
+ className={className}
140
+ data-tone={tone}
141
+ data-emphasis={emphasis}
142
+ data-variant={variant}
143
+ style={{ ...common, ...style }}
144
+ >
145
+ {variant === "sunburst" ? (
146
+ <svg
147
+ viewBox="0 0 12 12" width="100%" height="100%" aria-hidden="true" focusable="false"
148
+ style={{ animation: "pw-spin var(--motion-duration-loop) var(--motion-easing-linear) infinite" }}
149
+ >
150
+ {SUNBURST.map((s, i) => (
151
+ <path key={i} d={s.d} opacity={s.opacity} stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
152
+ ))}
153
+ </svg>
154
+ ) : (
155
+ <svg
156
+ viewBox={`0 0 ${V} ${V}`} width="100%" height="100%" fill="none" aria-hidden="true" focusable="false"
157
+ // 1.4s linear, from the concept. Linear is correct for a continuous
158
+ // loop: any eased curve makes a constant rotation appear to stutter
159
+ // once per revolution. motion.css reserves easing-linear for exactly
160
+ // this and says so.
161
+ style={{ animation: "pw-spin 1400ms var(--motion-easing-linear) infinite" }}
162
+ >
163
+ {RING.map((s, i) => (
164
+ <circle
165
+ key={i}
166
+ cx={V / 2} cy={V / 2} r={r}
167
+ stroke="currentColor" strokeOpacity={s.opacity}
168
+ strokeWidth={STROKE} strokeLinecap="round"
169
+ strokeDasharray={`${s.frac * circ} ${circ}`}
170
+ strokeDashoffset={-s.off * circ}
171
+ />
172
+ ))}
173
+ </svg>
174
+ )}
175
+ <span
176
+ style={{
177
+ position: "absolute", width: 1, height: 1, padding: 0, margin: -1,
178
+ overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0,
179
+ }}
180
+ >
181
+ {label}
182
+ </span>
183
+ </span>
184
+ );
185
+ }
186
+
187
+ /**
188
+ * The keyframes both variants need, plus the reduced-motion opt-out.
189
+ *
190
+ * Returned as a component rather than injected on import, because a module with
191
+ * an import side effect cannot be tree-shaken and would push CSS into any
192
+ * bundle that merely referenced the file. Mount it once per document.
193
+ */
194
+ export function SpinnerKeyframes() {
195
+ return (
196
+ <style>{`
197
+ @keyframes pw-spin { to { transform: rotate(360deg); } }
198
+ @media (prefers-reduced-motion: reduce) {
199
+ [role="status"][data-variant] svg { animation: none !important; }
200
+ }
201
+ `}</style>
202
+ );
203
+ }
204
+
205
+ export default Spinner;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
17
17
  import { Tooltip } from "../tooltip/tooltip.jsx";
18
- import { Dropdown, DropdownItem } from "../dropdown/dropdown.jsx";
18
+ import { PopoverMenu, PopoverMenuItem } from "../popover-menu/popover-menu.jsx";
19
19
  import { Icon } from "../icon/icon.jsx";
20
20
  import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
21
21
 
@@ -290,6 +290,10 @@ export function ToggleButton({
290
290
  const btnRefs = useRef({});
291
291
  const [fit, setFit] = useState({ visible: options.map((_, i) => i), hidden: [], natural: null });
292
292
  const [focusAfter, setFocusAfter] = useState(null);
293
+ /* An option picked from More takes focus once the menu has closed. The menu
294
+ returns focus to its trigger as it goes, so the hand-over waits for that
295
+ return instead of racing it. */
296
+ const focusOnReturn = useRef(null);
293
297
 
294
298
  const refit = useCallback(() => {
295
299
  const wrap = wrapRef.current, meas = measureRef.current;
@@ -394,11 +398,17 @@ export function ToggleButton({
394
398
  </div>
395
399
 
396
400
  {showMore && (
397
- <Dropdown
401
+ <PopoverMenu
398
402
  align="end"
399
403
  label={iconOnly ? "More options" : moreLabel}
400
404
  renderTrigger={({ title, ...trigger }) => (
401
405
  <ToggleButtonOption {...shared} {...trigger} as="button" fill={false}
406
+ onFocus={() => {
407
+ if (focusOnReturn.current == null) return;
408
+ const v = focusOnReturn.current;
409
+ focusOnReturn.current = null;
410
+ setFocusAfter(v);
411
+ }}
402
412
  label={iconOnly ? "More options" : moreLabel} icon={iconOnly ? "more_horiz" : undefined}
403
413
  position={positionOf(count - 1)} />
404
414
  )}
@@ -406,12 +416,12 @@ export function ToggleButton({
406
416
  {hidden.map((i) => {
407
417
  const o = options[i];
408
418
  return (
409
- <DropdownItem key={o.value} label={o.label} icon={showIcon ? o.icon : undefined}
410
- disabled={!!o.disabled} note={o.disabled ? o.reason : undefined}
411
- onSelect={() => { select(o.value); setFocusAfter(o.value); }} />
419
+ <PopoverMenuItem key={o.value} id={o.value} label={o.label} leading={showIcon ? o.icon : undefined}
420
+ disabled={!!o.disabled} reason={o.disabled ? o.reason : undefined}
421
+ onAction={() => { select(o.value); focusOnReturn.current = o.value; }} />
412
422
  );
413
423
  })}
414
- </Dropdown>
424
+ </PopoverMenu>
415
425
  )}
416
426
 
417
427
  {/* Measure layer: every option and the More trigger at natural size, out