@motion-proto/live-tokens 0.97.0 → 0.98.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.
Files changed (184) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +5 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +4 -3
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +71 -11
  4. package/CHANGELOG.md +272 -0
  5. package/README.md +2 -2
  6. package/bin/check-component.mjs +8 -0
  7. package/bin/check-page.mjs +6 -0
  8. package/bin/cli.mjs +36 -6
  9. package/bin/contractRunner.mjs +142 -17
  10. package/bin/lib/findings.mjs +20 -4
  11. package/bin/rules/a11y.mjs +53 -0
  12. package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
  13. package/dist-plugin/index.cjs +148 -3
  14. package/dist-plugin/index.js +250 -105
  15. package/dist-plugin/setColors/index.js +1 -1
  16. package/package.json +47 -2
  17. package/src/demo/LabeledSelect.svelte +1 -1
  18. package/src/editor/bootstrap.ts +3 -1
  19. package/src/editor/component-editor/BadgeEditor.svelte +13 -0
  20. package/src/editor/component-editor/ButtonEditor.svelte +33 -2
  21. package/src/editor/component-editor/CalloutEditor.svelte +13 -0
  22. package/src/editor/component-editor/CardEditor.svelte +12 -2
  23. package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
  24. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
  25. package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
  26. package/src/editor/component-editor/DialogEditor.svelte +15 -0
  27. package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
  28. package/src/editor/component-editor/ImageEditor.svelte +20 -9
  29. package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
  30. package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
  31. package/src/editor/component-editor/InputEditor.svelte +23 -0
  32. package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
  33. package/src/editor/component-editor/NotificationEditor.svelte +15 -0
  34. package/src/editor/component-editor/PanelEditor.svelte +1 -1
  35. package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
  36. package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
  37. package/src/editor/component-editor/SectionDividerEditor.svelte +41 -13
  38. package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
  39. package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
  40. package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
  41. package/src/editor/component-editor/SliderEditor.svelte +33 -0
  42. package/src/editor/component-editor/TabBarEditor.svelte +23 -2
  43. package/src/editor/component-editor/TableEditor.svelte +14 -0
  44. package/src/editor/component-editor/ToggleEditor.svelte +17 -0
  45. package/src/editor/component-editor/TooltipEditor.svelte +11 -2
  46. package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
  47. package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
  48. package/src/editor/component-editor/registration.ts +2 -1
  49. package/src/editor/component-editor/registry.ts +69 -27
  50. package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
  51. package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
  52. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
  53. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -3
  54. package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
  55. package/src/editor/core/a11y/a11yReport.ts +101 -0
  56. package/src/editor/core/a11y/a11yReportTypes.ts +69 -0
  57. package/src/editor/core/a11y/contrastFailures.ts +22 -0
  58. package/src/editor/core/components/contrastPairRegistry.ts +18 -0
  59. package/src/editor/core/palettes/contrast.ts +44 -0
  60. package/src/editor/core/palettes/contrastPairs.ts +79 -0
  61. package/src/editor/core/palettes/resolveColor.ts +77 -0
  62. package/src/editor/core/store/editorStore.ts +4 -0
  63. package/src/editor/core/store/editorViewStore.ts +2 -2
  64. package/src/editor/core/store/unsavedEdits.ts +10 -0
  65. package/src/editor/core/themes/liveStateStream.ts +7 -1
  66. package/src/editor/core/themes/parsers/gradient.ts +5 -2
  67. package/src/editor/docs/chapters.ts +1 -0
  68. package/src/editor/docs/content/accessibility.md +362 -0
  69. package/src/editor/docs/content/editing-tokens.md +28 -1
  70. package/src/editor/docs/content.generated.ts +2 -1
  71. package/src/editor/pages/ComponentEditorPage.svelte +10 -1
  72. package/src/editor/pages/EditorShell.svelte +96 -10
  73. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  74. package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
  75. package/src/editor/skill-atlas/trees/create-component.ts +12 -12
  76. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  77. package/src/editor/ui/GradientEditor.svelte +9 -1
  78. package/src/editor/ui/ThemePanel.svelte +4 -4
  79. package/src/editor/ui/UIContrastReadout.svelte +186 -0
  80. package/src/editor/ui/UIInfoPopover.svelte +16 -2
  81. package/src/editor/ui/UIPaletteSelector.svelte +9 -0
  82. package/src/editor/ui/UITokenSelector.svelte +26 -2
  83. package/src/editor/ui/a11y/A11yComponentIssues.svelte +103 -0
  84. package/src/editor/ui/a11y/A11yFindingRow.svelte +57 -0
  85. package/src/editor/ui/a11y/A11yIssueRow.svelte +121 -0
  86. package/src/editor/ui/a11y/A11yOverview.svelte +242 -0
  87. package/src/editor/ui/a11y/A11yPanel.svelte +128 -0
  88. package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
  89. package/src/editor/ui/a11y/A11yRailMark.svelte +34 -0
  90. package/src/editor/ui/a11y/AccessibilityTab.svelte +334 -0
  91. package/src/editor/ui/a11y/a11yView.ts +218 -0
  92. package/src/editor/ui/contrastReadout.ts +45 -0
  93. package/src/live-tokens/data/themes/autumn.json +20 -20
  94. package/src/live-tokens/data/themes/halloween.json +20 -20
  95. package/src/live-tokens/data/themes/midnight-study.json +20 -20
  96. package/src/live-tokens/data/themes/ocean.json +20 -20
  97. package/src/live-tokens/data/themes/royal-velvet.json +20 -20
  98. package/src/live-tokens/data/themes/sketchy.json +20 -20
  99. package/src/live-tokens/data/themes/spring-meadow.json +20 -20
  100. package/src/live-tokens/data/themes/sunset.json +20 -20
  101. package/src/system/components/Badge.svelte +3 -3
  102. package/src/system/components/Button.svelte +9 -3
  103. package/src/system/components/CodeSnippet.svelte +8 -1
  104. package/src/system/components/CornerBadge.svelte +3 -3
  105. package/src/system/components/Dialog.svelte +43 -14
  106. package/src/system/components/IconButton.svelte +1 -1
  107. package/src/system/components/Image.svelte +7 -0
  108. package/src/system/components/ImageLightbox.svelte +18 -25
  109. package/src/system/components/Input.svelte +8 -1
  110. package/src/system/components/MenuSelect.svelte +30 -2
  111. package/src/system/components/Notification.svelte +1 -1
  112. package/src/system/components/ProgressBar.svelte +6 -0
  113. package/src/system/components/RadioButton.svelte +36 -3
  114. package/src/system/components/SegmentedControl.svelte +36 -2
  115. package/src/system/components/SlidePager.svelte +2 -2
  116. package/src/system/components/Slider.svelte +13 -9
  117. package/src/system/components/TabBar.svelte +33 -3
  118. package/src/system/components/Toggle.svelte +2 -2
  119. package/src/system/components/VideoFrame.svelte +1 -1
  120. package/src/system/components/VideoLightbox.svelte +2 -2
  121. package/src/system/internal/focusTrap.ts +35 -0
  122. package/src/testing-js/__aria__/badge.yml +40 -0
  123. package/src/testing-js/__aria__/button.yml +88 -0
  124. package/src/testing-js/__aria__/callout.yml +12 -0
  125. package/src/testing-js/__aria__/card.yml +8 -0
  126. package/src/testing-js/__aria__/codesnippet.yml +21 -0
  127. package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
  128. package/src/testing-js/__aria__/cornerbadge.yml +24 -0
  129. package/src/testing-js/__aria__/dialog.yml +42 -0
  130. package/src/testing-js/__aria__/iconbutton.yml +57 -0
  131. package/src/testing-js/__aria__/image.yml +10 -0
  132. package/src/testing-js/__aria__/imagelightbox.yml +50 -0
  133. package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
  134. package/src/testing-js/__aria__/input.yml +61 -0
  135. package/src/testing-js/__aria__/menuselect.yml +30 -0
  136. package/src/testing-js/__aria__/notification.yml +30 -0
  137. package/src/testing-js/__aria__/panel.yml +2 -0
  138. package/src/testing-js/__aria__/progressbar.yml +2 -0
  139. package/src/testing-js/__aria__/radiobutton.yml +20 -0
  140. package/src/testing-js/__aria__/sectiondivider.yml +9 -0
  141. package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
  142. package/src/testing-js/__aria__/sidenavigation.yml +304 -0
  143. package/src/testing-js/__aria__/slidepager.yml +72 -0
  144. package/src/testing-js/__aria__/slider.yml +28 -0
  145. package/src/testing-js/__aria__/tabbar.yml +35 -0
  146. package/src/testing-js/__aria__/table.yml +144 -0
  147. package/src/testing-js/__aria__/toggle.yml +12 -0
  148. package/src/testing-js/__aria__/tooltip.yml +2 -0
  149. package/src/testing-js/__aria__/videoframe.yml +6 -0
  150. package/src/testing-js/__aria__/videolightbox.yml +12 -0
  151. package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
  152. package/src/testing-js/{chunk-SHBYZUPD.js → chunk-5HNZHIHG.js} +211 -14
  153. package/src/testing-js/chunk-5HNZHIHG.js.map +1 -0
  154. package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
  155. package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
  156. package/src/testing-js/chunk-EWSGE27V.js +168 -0
  157. package/src/testing-js/chunk-EWSGE27V.js.map +1 -0
  158. package/src/testing-js/{chunk-GKO27NGN.js → chunk-R54I7YL6.js} +2 -2
  159. package/src/testing-js/{chunk-X6KHEVIG.js → chunk-RWH46IU5.js} +97 -29
  160. package/src/testing-js/chunk-RWH46IU5.js.map +1 -0
  161. package/src/testing-js/chunk-SHLPDNAQ.js +658 -0
  162. package/src/testing-js/chunk-SHLPDNAQ.js.map +1 -0
  163. package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
  164. package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
  165. package/src/testing-js/component-a11y.contract.js +283 -0
  166. package/src/testing-js/component-a11y.contract.js.map +1 -0
  167. package/src/testing-js/component-alias.contract.js +2 -2
  168. package/src/testing-js/component-behavior.contract.js +2 -2
  169. package/src/testing-js/component-editor.contract.js +6 -5
  170. package/src/testing-js/component-editor.contract.js.map +1 -1
  171. package/src/testing-js/component-render.contract.js +10 -6
  172. package/src/testing-js/component-render.contract.js.map +1 -1
  173. package/src/testing-js/index.d.ts +44 -16
  174. package/src/testing-js/index.js +9 -9
  175. package/src/testing-js/page-compliance.contract.js +132 -13
  176. package/src/testing-js/page-compliance.contract.js.map +1 -1
  177. package/src/testing-js/vitest.js +3 -3
  178. package/template/package.json +9 -0
  179. package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
  180. package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
  181. package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
  182. package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
  183. /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
  184. /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-R54I7YL6.js.map} +0 -0
@@ -21,6 +21,7 @@
21
21
  <script lang="ts">
22
22
  import { onMount, tick, type Snippet } from 'svelte';
23
23
  import { portal } from '../internal/portal';
24
+ import { trapTab } from '../internal/focusTrap';
24
25
 
25
26
  interface GalleryImage {
26
27
  src: string;
@@ -45,6 +46,8 @@
45
46
  thumbSrc?: string | undefined;
46
47
  /** Closed-thumbnail object-fit. `cover` crops the thumbnail to fill its box;
47
48
  the expanded modal always uses `contain` so the whole image stays visible.
49
+ Unset, it follows the theme's `--imagelightbox-tile-object-fit`, which
50
+ defaults to `contain`.
48
51
  `cover` only crops when the thumbnail box differs from the image's shape
49
52
  (a `thumbAspect`, or a CSS-constrained container). */
50
53
  fit?: 'contain' | 'cover';
@@ -111,7 +114,7 @@
111
114
  maxWidth = undefined,
112
115
  thumbAspect = undefined,
113
116
  thumbSrc = undefined,
114
- fit = 'contain',
117
+ fit = undefined,
115
118
  shadow = 'box',
116
119
  surface = undefined,
117
120
  border = undefined,
@@ -607,29 +610,12 @@
607
610
  if (!isGallery && scale <= MIN_SCALE) closeLightbox();
608
611
  }
609
612
 
610
- // Keep Tab inside the open dialog. The stage (tabindex -1) is excluded, so an
611
- // image-only lightbox holds focus on its close button.
612
- function trapTab(e: KeyboardEvent) {
613
- if (!modalEl) return;
614
- const f = [
615
- ...modalEl.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'),
616
- ];
617
- if (f.length === 0) {
618
- e.preventDefault();
619
- stageEl?.focus();
620
- return;
621
- }
622
- const first = f[0];
623
- const last = f[f.length - 1];
624
- const active = document.activeElement as HTMLElement | null;
625
- const inside = active && modalEl.contains(active);
626
- if (e.shiftKey && (!inside || active === first)) {
627
- e.preventDefault();
628
- last.focus();
629
- } else if (!e.shiftKey && (!inside || active === last)) {
630
- e.preventDefault();
631
- first.focus();
632
- }
613
+ // The stage (tabindex -1) is no tab stop, so an image-only lightbox holds
614
+ // focus on its close button.
615
+ function onTab(e: KeyboardEvent) {
616
+ if (!modalEl || trapTab(modalEl, e)) return;
617
+ e.preventDefault();
618
+ stageEl?.focus();
633
619
  }
634
620
 
635
621
  onMount(() => {
@@ -638,7 +624,7 @@
638
624
  if (e.key === 'Escape') {
639
625
  closeLightbox();
640
626
  } else if (e.key === 'Tab') {
641
- trapTab(e);
627
+ onTab(e);
642
628
  } else if (isGallery && e.key === 'ArrowRight') {
643
629
  e.preventDefault();
644
630
  next();
@@ -920,6 +906,13 @@
920
906
  transition: none;
921
907
  }
922
908
 
909
+ @media (prefers-reduced-motion: reduce) {
910
+ .image-lightbox-thumb,
911
+ .image-lightbox-attached {
912
+ transition: none;
913
+ }
914
+ }
915
+
923
916
  .image-lightbox-thumb:focus-visible {
924
917
  outline: 2px solid var(--border-brand-medium);
925
918
  outline-offset: 2px;
@@ -165,7 +165,7 @@
165
165
 
166
166
  /* Default */
167
167
  --input-default-surface: var(--surface-neutral-lower);
168
- --input-default-border: var(--border-neutral);
168
+ --input-default-border: var(--border-neutral-medium);
169
169
  --input-default-text: var(--text-primary);
170
170
  --input-default-text-font-family: var(--font-sans);
171
171
  --input-default-text-font-size: var(--font-size-sm);
@@ -305,6 +305,13 @@
305
305
  outline: var(--input-error-border-width) solid var(--input-error-border);
306
306
  }
307
307
 
308
+ /* The red outline stays put on focus, so focus adds a neutral ring outside
309
+ it. The spread covers the outline's width, which paints over the shadow. */
310
+ .input-field.invalid .input-control:focus,
311
+ .input-field.invalid.force-focus .input-control {
312
+ box-shadow: 0 0 0 calc(var(--input-error-border-width) + var(--border-width-2)) var(--border-neutral-strong);
313
+ }
314
+
308
315
  .input-icon {
309
316
  color: var(--input-default-icon);
310
317
  font-size: var(--input-default-icon-size);
@@ -40,6 +40,8 @@
40
40
  minWidth?: string;
41
41
  /** ARIA role on the wrapper. `listbox` for select-like menus; `menu` for command menus. */
42
42
  role?: 'listbox' | 'menu';
43
+ /** The list's accessible name, such as the text of the Button that opens it. */
44
+ ariaLabel?: string;
43
45
  /** Selection callback. */
44
46
  onchange?: (value: string) => void;
45
47
  }
@@ -51,6 +53,7 @@
51
53
  forceHoverValue = null,
52
54
  minWidth = undefined,
53
55
  role = 'listbox',
56
+ ariaLabel = undefined,
54
57
  onchange,
55
58
  }: Props = $props();
56
59
 
@@ -67,16 +70,38 @@
67
70
  onchange?.(v);
68
71
  dispatch('change', v);
69
72
  }
73
+
74
+ const buttons: HTMLButtonElement[] = $state([]);
75
+
76
+ // Roving tab stop: the list takes one Tab press, on the selected item.
77
+ let tabStop = $derived(
78
+ items.find((i) => i.value === value && !i.disabled)?.value ?? items.find((i) => !i.disabled)?.value,
79
+ );
80
+
81
+ function moveFocus(event: KeyboardEvent, from: number) {
82
+ const enabled = items.flatMap((item, i) => (item.disabled ? [] : [i]));
83
+ const at = enabled.indexOf(from);
84
+ const to = {
85
+ ArrowDown: enabled[(at + 1) % enabled.length],
86
+ ArrowUp: enabled[(at - 1 + enabled.length) % enabled.length],
87
+ Home: enabled[0],
88
+ End: enabled[enabled.length - 1],
89
+ }[event.key];
90
+ if (to === undefined) return;
91
+ event.preventDefault();
92
+ buttons[to].focus();
93
+ }
70
94
  </script>
71
95
 
72
96
  <ul
73
97
  style:--menuselect-hover-tint-enabled={hoverTintValue}
74
98
  class="menuselect"
75
99
  {role}
100
+ aria-label={ariaLabel}
76
101
  style:min-width={minWidth}
77
102
  >
78
- {#each items as item (item.value)}
79
- <li>
103
+ {#each items as item, i (item.value)}
104
+ <li role="none">
80
105
  <button
81
106
  type="button"
82
107
  class="menuselect-item"
@@ -85,7 +110,10 @@
85
110
  disabled={item.disabled}
86
111
  role={role === 'listbox' ? 'option' : 'menuitem'}
87
112
  aria-selected={role === 'listbox' ? value === item.value : undefined}
113
+ tabindex={item.value === tabStop ? 0 : -1}
114
+ bind:this={buttons[i]}
88
115
  onclick={() => select(item.value)}
116
+ onkeydown={(event) => moveFocus(event, i)}
89
117
  >
90
118
  {#if item.icon}<i class="menuselect-icon {item.icon}" aria-hidden="true"></i>{/if}
91
119
  <span class="menuselect-label">{item.label}</span>
@@ -188,7 +188,7 @@
188
188
  --notification-warning-text-line-height: var(--line-height-normal);
189
189
 
190
190
  /* Danger */
191
- --notification-danger-surface: var(--surface-danger);
191
+ --notification-danger-surface: var(--surface-danger-low);
192
192
  --notification-danger-action-surface: var(--surface-neutral-lowest);
193
193
  --notification-danger-border: var(--border-danger);
194
194
  --notification-danger-border-width: var(--border-width-1);
@@ -135,4 +135,10 @@
135
135
  border-radius: var(--progressbar-radius);
136
136
  transition: width 0.4s ease;
137
137
  }
138
+
139
+ @media (prefers-reduced-motion: reduce) {
140
+ .progress-fill {
141
+ transition: none;
142
+ }
143
+ }
138
144
  </style>
@@ -38,13 +38,46 @@
38
38
  onclick?.();
39
39
  dispatch('click');
40
40
  }
41
+
42
+ let button: HTMLButtonElement;
43
+
44
+ // Each option renders alone, so the group is read from the DOM: the nearest
45
+ // radiogroup, else the parent the options share.
46
+ function groupOf(radio: HTMLButtonElement): HTMLButtonElement[] {
47
+ const scope = radio.closest('[role="radiogroup"]') ?? radio.parentElement;
48
+ return scope ? [...scope.querySelectorAll<HTMLButtonElement>('.radio-button')] : [radio];
49
+ }
50
+
51
+ // Roving tab stop: the group takes one Tab press, on the checked option.
52
+ $effect(() => {
53
+ void selected;
54
+ const group = groupOf(button);
55
+ const stop = group.find((radio) => radio.getAttribute('aria-checked') === 'true') ?? group[0];
56
+ for (const radio of group) radio.tabIndex = radio === stop ? 0 : -1;
57
+ });
58
+
59
+ const ARROW_STEPS: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
60
+
61
+ function moveSelection(event: KeyboardEvent) {
62
+ const step = ARROW_STEPS[event.key];
63
+ if (step === undefined) return;
64
+ event.preventDefault();
65
+ const group = groupOf(button);
66
+ const next = group[(group.indexOf(button) + step + group.length) % group.length];
67
+ next.focus();
68
+ next.click();
69
+ }
41
70
  </script>
42
71
 
43
72
  <button
44
73
  class="radio-button {className}"
45
74
  class:selected
75
+ role="radio"
76
+ aria-checked={selected}
46
77
  style={color ? `--radiobutton-color: ${color};` : ''}
78
+ bind:this={button}
47
79
  onclick={fireClick}
80
+ onkeydown={moveSelection}
48
81
  >
49
82
  <span class="radio-dot"><span class="radio-dot-fill"></span></span>
50
83
  <span class="radio-label">{label}</span>
@@ -53,7 +86,7 @@
53
86
  <style lang="scss">
54
87
  :global(:root) {
55
88
  /* Default */
56
- --radiobutton-default-dot-border-color: var(--border-neutral);
89
+ --radiobutton-default-dot-border-color: var(--border-neutral-medium);
57
90
  --radiobutton-default-dot-border-width: var(--border-width-2);
58
91
  --radiobutton-default-dot-fill: var(--text-secondary);
59
92
  --radiobutton-default-dot-size: var(--dot-size-0);
@@ -64,7 +97,7 @@
64
97
  --radiobutton-default-label-line-height: var(--line-height-none);
65
98
 
66
99
  /* Hover */
67
- --radiobutton-hover-dot-border-color: var(--border-neutral);
100
+ --radiobutton-hover-dot-border-color: var(--border-neutral-medium);
68
101
  --radiobutton-hover-dot-border-width: var(--border-width-2);
69
102
  --radiobutton-hover-dot-fill: var(--text-secondary);
70
103
  --radiobutton-hover-dot-size: var(--dot-size-50);
@@ -75,7 +108,7 @@
75
108
  --radiobutton-hover-label-line-height: var(--line-height-none);
76
109
 
77
110
  /* Selected */
78
- --radiobutton-selected-dot-border-color: var(--border-neutral);
111
+ --radiobutton-selected-dot-border-color: var(--border-neutral-medium);
79
112
  --radiobutton-selected-dot-border-width: var(--border-width-2);
80
113
  --radiobutton-selected-dot-fill: var(--text-secondary);
81
114
  --radiobutton-selected-dot-size: var(--dot-size-50);
@@ -59,6 +59,29 @@
59
59
  onchange?.(v);
60
60
  dispatch('change', v);
61
61
  }
62
+
63
+ const buttons: HTMLButtonElement[] = $state([]);
64
+
65
+ // Roving tab stop: the group takes one Tab press, on the checked segment.
66
+ let tabStop = $derived(
67
+ segments.find((s) => s.value === value && !s.disabled)?.value ?? segments.find((s) => !s.disabled)?.value,
68
+ );
69
+
70
+ const ARROW_STEPS: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
71
+
72
+ function moveSelection(event: KeyboardEvent, from: number) {
73
+ const step = ARROW_STEPS[event.key];
74
+ if (step === undefined) return;
75
+ event.preventDefault();
76
+ const count = segments.length;
77
+ for (let n = 1; n < count; n++) {
78
+ const i = (((from + step * n) % count) + count) % count;
79
+ if (segments[i].disabled) continue;
80
+ buttons[i].focus();
81
+ select(segments[i].value);
82
+ return;
83
+ }
84
+ }
62
85
  </script>
63
86
 
64
87
  <div
@@ -77,7 +100,10 @@
77
100
  disabled={disabled || seg.disabled}
78
101
  role="radio"
79
102
  aria-checked={value === seg.value}
103
+ tabindex={seg.value === tabStop ? 0 : -1}
104
+ bind:this={buttons[i]}
80
105
  onclick={() => select(seg.value)}
106
+ onkeydown={(event) => moveSelection(event, i)}
81
107
  >
82
108
  {#if seg.icon}<i class={seg.icon}></i>{/if}
83
109
  <span>{seg.label}</span>
@@ -96,7 +122,7 @@
96
122
 
97
123
  /* Bar (outer wrapper) */
98
124
  --segmentedcontrol-bar-surface: color-mix(in srgb, var(--surface-neutral-lowest) 75%, transparent);
99
- --segmentedcontrol-bar-border: var(--border-neutral);
125
+ --segmentedcontrol-bar-border: var(--border-neutral-medium);
100
126
  --segmentedcontrol-bar-border-width: var(--border-width-1);
101
127
  --segmentedcontrol-bar-radius: var(--radius-lg);
102
128
  --segmentedcontrol-bar-padding: var(--space-4);
@@ -138,7 +164,7 @@
138
164
  --segmentedcontrol-selected-text-font-weight: var(--font-weight-normal);
139
165
  --segmentedcontrol-selected-text-line-height: var(--line-height-normal);
140
166
  --segmentedcontrol-selected-icon: var(--text-secondary);
141
- --segmentedcontrol-selected-border: var(--border-brand);
167
+ --segmentedcontrol-selected-border: var(--border-brand-medium);
142
168
  --segmentedcontrol-selected-border-width: var(--border-width-1);
143
169
  --segmentedcontrol-selected-radius: var(--radius-md);
144
170
 
@@ -245,6 +271,7 @@
245
271
  @include themed-padding(--segmentedcontrol-option-padding, $h: 2);
246
272
  background: transparent;
247
273
  border: 0;
274
+ outline: none;
248
275
  border-radius: var(--segmentedcontrol-selected-radius);
249
276
  color: var(--_text-color);
250
277
  font-family: var(--_text-family);
@@ -308,6 +335,13 @@
308
335
  outline-offset: calc(var(--segmentedcontrol-selected-border-width) * -1);
309
336
  }
310
337
 
338
+ /* A ring outside the box, in a neutral that reads apart from the selected
339
+ option's brand outline. It is a shadow because the selected option
340
+ already owns the outline. */
341
+ .segment:focus-visible {
342
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
343
+ }
344
+
311
345
  .segment:disabled {
312
346
  --_text-color: var(--segmentedcontrol-disabled-text);
313
347
  --_text-family: var(--segmentedcontrol-disabled-text-font-family);
@@ -357,7 +357,7 @@
357
357
 
358
358
  .slidepager-stage:focus-visible {
359
359
  outline: none;
360
- box-shadow: var(--ring-focus-md);
360
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
361
361
  }
362
362
 
363
363
  .slidepager-stage img {
@@ -438,7 +438,7 @@
438
438
 
439
439
  .slidepager-control:focus-visible {
440
440
  outline: none;
441
- box-shadow: var(--ring-focus-md);
441
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
442
442
  }
443
443
 
444
444
  .slidepager-counter {
@@ -131,13 +131,13 @@
131
131
  to one moves the other until the designer unlinks it. */
132
132
  /* Single: one thumb. */
133
133
  --slider-single-track-surface: var(--surface-neutral-lowest);
134
- --slider-single-track-border: var(--border-neutral-subtle);
134
+ --slider-single-track-border: var(--border-neutral-medium);
135
135
  --slider-single-track-border-width: var(--border-width-1);
136
136
  --slider-single-track-height: var(--space-8);
137
137
  --slider-single-track-radius: var(--radius-full);
138
138
  --slider-single-fill: var(--surface-brand-high);
139
139
  --slider-single-thumb-surface: var(--surface-neutral-highest);
140
- --slider-single-thumb-border: var(--border-brand);
140
+ --slider-single-thumb-border: var(--border-brand-medium);
141
141
  --slider-single-thumb-border-width: var(--border-width-2);
142
142
  --slider-single-thumb-size: var(--font-size-lg);
143
143
  --slider-single-thumb-radius: var(--radius-full);
@@ -151,13 +151,13 @@
151
151
 
152
152
  /* Range: two thumbs bounding a fill. */
153
153
  --slider-range-track-surface: var(--surface-neutral-lowest);
154
- --slider-range-track-border: var(--border-neutral-subtle);
154
+ --slider-range-track-border: var(--border-neutral-medium);
155
155
  --slider-range-track-border-width: var(--border-width-1);
156
156
  --slider-range-track-height: var(--space-8);
157
157
  --slider-range-track-radius: var(--radius-full);
158
158
  --slider-range-fill: var(--surface-brand-high);
159
159
  --slider-range-thumb-surface: var(--surface-neutral-highest);
160
- --slider-range-thumb-border: var(--border-brand);
160
+ --slider-range-thumb-border: var(--border-brand-medium);
161
161
  --slider-range-thumb-border-width: var(--border-width-2);
162
162
  --slider-range-thumb-size: var(--font-size-lg);
163
163
  --slider-range-thumb-radius: var(--radius-full);
@@ -224,6 +224,10 @@
224
224
  margin-block: calc((var(--_thumb-size) - var(--_track-height)) / 2);
225
225
  border-style: solid;
226
226
  transition: background var(--duration-150), border-color var(--duration-150);
227
+ /* WCAG 2.2 SC 2.5.8: the native thumb takes the pointer over 24px or
228
+ more. A cap smaller than that sits up to half the difference off the
229
+ native thumb's centre at either end of the travel. */
230
+ --_hit-size: max(var(--_thumb-size), var(--space-24));
227
231
  --_origin: calc(var(--_thumb-size) / 2 - var(--_track-border-width));
228
232
  --_travel: calc(100% + var(--_track-border-width) * 2 - var(--_thumb-size));
229
233
  }
@@ -287,7 +291,7 @@
287
291
  top: 50%;
288
292
  left: calc(-1 * var(--_track-border-width));
289
293
  width: calc(100% + var(--_track-border-width) * 2);
290
- height: var(--_thumb-size);
294
+ height: var(--_hit-size);
291
295
  margin: 0;
292
296
  padding: 0;
293
297
  transform: translateY(-50%);
@@ -299,14 +303,14 @@
299
303
  .thumb:disabled { cursor: not-allowed; }
300
304
  .thumb::-webkit-slider-thumb {
301
305
  appearance: none;
302
- width: var(--_thumb-size);
303
- height: var(--_thumb-size);
306
+ width: var(--_hit-size);
307
+ height: var(--_hit-size);
304
308
  opacity: 0;
305
309
  pointer-events: auto;
306
310
  }
307
311
  .thumb::-moz-range-thumb {
308
- width: var(--_thumb-size);
309
- height: var(--_thumb-size);
312
+ width: var(--_hit-size);
313
+ height: var(--_hit-size);
310
314
  border: 0;
311
315
  opacity: 0;
312
316
  pointer-events: auto;
@@ -58,22 +58,52 @@
58
58
  dispatch('tabChange', tab.id);
59
59
  }
60
60
  }
61
+
62
+ const buttons: HTMLButtonElement[] = $state([]);
63
+
64
+ // Roving tab stop: the bar takes one Tab press, on the selected tab.
65
+ let tabStop = $derived(
66
+ tabs.find((t) => t.id === value && !t.disabled)?.id ?? tabs.find((t) => !t.disabled)?.id,
67
+ );
68
+
69
+ // Arrows move focus alone; Enter or Space selects, since a consumer's
70
+ // onchange may load the panel.
71
+ function moveFocus(event: KeyboardEvent, from: number) {
72
+ const enabled = tabs.flatMap((t, i) => (t.disabled ? [] : [i]));
73
+ const at = enabled.indexOf(from);
74
+ const to = {
75
+ ArrowRight: enabled[(at + 1) % enabled.length],
76
+ ArrowLeft: enabled[(at - 1 + enabled.length) % enabled.length],
77
+ Home: enabled[0],
78
+ End: enabled[enabled.length - 1],
79
+ }[event.key];
80
+ if (to === undefined) return;
81
+ event.preventDefault();
82
+ buttons[to].focus();
83
+ }
61
84
  </script>
62
85
 
63
86
  <div
64
87
  class="tab-bar {className}"
88
+ role="tablist"
65
89
  style:--tabbar-hover-tint-enabled={hoverTintValue}
66
90
  >
67
- {#each tabs as tab}
91
+ {#each tabs as tab, i}
68
92
  <button
69
93
  class="tab"
70
94
  class:selected={value === tab.id}
71
95
  class:icon-only={iconOnly}
96
+ role="tab"
97
+ aria-selected={value === tab.id}
98
+ aria-label={iconOnly ? tab.label : undefined}
99
+ tabindex={tab.id === tabStop ? 0 : -1}
72
100
  disabled={tab.disabled}
101
+ bind:this={buttons[i]}
73
102
  onclick={() => selectTab(tab)}
103
+ onkeydown={(event) => moveFocus(event, i)}
74
104
  >
75
105
  {#if tab.icon}
76
- <i class={tab.icon}></i>
106
+ <i class={tab.icon} aria-hidden="true"></i>
77
107
  {/if}
78
108
  {#if !iconOnly}
79
109
  <span>{tab.label}</span>
@@ -133,7 +163,7 @@
133
163
 
134
164
  /* Selected tab */
135
165
  --tabbar-selected-text: var(--text-primary);
136
- --tabbar-selected-border: var(--color-brand-500);
166
+ --tabbar-selected-border: var(--border-brand-medium);
137
167
  --tabbar-selected-surface: var(--tint-low);
138
168
  --tabbar-selected-text-font-family: var(--font-sans);
139
169
  --tabbar-selected-text-font-size: var(--font-size-md);
@@ -59,7 +59,7 @@
59
59
  :global(:root) {
60
60
  /* Default (off resting) — also carries all geometry + label typography. */
61
61
  --toggle-track-surface: var(--surface-neutral);
62
- --toggle-track-border: var(--border-neutral);
62
+ --toggle-track-border: var(--border-neutral-medium);
63
63
  --toggle-track-border-width: var(--border-width-1);
64
64
  --toggle-track-radius: var(--radius-full);
65
65
  --toggle-track-padding: var(--space-2);
@@ -78,7 +78,7 @@
78
78
 
79
79
  /* On (selected — toggle is checked). */
80
80
  --toggle-on-track-surface: var(--surface-brand-high);
81
- --toggle-on-track-border: var(--border-brand);
81
+ --toggle-on-track-border: var(--border-brand-medium);
82
82
  --toggle-on-thumb-surface: var(--text-primary);
83
83
  --toggle-on-thumb-border: var(--border-brand-strong);
84
84
 
@@ -225,7 +225,7 @@
225
225
 
226
226
  .videoframe-trigger:focus-visible {
227
227
  outline: none;
228
- box-shadow: var(--ring-focus-md);
228
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
229
229
  border-radius: var(--sketch-radius, var(--videoframe-frame-radius));
230
230
  }
231
231
 
@@ -318,7 +318,7 @@
318
318
 
319
319
  .videolightbox-trigger:focus-visible {
320
320
  outline: none;
321
- box-shadow: var(--ring-focus-md);
321
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
322
322
  }
323
323
 
324
324
  .videolightbox-trigger img,
@@ -436,7 +436,7 @@
436
436
 
437
437
  .videolightbox-close:focus-visible {
438
438
  outline: none;
439
- box-shadow: var(--ring-focus-md);
439
+ box-shadow: 0 0 0 var(--border-width-2) var(--border-neutral-strong);
440
440
  }
441
441
 
442
442
  @media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,35 @@
1
+ const TABBABLE = [
2
+ '[href]',
3
+ 'button:not([disabled])',
4
+ 'input:not([disabled]):not([type="hidden"])',
5
+ 'select:not([disabled])',
6
+ 'textarea:not([disabled])',
7
+ '[tabindex]',
8
+ ]
9
+ .map((selector) => `${selector}:not([tabindex="-1"])`)
10
+ .join(', ');
11
+
12
+ export function tabbables(container: HTMLElement): HTMLElement[] {
13
+ return [...container.querySelectorAll<HTMLElement>(TABBABLE)];
14
+ }
15
+
16
+ /** Keeps Tab and Shift+Tab cycling through `container`'s tabbable elements.
17
+ * Focus on the container itself or outside it enters at the near end.
18
+ * Returns false when the container holds none, so the caller picks where
19
+ * focus rests. */
20
+ export function trapTab(container: HTMLElement, e: KeyboardEvent): boolean {
21
+ const stops = tabbables(container);
22
+ if (stops.length === 0) return false;
23
+ const first = stops[0];
24
+ const last = stops[stops.length - 1];
25
+ const active = document.activeElement as HTMLElement | null;
26
+ const inside = active !== null && active !== container && container.contains(active);
27
+ if (e.shiftKey && (!inside || active === first)) {
28
+ e.preventDefault();
29
+ last.focus();
30
+ } else if (!e.shiftKey && (!inside || active === last)) {
31
+ e.preventDefault();
32
+ first.focus();
33
+ }
34
+ return true;
35
+ }
@@ -0,0 +1,40 @@
1
+ "Brand / base": |
2
+ - text: Brand With Icon
3
+ "Brand / colors": |
4
+ - text: Brand With Icon
5
+ "Accent / base": |
6
+ - text: Accent With Icon
7
+ "Accent / colors": |
8
+ - text: Accent With Icon
9
+ "Neutral / base": |
10
+ - text: Neutral With Icon
11
+ "Neutral / colors": |
12
+ - text: Neutral With Icon
13
+ "Alternate / base": |
14
+ - text: Alternate With Icon
15
+ "Alternate / colors": |
16
+ - text: Alternate With Icon
17
+ "Canvas / base": |
18
+ - text: Canvas With Icon
19
+ "Canvas / colors": |
20
+ - text: Canvas With Icon
21
+ "Special / base": |
22
+ - text: Special With Icon
23
+ "Special / colors": |
24
+ - text: Special With Icon
25
+ "Success / base": |
26
+ - text: Success With Icon
27
+ "Success / colors": |
28
+ - text: Success With Icon
29
+ "Warning / base": |
30
+ - text: Warning With Icon
31
+ "Warning / colors": |
32
+ - text: Warning With Icon
33
+ "Danger / base": |
34
+ - text: Danger With Icon
35
+ "Danger / colors": |
36
+ - text: Danger With Icon
37
+ "Info / base": |
38
+ - text: Info With Icon
39
+ "Info / colors": |
40
+ - text: Info With Icon