@unpunnyfuns/swatchbook-blocks 0.51.1 → 0.53.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/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as _$react from "react";
2
2
  import { ReactElement, ReactNode } from "react";
3
+ import { Axis, Diagnostic, Permutation, Preset } from "@unpunnyfuns/swatchbook-core";
3
4
 
4
5
  //#region src/format-color.d.ts
5
6
  /**
@@ -48,34 +49,24 @@ declare function formatColor(value: unknown, format: ColorFormat, fallback?: str
48
49
  //#endregion
49
50
  //#region src/contexts.d.ts
50
51
  /**
51
- * Typed shape of the addon's `virtual:swatchbook/tokens` module, duplicated
52
- * as value-importable interfaces so consumers outside the addon's Vite
53
- * plugin (unit tests, custom React apps) can construct a snapshot by hand.
52
+ * Typed shape of the addon's `virtual:swatchbook/tokens` module.
54
53
  *
55
- * The ambient `declare module 'virtual:swatchbook/tokens'` declarations in
56
- * `packages/addon/src/virtual.d.ts` describe the same payload; the two
57
- * stay in sync by eye.
54
+ * The axis / permutation / diagnostic / preset entries are deliberate
55
+ * type-aliases of core's authoritative shapes the virtual module
56
+ * publishes those shapes verbatim, so single-sourcing the types prevents
57
+ * silent drift the moment core grows a field the plugin doesn't
58
+ * serialise.
59
+ *
60
+ * Token / listing shapes stay as narrowed interfaces because blocks
61
+ * only read a subset of Terrazzo's full token / listing structure; the
62
+ * narrower shape documents what's actually relied on.
63
+ *
64
+ * The ambient `declare module 'virtual:swatchbook/tokens'` declarations
65
+ * in `packages/addon/src/virtual.d.ts` describe the same payload.
58
66
  */
59
- interface VirtualAxisShape {
60
- name: string;
61
- contexts: readonly string[];
62
- default: string;
63
- description?: string;
64
- source: 'resolver' | 'layered' | 'synthetic';
65
- }
66
- interface VirtualPermutationShape {
67
- name: string;
68
- input: Record<string, string>;
69
- sources: string[];
70
- }
71
- interface VirtualDiagnosticShape {
72
- severity: 'error' | 'warn' | 'info';
73
- group: string;
74
- message: string;
75
- filename?: string;
76
- line?: number;
77
- column?: number;
78
- }
67
+ type VirtualAxisShape = Axis;
68
+ type VirtualPermutationShape = Permutation;
69
+ type VirtualDiagnosticShape = Diagnostic;
79
70
  interface VirtualTokenShape {
80
71
  $type?: string;
81
72
  $value?: unknown;
@@ -112,11 +103,7 @@ interface VirtualTokenListingShape {
112
103
  };
113
104
  };
114
105
  }
115
- interface VirtualPresetShape {
116
- name: string;
117
- axes: Partial<Record<string, string>>;
118
- description?: string;
119
- }
106
+ type VirtualPresetShape = Preset;
120
107
  /**
121
108
  * Full project data read by blocks. Populated by the addon's preview
122
109
  * decorator (from the virtual module) or constructed by hand in
package/dist/index.mjs CHANGED
@@ -46,16 +46,16 @@ function formatColor(value, format, fallback = DEFAULT_FALLBACK) {
46
46
  function coerce(value) {
47
47
  if (!value || typeof value !== "object") return null;
48
48
  const v = value;
49
- const colorSpace = typeof v["colorSpace"] === "string" ? v["colorSpace"] : void 0;
50
- const components = Array.isArray(v["components"]) ? v["components"] : Array.isArray(v["channels"]) ? v["channels"] : void 0;
49
+ const colorSpace = typeof v.colorSpace === "string" ? v.colorSpace : void 0;
50
+ const components = Array.isArray(v.components) ? v.components : Array.isArray(v.channels) ? v.channels : void 0;
51
51
  if (!colorSpace || !components) {
52
- if (typeof v["hex"] === "string") return {
52
+ if (typeof v.hex === "string") return {
53
53
  colorSpace: "srgb",
54
- components: hexToComponents(v["hex"])
54
+ components: hexToComponents(v.hex)
55
55
  };
56
56
  return null;
57
57
  }
58
- const alpha = typeof v["alpha"] === "number" ? v["alpha"] : void 0;
58
+ const alpha = typeof v.alpha === "number" ? v.alpha : void 0;
59
59
  const hex = typeof v["hex"] === "string" ? v["hex"] : void 0;
60
60
  return {
61
61
  colorSpace,
@@ -926,11 +926,7 @@ function CopyButton$1({ value, label, variant = "icon", className }) {
926
926
  };
927
927
  }, []);
928
928
  const handleClick = useCallback(() => {
929
- try {
930
- navigator.clipboard?.writeText(value);
931
- } catch {
932
- return;
933
- }
929
+ navigator.clipboard?.writeText(value).catch(() => {});
934
930
  setCopied(true);
935
931
  if (timerRef.current !== null) clearTimeout(timerRef.current);
936
932
  timerRef.current = setTimeout(() => setCopied(false), 1500);
@@ -1658,13 +1654,13 @@ function formatShadow(v, colorFormat) {
1658
1654
  if (!layer || typeof layer !== "object") return formatUnknown(layer);
1659
1655
  const s = layer;
1660
1656
  const pieces = [
1661
- formatDimension$1(s["offsetX"]),
1662
- formatDimension$1(s["offsetY"]),
1663
- formatDimension$1(s["blur"]),
1664
- formatDimension$1(s["spread"]),
1665
- formatColor(s["color"], colorFormat).value
1657
+ formatDimension$1(s.offsetX),
1658
+ formatDimension$1(s.offsetY),
1659
+ formatDimension$1(s.blur),
1660
+ formatDimension$1(s.spread),
1661
+ formatColor(s.color, colorFormat).value
1666
1662
  ].filter((p) => p !== "");
1667
- if (s["inset"]) pieces.push("inset");
1663
+ if (s.inset) pieces.push("inset");
1668
1664
  return pieces.join(" ");
1669
1665
  }).join(", ");
1670
1666
  }
@@ -1672,9 +1668,9 @@ function formatBorder(v, colorFormat) {
1672
1668
  if (!v || typeof v !== "object") return formatUnknown(v);
1673
1669
  const b = v;
1674
1670
  return [
1675
- formatDimension$1(b["width"]),
1676
- formatPrimitive$1(b["style"]),
1677
- formatColor(b["color"], colorFormat).value
1671
+ formatDimension$1(b.width),
1672
+ formatPrimitive$1(b.style),
1673
+ formatColor(b.color, colorFormat).value
1678
1674
  ].filter((p) => p !== "").join(" ");
1679
1675
  }
1680
1676
  function formatUnknown(v) {
@@ -1844,6 +1840,21 @@ function FontFamilySample({ filter, sample = "The quick brown fox jumps over the
1844
1840
  });
1845
1841
  }
1846
1842
  //#endregion
1843
+ //#region src/internal/css-var-style.ts
1844
+ /**
1845
+ * Coerce a CSS `var(--…)` reference into the numeric slot of a React
1846
+ * inline-style property.
1847
+ *
1848
+ * React's `CSSProperties` types unitless properties (`fontWeight`,
1849
+ * `lineHeight`, `zIndex`, …) as `number`. The DOM accepts a string at
1850
+ * runtime — the rendered stylesheet just receives whatever React passes —
1851
+ * so a `var(--font-weight)` reference works functionally. TypeScript still
1852
+ * complains. Centralising the type assertion in one named helper keeps
1853
+ * the gap visible (and greppable) instead of scattering casts across
1854
+ * block components that want CSS-var-driven typography or layout values.
1855
+ */
1856
+ const cssVarAsNumber = (varRef) => varRef;
1857
+ //#endregion
1847
1858
  //#region src/FontWeightScale.tsx
1848
1859
  function toWeight(raw) {
1849
1860
  if (typeof raw === "number") return raw;
@@ -1904,7 +1915,7 @@ function FontWeightScale({ filter, sample = "Aa", caption, sortBy = "value", sor
1904
1915
  }),
1905
1916
  /* @__PURE__ */ jsx("div", {
1906
1917
  className: "sb-font-weight-scale__sample",
1907
- style: { fontWeight: row.cssVar },
1918
+ style: { fontWeight: cssVarAsNumber(row.cssVar) },
1908
1919
  children: sample
1909
1920
  }),
1910
1921
  /* @__PURE__ */ jsx("span", {
@@ -2984,27 +2995,27 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
2984
2995
  return renderKeyValueList([
2985
2996
  [
2986
2997
  "fontFamily",
2987
- formatFontFamily(v["fontFamily"]),
2998
+ formatFontFamily(v.fontFamily),
2988
2999
  aliasFor("fontFamily")
2989
3000
  ],
2990
3001
  [
2991
3002
  "fontSize",
2992
- formatDimensionValue(v["fontSize"]),
3003
+ formatDimensionValue(v.fontSize),
2993
3004
  aliasFor("fontSize")
2994
3005
  ],
2995
3006
  [
2996
3007
  "fontWeight",
2997
- formatPrimitive(v["fontWeight"]),
3008
+ formatPrimitive(v.fontWeight),
2998
3009
  aliasFor("fontWeight")
2999
3010
  ],
3000
3011
  [
3001
3012
  "lineHeight",
3002
- formatPrimitive(v["lineHeight"]),
3013
+ formatPrimitive(v.lineHeight),
3003
3014
  aliasFor("lineHeight")
3004
3015
  ],
3005
3016
  [
3006
3017
  "letterSpacing",
3007
- formatDimensionValue(v["letterSpacing"]),
3018
+ formatDimensionValue(v.letterSpacing),
3008
3019
  aliasFor("letterSpacing")
3009
3020
  ]
3010
3021
  ]);
@@ -3014,17 +3025,17 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
3014
3025
  return renderKeyValueList([
3015
3026
  [
3016
3027
  "color",
3017
- formatColorSubValue(v["color"], colorFormat),
3028
+ formatColorSubValue(v.color, colorFormat),
3018
3029
  aliasFor("color")
3019
3030
  ],
3020
3031
  [
3021
3032
  "width",
3022
- formatDimensionValue(v["width"]),
3033
+ formatDimensionValue(v.width),
3023
3034
  aliasFor("width")
3024
3035
  ],
3025
3036
  [
3026
3037
  "style",
3027
- formatPrimitive(v["style"]),
3038
+ formatPrimitive(v.style),
3028
3039
  aliasFor("style")
3029
3040
  ]
3030
3041
  ]);
@@ -3034,17 +3045,17 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
3034
3045
  return renderKeyValueList([
3035
3046
  [
3036
3047
  "duration",
3037
- formatDimensionValue(v["duration"]),
3048
+ formatDimensionValue(v.duration),
3038
3049
  aliasFor("duration")
3039
3050
  ],
3040
3051
  [
3041
3052
  "timingFunction",
3042
- formatPrimitive(v["timingFunction"]),
3053
+ formatPrimitive(v.timingFunction),
3043
3054
  aliasFor("timingFunction")
3044
3055
  ],
3045
3056
  [
3046
3057
  "delay",
3047
- formatDimensionValue(v["delay"]),
3058
+ formatDimensionValue(v.delay),
3048
3059
  aliasFor("delay")
3049
3060
  ]
3050
3061
  ]);
@@ -3066,32 +3077,32 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
3066
3077
  }),
3067
3078
  /* @__PURE__ */ jsx(KeyValueRow, {
3068
3079
  label: "color",
3069
- value: formatColorSubValue(v["color"], colorFormat),
3080
+ value: formatColorSubValue(v.color, colorFormat),
3070
3081
  alias: layerAliasFor(i, "color")
3071
3082
  }),
3072
3083
  /* @__PURE__ */ jsx(KeyValueRow, {
3073
3084
  label: "offsetX",
3074
- value: formatDimensionValue(v["offsetX"]),
3085
+ value: formatDimensionValue(v.offsetX),
3075
3086
  alias: layerAliasFor(i, "offsetX")
3076
3087
  }),
3077
3088
  /* @__PURE__ */ jsx(KeyValueRow, {
3078
3089
  label: "offsetY",
3079
- value: formatDimensionValue(v["offsetY"]),
3090
+ value: formatDimensionValue(v.offsetY),
3080
3091
  alias: layerAliasFor(i, "offsetY")
3081
3092
  }),
3082
3093
  /* @__PURE__ */ jsx(KeyValueRow, {
3083
3094
  label: "blur",
3084
- value: formatDimensionValue(v["blur"]),
3095
+ value: formatDimensionValue(v.blur),
3085
3096
  alias: layerAliasFor(i, "blur")
3086
3097
  }),
3087
3098
  /* @__PURE__ */ jsx(KeyValueRow, {
3088
3099
  label: "spread",
3089
- value: formatDimensionValue(v["spread"]),
3100
+ value: formatDimensionValue(v.spread),
3090
3101
  alias: layerAliasFor(i, "spread")
3091
3102
  }),
3092
- "inset" in v && /* @__PURE__ */ jsx(KeyValueRow, {
3103
+ v.inset !== void 0 && /* @__PURE__ */ jsx(KeyValueRow, {
3093
3104
  label: "inset",
3094
- value: formatPrimitive(v["inset"]),
3105
+ value: formatPrimitive(v.inset),
3095
3106
  alias: void 0
3096
3107
  })
3097
3108
  ]
@@ -3108,8 +3119,8 @@ function CompositeBreakdownContent({ type, rawValue, partialAliasOf, resolved, c
3108
3119
  children: stops.map((stop, i) => {
3109
3120
  const v = stop;
3110
3121
  return /* @__PURE__ */ jsx(KeyValueRow, {
3111
- label: `${((typeof v["position"] === "number" ? v["position"] : 0) * 100).toFixed(0)}%`,
3112
- value: formatColorSubValue(v["color"], colorFormat),
3122
+ label: `${((typeof v.position === "number" ? v.position : 0) * 100).toFixed(0)}%`,
3123
+ value: formatColorSubValue(v.color, colorFormat),
3113
3124
  alias: stopAliasFor(i)
3114
3125
  }, gradientStopKey(v, i));
3115
3126
  })
@@ -3200,16 +3211,16 @@ function subValueChain(aliasTarget, resolved) {
3200
3211
  }
3201
3212
  function shadowLayerKey(layer, fallback) {
3202
3213
  return `shadow|${[
3203
- layer["color"],
3204
- layer["offsetX"],
3205
- layer["offsetY"],
3206
- layer["blur"],
3207
- layer["spread"],
3208
- layer["inset"]
3214
+ layer.color,
3215
+ layer.offsetX,
3216
+ layer.offsetY,
3217
+ layer.blur,
3218
+ layer.spread,
3219
+ layer.inset
3209
3220
  ].map((p) => p === void 0 ? "" : JSON.stringify(p)).join("|")}|${fallback}`;
3210
3221
  }
3211
3222
  function gradientStopKey(stop, fallback) {
3212
- return `stop|${stop["position"] ?? fallback}|${JSON.stringify(stop["color"])}`;
3223
+ return `stop|${stop.position ?? fallback}|${JSON.stringify(stop.color)}`;
3213
3224
  }
3214
3225
  //#endregion
3215
3226
  //#region src/token-detail/CompositePreview.tsx
@@ -3241,8 +3252,8 @@ function CompositePreviewContent({ type, cssVar, rawValue }) {
3241
3252
  style: {
3242
3253
  fontFamily: `var(${base}-font-family)`,
3243
3254
  fontSize: `var(${base}-font-size)`,
3244
- fontWeight: `var(${base}-font-weight)`,
3245
- lineHeight: `var(${base}-line-height)`,
3255
+ fontWeight: cssVarAsNumber(`var(${base}-font-weight)`),
3256
+ lineHeight: cssVarAsNumber(`var(${base}-line-height)`),
3246
3257
  letterSpacing: `var(${base}-letter-spacing)`
3247
3258
  },
3248
3259
  children: PANGRAM
@@ -3275,7 +3286,7 @@ function CompositePreviewContent({ type, cssVar, rawValue }) {
3275
3286
  });
3276
3287
  if (type === "fontWeight") return /* @__PURE__ */ jsx("div", {
3277
3288
  className: "sb-token-detail__font-weight-sample",
3278
- style: { fontWeight: cssVar },
3289
+ style: { fontWeight: cssVarAsNumber(cssVar) },
3279
3290
  children: "Aa"
3280
3291
  });
3281
3292
  if (type === "cubicBezier") return /* @__PURE__ */ jsx(TransitionSample, { transition: `left 800ms ${cssVar}` });
@@ -3585,7 +3596,30 @@ function TokenDetail({ path, heading }) {
3585
3596
  }
3586
3597
  //#endregion
3587
3598
  //#region src/internal/DetailOverlay.tsx
3599
+ /**
3600
+ * Selector for elements the trap considers focus stops. Mirrors the
3601
+ * "tabbable" set most focus-trap libraries use; the `:not(...)` clauses
3602
+ * skip the panel wrapper itself (we focus it manually on mount via its
3603
+ * own ref) and any explicitly-detabbed descendants.
3604
+ */
3605
+ const FOCUSABLE_SELECTOR = [
3606
+ "a[href]",
3607
+ "button:not([disabled])",
3608
+ "input:not([disabled])",
3609
+ "select:not([disabled])",
3610
+ "textarea:not([disabled])",
3611
+ "[tabindex]:not([tabindex=\"-1\"])"
3612
+ ].join(", ");
3588
3613
  function DetailOverlay({ path, onClose, testId = "swatchbook-overlay" }) {
3614
+ const panelRef = useRef(null);
3615
+ const openerRef = useRef(null);
3616
+ useEffect(() => {
3617
+ openerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
3618
+ panelRef.current?.focus();
3619
+ return () => {
3620
+ openerRef.current?.focus();
3621
+ };
3622
+ }, []);
3589
3623
  useEffect(() => {
3590
3624
  const onKey = (e) => {
3591
3625
  if (e.key === "Escape") onClose();
@@ -3593,17 +3627,48 @@ function DetailOverlay({ path, onClose, testId = "swatchbook-overlay" }) {
3593
3627
  window.addEventListener("keydown", onKey);
3594
3628
  return () => window.removeEventListener("keydown", onKey);
3595
3629
  }, [onClose]);
3630
+ /**
3631
+ * Wrap Tab inside the panel: from the last focusable, jump to the first;
3632
+ * from the first (or from the panel itself), Shift+Tab jumps to the last.
3633
+ * Defers to the browser otherwise.
3634
+ */
3635
+ const onPanelKeyDown = (e) => {
3636
+ if (e.key !== "Tab") return;
3637
+ const panel = panelRef.current;
3638
+ if (!panel) return;
3639
+ const focusables = panel.querySelectorAll(FOCUSABLE_SELECTOR);
3640
+ if (focusables.length === 0) {
3641
+ e.preventDefault();
3642
+ return;
3643
+ }
3644
+ const first = focusables[0];
3645
+ const last = focusables[focusables.length - 1];
3646
+ const active = document.activeElement;
3647
+ if (!first || !last) return;
3648
+ if (e.shiftKey) {
3649
+ if (active === first || active === panel) {
3650
+ e.preventDefault();
3651
+ last.focus();
3652
+ }
3653
+ } else if (active === last) {
3654
+ e.preventDefault();
3655
+ first.focus();
3656
+ }
3657
+ };
3596
3658
  return /* @__PURE__ */ jsx("div", {
3597
3659
  className: "sb-detail-overlay__backdrop",
3598
3660
  onClick: onClose,
3599
3661
  role: "presentation",
3600
3662
  "data-testid": testId,
3601
3663
  children: /* @__PURE__ */ jsxs("div", {
3664
+ ref: panelRef,
3602
3665
  className: "sb-detail-overlay__panel",
3603
3666
  onClick: (e) => e.stopPropagation(),
3667
+ onKeyDown: onPanelKeyDown,
3604
3668
  role: "dialog",
3605
3669
  "aria-modal": "true",
3606
3670
  "aria-label": `Token detail for ${path}`,
3671
+ tabIndex: -1,
3607
3672
  children: [/* @__PURE__ */ jsx("button", {
3608
3673
  type: "button",
3609
3674
  className: "sb-detail-overlay__close",
@@ -3692,6 +3757,16 @@ function collectLeafPaths(nodes, out) {
3692
3757
  for (const node of nodes) if (node.kind === "leaf") out.push(node.path);
3693
3758
  else collectLeafPaths(node.children, out);
3694
3759
  }
3760
+ function flattenVisible(nodes, expanded, parentPath, out) {
3761
+ for (const node of nodes) {
3762
+ out.push({
3763
+ path: node.path,
3764
+ kind: node.kind,
3765
+ parentPath
3766
+ });
3767
+ if (node.kind === "group" && expanded.has(node.path)) flattenVisible(node.children, expanded, node.path, out);
3768
+ }
3769
+ }
3695
3770
  /**
3696
3771
  * Return a pruned copy of the tree keeping only leaves whose path is in
3697
3772
  * `matches`, plus the groups on the way to them. Every surviving group's
@@ -3771,6 +3846,125 @@ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true,
3771
3846
  if (onSelect) onSelect(path);
3772
3847
  else setSelectedPath(path);
3773
3848
  }, [onSelect]);
3849
+ const [focusedPath, setFocusedPath] = useState(null);
3850
+ const treeItemRefs = useRef(/* @__PURE__ */ new Map());
3851
+ const registerTreeItem = useCallback((path) => (el) => {
3852
+ if (el) treeItemRefs.current.set(path, el);
3853
+ else treeItemRefs.current.delete(path);
3854
+ }, []);
3855
+ const flatVisible = useMemo(() => {
3856
+ const out = [];
3857
+ flattenVisible(visibleTree, effectiveExpanded, null, out);
3858
+ return out;
3859
+ }, [visibleTree, effectiveExpanded]);
3860
+ useEffect(() => {
3861
+ if (flatVisible.length === 0) {
3862
+ setFocusedPath(null);
3863
+ return;
3864
+ }
3865
+ setFocusedPath((prev) => {
3866
+ if (prev && flatVisible.some((entry) => entry.path === prev)) return prev;
3867
+ return flatVisible[0]?.path ?? null;
3868
+ });
3869
+ }, [flatVisible]);
3870
+ const focusByPath = useCallback((path) => {
3871
+ const node = treeItemRefs.current.get(path);
3872
+ if (node) {
3873
+ node.focus();
3874
+ setFocusedPath(path);
3875
+ } else setFocusedPath(path);
3876
+ }, []);
3877
+ useEffect(() => {
3878
+ if (focusedPath === null) return;
3879
+ const node = treeItemRefs.current.get(focusedPath);
3880
+ if (node && document.activeElement !== node) {
3881
+ const active = document.activeElement;
3882
+ if (active instanceof HTMLElement && active.closest("[role=\"tree\"]")) node.focus();
3883
+ }
3884
+ }, [focusedPath]);
3885
+ const handleTreeKeyDown = useCallback((e) => {
3886
+ if (flatVisible.length === 0) return;
3887
+ const active = document.activeElement;
3888
+ if (!(active instanceof HTMLLIElement)) return;
3889
+ const activePath = active.getAttribute("data-path");
3890
+ if (activePath === null) return;
3891
+ const currentIndex = flatVisible.findIndex((entry) => entry.path === activePath);
3892
+ if (currentIndex < 0) return;
3893
+ const current = flatVisible[currentIndex];
3894
+ if (!current) return;
3895
+ switch (e.key) {
3896
+ case "ArrowDown": {
3897
+ const next = flatVisible[currentIndex + 1];
3898
+ if (next) {
3899
+ e.preventDefault();
3900
+ focusByPath(next.path);
3901
+ }
3902
+ return;
3903
+ }
3904
+ case "ArrowUp": {
3905
+ const prev = flatVisible[currentIndex - 1];
3906
+ if (prev) {
3907
+ e.preventDefault();
3908
+ focusByPath(prev.path);
3909
+ }
3910
+ return;
3911
+ }
3912
+ case "Home": {
3913
+ const first = flatVisible[0];
3914
+ if (first) {
3915
+ e.preventDefault();
3916
+ focusByPath(first.path);
3917
+ }
3918
+ return;
3919
+ }
3920
+ case "End": {
3921
+ const last = flatVisible[flatVisible.length - 1];
3922
+ if (last) {
3923
+ e.preventDefault();
3924
+ focusByPath(last.path);
3925
+ }
3926
+ return;
3927
+ }
3928
+ case "ArrowRight":
3929
+ if (current.kind === "group") {
3930
+ if (!effectiveExpanded.has(current.path)) {
3931
+ e.preventDefault();
3932
+ toggle(current.path);
3933
+ return;
3934
+ }
3935
+ const firstChild = flatVisible[currentIndex + 1];
3936
+ if (firstChild && firstChild.parentPath === current.path) {
3937
+ e.preventDefault();
3938
+ focusByPath(firstChild.path);
3939
+ }
3940
+ }
3941
+ return;
3942
+ case "ArrowLeft":
3943
+ if (current.kind === "group" && effectiveExpanded.has(current.path)) {
3944
+ e.preventDefault();
3945
+ toggle(current.path);
3946
+ return;
3947
+ }
3948
+ if (current.parentPath !== null) {
3949
+ e.preventDefault();
3950
+ focusByPath(current.parentPath);
3951
+ }
3952
+ return;
3953
+ case "Enter":
3954
+ case " ":
3955
+ e.preventDefault();
3956
+ if (current.kind === "group") toggle(current.path);
3957
+ else handleLeafClick(current.path);
3958
+ return;
3959
+ default: return;
3960
+ }
3961
+ }, [
3962
+ flatVisible,
3963
+ effectiveExpanded,
3964
+ toggle,
3965
+ focusByPath,
3966
+ handleLeafClick
3967
+ ]);
3774
3968
  const typeLabel = typeFilter ? ` · ${[...typeFilter].map((t) => `$type=${t}`).join(", ")}` : "";
3775
3969
  const trimmedQuery = query.trim();
3776
3970
  const matchCount = useMemo(() => {
@@ -3819,10 +4013,15 @@ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true,
3819
4013
  }) : /* @__PURE__ */ jsx("ul", {
3820
4014
  className: "sb-token-navigator__tree",
3821
4015
  role: "tree",
4016
+ "aria-label": "Token graph",
4017
+ onKeyDown: handleTreeKeyDown,
3822
4018
  children: visibleTree.map((node) => /* @__PURE__ */ jsx(TreeNodeRow, {
3823
4019
  node,
3824
4020
  expanded: effectiveExpanded,
4021
+ focusedPath,
4022
+ registerTreeItem,
3825
4023
  onToggle: toggle,
4024
+ onFocusPath: setFocusedPath,
3826
4025
  onLeafClick: handleLeafClick
3827
4026
  }, node.path || node.segment))
3828
4027
  }),
@@ -3834,29 +4033,31 @@ function TokenNavigator({ root, type, initiallyExpanded = 1, searchable = true,
3834
4033
  ]
3835
4034
  });
3836
4035
  }
3837
- function TreeNodeRow({ node, expanded, onToggle, onLeafClick }) {
4036
+ function TreeNodeRow({ node, expanded, focusedPath, registerTreeItem, onToggle, onFocusPath, onLeafClick }) {
3838
4037
  if (node.kind === "leaf") return /* @__PURE__ */ jsx(LeafRow, {
3839
4038
  node,
4039
+ focusedPath,
4040
+ registerTreeItem,
4041
+ onFocusPath,
3840
4042
  onLeafClick
3841
4043
  });
3842
4044
  const isOpen = expanded.has(node.path);
3843
- const onKey = (e) => {
3844
- if (e.key === "Enter" || e.key === " ") {
3845
- e.preventDefault();
3846
- onToggle(node.path);
3847
- }
3848
- };
4045
+ const isFocused = focusedPath === node.path;
3849
4046
  return /* @__PURE__ */ jsxs("li", {
4047
+ ref: registerTreeItem(node.path),
3850
4048
  role: "treeitem",
3851
4049
  "aria-expanded": isOpen,
4050
+ tabIndex: isFocused ? 0 : -1,
4051
+ onFocus: () => onFocusPath(node.path),
4052
+ "data-path": node.path,
4053
+ "data-testid": "token-navigator-group",
3852
4054
  children: [/* @__PURE__ */ jsxs("div", {
3853
- role: "button",
3854
- tabIndex: 0,
3855
4055
  className: "sb-token-navigator__group-row",
3856
- onClick: () => onToggle(node.path),
3857
- onKeyDown: onKey,
3858
- "data-path": node.path,
3859
- "data-testid": "token-navigator-group",
4056
+ "data-testid": "token-navigator-group-row",
4057
+ onClick: () => {
4058
+ onFocusPath(node.path);
4059
+ onToggle(node.path);
4060
+ },
3860
4061
  children: [
3861
4062
  /* @__PURE__ */ jsx("span", {
3862
4063
  className: "sb-token-navigator__caret",
@@ -3875,30 +4076,32 @@ function TreeNodeRow({ node, expanded, onToggle, onLeafClick }) {
3875
4076
  children: node.children.map((c) => /* @__PURE__ */ jsx(TreeNodeRow, {
3876
4077
  node: c,
3877
4078
  expanded,
4079
+ focusedPath,
4080
+ registerTreeItem,
3878
4081
  onToggle,
4082
+ onFocusPath,
3879
4083
  onLeafClick
3880
4084
  }, c.path || c.segment))
3881
4085
  })]
3882
4086
  });
3883
4087
  }
3884
- function LeafRow({ node, onLeafClick }) {
3885
- const onKey = (e) => {
3886
- if (e.key === "Enter" || e.key === " ") {
3887
- e.preventDefault();
3888
- onLeafClick(node.path);
3889
- }
3890
- };
4088
+ function LeafRow({ node, focusedPath, registerTreeItem, onFocusPath, onLeafClick }) {
3891
4089
  const type = node.token.$type ?? "";
4090
+ const isFocused = focusedPath === node.path;
3892
4091
  return /* @__PURE__ */ jsx("li", {
4092
+ ref: registerTreeItem(node.path),
3893
4093
  role: "treeitem",
4094
+ tabIndex: isFocused ? 0 : -1,
4095
+ onFocus: () => onFocusPath(node.path),
4096
+ "data-path": node.path,
4097
+ "data-testid": "token-navigator-leaf",
3894
4098
  children: /* @__PURE__ */ jsxs("div", {
3895
- role: "button",
3896
- tabIndex: 0,
3897
4099
  className: "sb-token-navigator__leaf-row",
3898
- onClick: () => onLeafClick(node.path),
3899
- onKeyDown: onKey,
3900
- "data-path": node.path,
3901
- "data-testid": "token-navigator-leaf",
4100
+ "data-testid": "token-navigator-leaf-row",
4101
+ onClick: () => {
4102
+ onFocusPath(node.path);
4103
+ onLeafClick(node.path);
4104
+ },
3902
4105
  children: [
3903
4106
  /* @__PURE__ */ jsx("span", {
3904
4107
  className: "sb-token-navigator__caret",
@@ -4150,7 +4353,7 @@ function asDimension(raw) {
4150
4353
  if (typeof raw === "string" || typeof raw === "number") return String(raw);
4151
4354
  if (typeof raw === "object") {
4152
4355
  const v = raw;
4153
- if ("value" in v && "unit" in v) return `${String(v["value"])}${String(v["unit"])}`;
4356
+ if (v.value !== void 0 && v.unit !== void 0) return `${String(v.value)}${String(v.unit)}`;
4154
4357
  }
4155
4358
  }
4156
4359
  function asFontFamily(raw) {
@@ -4158,11 +4361,11 @@ function asFontFamily(raw) {
4158
4361
  if (Array.isArray(raw)) return raw.map(String).join(", ");
4159
4362
  }
4160
4363
  function buildRow(path, composite) {
4161
- const fontFamily = asFontFamily(composite["fontFamily"]);
4162
- const fontSize = asDimension(composite["fontSize"]);
4163
- const fontWeight = composite["fontWeight"] == null ? void 0 : String(composite["fontWeight"]);
4164
- const lineHeight = composite["lineHeight"] == null ? void 0 : String(composite["lineHeight"]);
4165
- const letterSpacing = asDimension(composite["letterSpacing"]);
4364
+ const fontFamily = asFontFamily(composite.fontFamily);
4365
+ const fontSize = asDimension(composite.fontSize);
4366
+ const fontWeight = composite.fontWeight == null ? void 0 : String(composite.fontWeight);
4367
+ const lineHeight = composite.lineHeight == null ? void 0 : String(composite.lineHeight);
4368
+ const letterSpacing = asDimension(composite.letterSpacing);
4166
4369
  const sampleStyle = {};
4167
4370
  if (fontFamily) sampleStyle.fontFamily = fontFamily;
4168
4371
  if (fontSize) sampleStyle.fontSize = fontSize;