@spectral_labs/ui 1.1.1 → 1.2.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.
Files changed (29) hide show
  1. package/dist/components/badge/badge.svelte +11 -3
  2. package/dist/components/context-menu/__tests__/ContextMenuNonInteractiveTest.svelte +34 -0
  3. package/dist/components/context-menu/__tests__/context-menu.test.svelte.js +74 -1
  4. package/dist/components/context-menu/components/context-menu-trigger.svelte +4 -4
  5. package/dist/components/context-menu/types.d.ts +15 -0
  6. package/dist/components/context-menu/types.d.ts.map +1 -1
  7. package/dist/components/tabs/__tests__/TabsTest.svelte +4 -2
  8. package/dist/components/tabs/__tests__/tabs.test.svelte.js +74 -1
  9. package/dist/components/tabs/components/tabs-list.svelte +59 -1
  10. package/dist/components/tabs/components/tabs-list.svelte.d.ts.map +1 -1
  11. package/dist/components/tabs/index.d.ts +1 -1
  12. package/dist/components/tabs/index.d.ts.map +1 -1
  13. package/dist/components/tabs/types.d.ts +26 -0
  14. package/dist/components/tabs/types.d.ts.map +1 -1
  15. package/dist/components/tree/__tests__/TreeLabelContentTest.svelte +21 -0
  16. package/dist/components/tree/__tests__/tree.test.svelte.js +30 -0
  17. package/dist/components/tree/components/tree-item.svelte +47 -6
  18. package/dist/components/tree/components/tree-item.svelte.d.ts.map +1 -1
  19. package/dist/components/tree/components/tree-root.svelte +15 -5
  20. package/dist/components/tree/components/tree-root.svelte.d.ts.map +1 -1
  21. package/dist/components/tree/index.d.ts +1 -1
  22. package/dist/components/tree/index.d.ts.map +1 -1
  23. package/dist/components/tree/tree.svelte.d.ts.map +1 -1
  24. package/dist/components/tree/tree.svelte.js +9 -3
  25. package/dist/components/tree/types.d.ts +21 -0
  26. package/dist/components/tree/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/themes/__tests__/carbon-dark-contrast.test.ts +169 -0
  29. package/src/themes/carbon.css +32 -0
@@ -59,17 +59,25 @@
59
59
  color: var(--sp-comp-badge-filled-fg);
60
60
  }
61
61
 
62
- /* ── Outlined ── */
62
+ /* ── Outlined ──
63
+ * Themes may override `--sp-comp-badge-outlined-color` from the outer
64
+ * cascade to replace the foreground without fighting the local
65
+ * `--sp-comp-badge-outline-fg` tokens declared on `.sp-badge` (custom
66
+ * property scope rules: ancestors can't override descendants that
67
+ * re-declare the same name). `carbon` dark uses this to ship a WCAG AA
68
+ * pair (#a4b4ff on #3c3c3c ≈ 5.5:1) without breaking the existing per-
69
+ * colour mapping (`secondary`, `tertiary`, …).
70
+ */
63
71
  .sp-badge.outlined {
64
72
  background-color: transparent;
65
73
  border: 1px solid var(--sp-comp-badge-outline-border);
66
- color: var(--sp-comp-badge-outline-fg);
74
+ color: var(--sp-comp-badge-outlined-color, var(--sp-comp-badge-outline-fg));
67
75
  }
68
76
 
69
77
  /* ── Ghost ── */
70
78
  .sp-badge.ghost {
71
79
  background-color: transparent;
72
- color: var(--sp-comp-badge-outline-fg);
80
+ color: var(--sp-comp-badge-outlined-color, var(--sp-comp-badge-outline-fg));
73
81
  }
74
82
 
75
83
  /* ── Tonal ── */
@@ -0,0 +1,34 @@
1
+ <script lang='ts'>
2
+ import DropdownMenuItem from '../../dropdown-menu/components/dropdown-menu-item.svelte'
3
+ import ContextMenuContent from '../components/context-menu-content.svelte'
4
+ import ContextMenuRoot from '../components/context-menu-root.svelte'
5
+ import ContextMenuTrigger from '../components/context-menu-trigger.svelte'
6
+
7
+ let {
8
+ open = $bindable(false),
9
+ onOpenChange,
10
+ portalTo,
11
+ }: {
12
+ open?: boolean
13
+ onOpenChange?: (open: boolean) => void
14
+ portalTo?: HTMLElement | string
15
+ } = $props()
16
+ </script>
17
+
18
+ <!--
19
+ Mirrors the Refero V2 CollectionTree pattern: a `Tree.Item`-like focusable
20
+ child (role="treeitem" + tabindex=0) is wrapped by ContextMenu.Trigger with
21
+ `nonInteractive`. The treeitem stays the only interactive node — the trigger
22
+ wrapper attaches event listeners but no role/tabindex/aria-disabled.
23
+ -->
24
+ <ContextMenuRoot bind:open {onOpenChange}>
25
+ <ContextMenuTrigger nonInteractive>
26
+ <div role='treeitem' tabindex='0' aria-level='1' data-testid='treeitem'>
27
+ Tree item
28
+ </div>
29
+ </ContextMenuTrigger>
30
+ <ContextMenuContent {portalTo}>
31
+ <DropdownMenuItem>Item 1</DropdownMenuItem>
32
+ <DropdownMenuItem>Item 2</DropdownMenuItem>
33
+ </ContextMenuContent>
34
+ </ContextMenuRoot>
@@ -1,6 +1,7 @@
1
1
  import { flushSync, mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import ContextMenuRoot from '../components/context-menu-root.svelte';
4
+ import ContextMenuNonInteractiveTest from './ContextMenuNonInteractiveTest.svelte';
4
5
  import ContextMenuTest from './ContextMenuTest.svelte';
5
6
  describe('context-menu', () => {
6
7
  let target;
@@ -62,4 +63,76 @@ describe('context-menu', () => {
62
63
  localTarget.remove();
63
64
  }
64
65
  });
66
+ // -------------------------------------------------------------------------
67
+ // nonInteractive prop
68
+ //
69
+ // Use when the child is already focusable/interactive (e.g. a Tree.Item with
70
+ // role="treeitem" + tabindex=0). The trigger wrapper then attaches event
71
+ // listeners only, without role/tabindex/aria-disabled, so it does not nest a
72
+ // second interactive control inside its child — fixing axe `nested-interactive`
73
+ // + `aria-required-parent` when wrapping a treeitem.
74
+ // -------------------------------------------------------------------------
75
+ describe('nonInteractive prop', () => {
76
+ function setupNI(props = {}) {
77
+ target = document.createElement('div');
78
+ document.body.appendChild(target);
79
+ instance = mount(ContextMenuNonInteractiveTest, { target, props: { portalTo: target, ...props } });
80
+ flushSync();
81
+ return target;
82
+ }
83
+ it('wrapper has no role, no tabindex, no aria-disabled when nonInteractive', () => {
84
+ setupNI();
85
+ const trigger = target.querySelector('.sp-context-menu-trigger');
86
+ expect(trigger).toBeTruthy();
87
+ expect(trigger.getAttribute('role')).toBeNull();
88
+ expect(trigger.getAttribute('tabindex')).toBeNull();
89
+ expect(trigger.getAttribute('aria-disabled')).toBeNull();
90
+ });
91
+ it('treeitem child has no [role="button"] ancestor when nonInteractive', () => {
92
+ setupNI();
93
+ const treeitem = target.querySelector('[role="treeitem"]');
94
+ expect(treeitem).toBeTruthy();
95
+ // Structural ARIA assertion: the treeitem must be a direct interactive
96
+ // descendant of the tree (no role="button" wrapper between them) so axe's
97
+ // `nested-interactive` + `aria-required-parent` rules stop firing.
98
+ expect(treeitem.closest('[role="button"]')).toBeNull();
99
+ });
100
+ it('right-click on wrapper still opens menu when nonInteractive', () => {
101
+ const calls = [];
102
+ setupNI({ onOpenChange: (open) => calls.push(open) });
103
+ const trigger = target.querySelector('.sp-context-menu-trigger');
104
+ trigger.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
105
+ flushSync();
106
+ expect(calls).toEqual([true]);
107
+ });
108
+ it('forwards Shift+F10 from focusable child to the wrapper handler when nonInteractive', () => {
109
+ const calls = [];
110
+ setupNI({ onOpenChange: (open) => calls.push(open) });
111
+ const child = target.querySelector('[data-testid="treeitem"]');
112
+ child.focus();
113
+ child.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true }));
114
+ flushSync();
115
+ expect(calls).toEqual([true]);
116
+ });
117
+ it('long-press (touchstart + 700ms hold) still opens menu when nonInteractive', () => {
118
+ vi.useFakeTimers();
119
+ try {
120
+ const calls = [];
121
+ setupNI({ onOpenChange: (open) => calls.push(open) });
122
+ const trigger = target.querySelector('.sp-context-menu-trigger');
123
+ // jsdom lacks TouchEvent constructor — synthesise a bubbling Event with a touches array.
124
+ const evt = new Event('touchstart', { bubbles: true });
125
+ Object.defineProperty(evt, 'touches', {
126
+ value: [{ clientX: 100, clientY: 100 }],
127
+ });
128
+ trigger.dispatchEvent(evt);
129
+ vi.advanceTimersByTime(700);
130
+ flushSync();
131
+ expect(calls).toEqual([true]);
132
+ }
133
+ finally {
134
+ vi.useRealTimers();
135
+ }
136
+ });
137
+ });
65
138
  });
@@ -2,7 +2,7 @@
2
2
  import type { ContextMenuTriggerProps } from '../types.js'
3
3
  import { getContextMenuContext } from '../context-menu.svelte.js'
4
4
 
5
- const { disabled = false, class: className, children }: ContextMenuTriggerProps = $props()
5
+ const { disabled = false, nonInteractive = false, class: className, children }: ContextMenuTriggerProps = $props()
6
6
  const ctx = getContextMenuContext()
7
7
 
8
8
  let el: HTMLElement | undefined = $state()
@@ -52,9 +52,9 @@
52
52
  <div
53
53
  bind:this={el}
54
54
  class="sp-context-menu-trigger {className ?? ''}"
55
- role='button'
56
- tabindex={disabled ? -1 : 0}
57
- aria-disabled={disabled || undefined}
55
+ role={nonInteractive ? undefined : 'button'}
56
+ tabindex={nonInteractive ? undefined : (disabled ? -1 : 0)}
57
+ aria-disabled={!nonInteractive && disabled ? true : undefined}
58
58
  {oncontextmenu}
59
59
  {ontouchstart}
60
60
  {ontouchend}
@@ -9,6 +9,21 @@ export interface ContextMenuProps {
9
9
  }
10
10
  export interface ContextMenuTriggerProps {
11
11
  disabled?: boolean;
12
+ /**
13
+ * When `true`, the trigger renders a neutral wrapper (no `role`, no
14
+ * `tabindex`, no `aria-disabled`) and only attaches event listeners
15
+ * (`contextmenu`, `touchstart`, `keydown`). Use this when the child is
16
+ * itself focusable/interactive (e.g. a `Tree.Item` with `role="treeitem"`
17
+ * + `tabindex=0`) so the wrapper doesn't introduce a nested interactive
18
+ * control, which otherwise breaks ARIA `nested-interactive` and
19
+ * `aria-required-parent` invariants.
20
+ *
21
+ * The child remains the only focusable element — keyboard activation
22
+ * (Shift+F10 / ContextMenu key) bubbles from the child to the wrapper's
23
+ * `keydown` handler. If the child is **not** focusable, prefer the
24
+ * default (`nonInteractive: false`) so the wrapper stays keyboard-reachable.
25
+ */
26
+ nonInteractive?: boolean;
12
27
  class?: string;
13
28
  children?: Snippet;
14
29
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/context-menu/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,wCAAwC,CAAA;AAEzE,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAGD,YAAY,EAAE,6BAA6B,IAAI,4BAA4B,EAAE,qBAAqB,IAAI,oBAAoB,EAAE,sBAAsB,IAAI,qBAAqB,EAAE,0BAA0B,IAAI,yBAAyB,EAAE,MAAM,2BAA2B,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/context-menu/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AACrC,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,wCAAwC,CAAA;AAEzE,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uFAAuF;IACvF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;;;;;;;;OAaG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAGD,YAAY,EAAE,6BAA6B,IAAI,4BAA4B,EAAE,qBAAqB,IAAI,oBAAoB,EAAE,sBAAsB,IAAI,qBAAqB,EAAE,0BAA0B,IAAI,yBAAyB,EAAE,MAAM,2BAA2B,CAAA"}
@@ -1,5 +1,5 @@
1
1
  <script lang='ts'>
2
- import type { TabsActivationMode } from '../types.js'
2
+ import type { TabsActivationMode, TabsListOverflow } from '../types.js'
3
3
  import TabsContent from '../components/tabs-content.svelte'
4
4
  import TabsList from '../components/tabs-list.svelte'
5
5
  import TabsRoot from '../components/tabs-root.svelte'
@@ -10,16 +10,18 @@
10
10
  activationMode = 'automatic' as TabsActivationMode,
11
11
  orientation = 'horizontal' as 'horizontal' | 'vertical',
12
12
  loop = true,
13
+ overflow,
13
14
  }: {
14
15
  value?: string
15
16
  activationMode?: TabsActivationMode
16
17
  orientation?: 'horizontal' | 'vertical'
17
18
  loop?: boolean
19
+ overflow?: TabsListOverflow
18
20
  } = $props()
19
21
  </script>
20
22
 
21
23
  <TabsRoot bind:value {activationMode} {orientation} {loop}>
22
- <TabsList>
24
+ <TabsList {overflow}>
23
25
  <TabsTrigger value='tab1'>Tab 1</TabsTrigger>
24
26
  <TabsTrigger value='tab2'>Tab 2</TabsTrigger>
25
27
  <TabsTrigger value='tab3' disabled>Tab 3</TabsTrigger>
@@ -1,5 +1,5 @@
1
1
  import { flushSync, mount, unmount } from 'svelte';
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
3
  import TabsRoot from '../components/tabs-root.svelte';
4
4
  import TabsTest from './TabsTest.svelte';
5
5
  describe('tabs', () => {
@@ -148,4 +148,77 @@ describe('tabs', () => {
148
148
  flushSync();
149
149
  expect(tabs[1]?.getAttribute('aria-selected')).toBe('true');
150
150
  });
151
+ // -------------------------------------------------------------------------
152
+ // overflow prop (mobile overflow strategy)
153
+ //
154
+ // Default is 'scroll' so long labels never silently truncate; consumers can
155
+ // opt into 'wrap' for multi-row pill layouts. When the active tab changes,
156
+ // the list scrolls it into view via `scrollIntoView` so the user is never
157
+ // looking at an out-of-viewport active tab.
158
+ // -------------------------------------------------------------------------
159
+ describe('overflow prop', () => {
160
+ it('default overflow="scroll" emits data-overflow="scroll" on tablist', () => {
161
+ setup();
162
+ const tablist = target.querySelector('[role="tablist"]');
163
+ expect(tablist.getAttribute('data-overflow')).toBe('scroll');
164
+ });
165
+ it('overflow="wrap" emits data-overflow="wrap" on tablist', () => {
166
+ setup({ overflow: 'wrap' });
167
+ const tablist = target.querySelector('[role="tablist"]');
168
+ expect(tablist.getAttribute('data-overflow')).toBe('wrap');
169
+ });
170
+ it('vertical + default overflow still emits data-overflow="scroll" alongside data-orientation', () => {
171
+ setup({ orientation: 'vertical' });
172
+ const tablist = target.querySelector('[role="tablist"]');
173
+ expect(tablist.getAttribute('data-overflow')).toBe('scroll');
174
+ expect(tablist.getAttribute('data-orientation')).toBe('vertical');
175
+ });
176
+ it('changing the active tab calls scrollIntoView on the newly active trigger when overflow="scroll"', () => {
177
+ // jsdom does not implement scrollIntoView — install a mock on the
178
+ // prototype so the component's call hits our spy instead of throwing.
179
+ const original = HTMLElement.prototype.scrollIntoView;
180
+ const spy = vi.fn();
181
+ HTMLElement.prototype.scrollIntoView = spy;
182
+ try {
183
+ setup({ value: 'tab1' });
184
+ const tabs = target.querySelectorAll('[role="tab"]');
185
+ spy.mockClear(); // ignore any call from the initial mount
186
+ tabs[1].click();
187
+ flushSync();
188
+ // The spy should have been called on the new active trigger with a
189
+ // sensible config that brings it into the viewport without scrolling
190
+ // the page vertically.
191
+ expect(spy).toHaveBeenCalled();
192
+ const lastCall = spy.mock.calls.at(-1);
193
+ const arg = lastCall?.[0];
194
+ expect(arg).toBeDefined();
195
+ expect(typeof arg).toBe('object');
196
+ expect(arg).toMatchObject({ inline: expect.stringMatching(/^(nearest|center|start|end)$/), block: 'nearest' });
197
+ // And the call target was the now-active trigger, not an arbitrary node.
198
+ const callTarget = spy.mock.contexts.at(-1);
199
+ expect(callTarget?.getAttribute('data-state')).toBe('active');
200
+ }
201
+ finally {
202
+ ;
203
+ HTMLElement.prototype.scrollIntoView = original;
204
+ }
205
+ });
206
+ it('overflow="wrap" does NOT call scrollIntoView on tab change (wrap layout shows all tabs)', () => {
207
+ const original = HTMLElement.prototype.scrollIntoView;
208
+ const spy = vi.fn();
209
+ HTMLElement.prototype.scrollIntoView = spy;
210
+ try {
211
+ setup({ value: 'tab1', overflow: 'wrap' });
212
+ const tabs = target.querySelectorAll('[role="tab"]');
213
+ spy.mockClear();
214
+ tabs[1].click();
215
+ flushSync();
216
+ expect(spy).not.toHaveBeenCalled();
217
+ }
218
+ finally {
219
+ ;
220
+ HTMLElement.prototype.scrollIntoView = original;
221
+ }
222
+ });
223
+ });
151
224
  });
@@ -3,7 +3,7 @@
3
3
  import { RovingFocusGroup } from '../../../internal/roving-focus/index.js'
4
4
  import { getTabsContext } from '../tabs.svelte.js'
5
5
 
6
- const { class: className, children }: TabsListProps = $props()
6
+ const { overflow = 'scroll', class: className, children }: TabsListProps = $props()
7
7
 
8
8
  const ctx = getTabsContext()
9
9
 
@@ -28,6 +28,31 @@
28
28
  }
29
29
  }
30
30
  }
31
+
32
+ // When `overflow="scroll"`, the active trigger may sit outside the visible
33
+ // viewport (especially on mobile after a tab is selected via list-restored
34
+ // state or via keyboard navigation). Re-run scrollIntoView on every value
35
+ // change so the user never looks at an out-of-viewport active tab. We use
36
+ // `inline: 'nearest'` + `block: 'nearest'` so the scroll is minimal and
37
+ // never moves the page vertically when the tablist is horizontal.
38
+ $effect(() => {
39
+ if (overflow !== 'scroll')
40
+ return
41
+ // Track ctx.value to retrigger on activation changes.
42
+ const _activeValue = ctx.value
43
+ if (!listEl)
44
+ return
45
+ const active = listEl.querySelector<HTMLElement>('[role="tab"][data-state="active"]')
46
+ if (!active)
47
+ return
48
+ // SSR-safe: $effect only runs in the browser, but scrollIntoView may still
49
+ // be undefined in jsdom-based tests — guard before calling.
50
+ if (typeof active.scrollIntoView !== 'function')
51
+ return
52
+ active.scrollIntoView({ inline: 'nearest', block: 'nearest' })
53
+ // `_activeValue` is read above strictly for the reactive subscription.
54
+ void _activeValue
55
+ })
31
56
  </script>
32
57
 
33
58
  <div
@@ -37,6 +62,7 @@
37
62
  aria-orientation={ctx.orientation}
38
63
  tabindex={0}
39
64
  data-orientation={ctx.orientation}
65
+ data-overflow={overflow}
40
66
  {onkeydown}
41
67
  >
42
68
  {#if children}{@render children()}{/if}
@@ -54,4 +80,36 @@
54
80
  border-bottom: none;
55
81
  border-right: 1px solid var(--sp-sys-color-outline-variant);
56
82
  }
83
+
84
+ /* `overflow="scroll"` (default): horizontal scroll on horizontal tablist,
85
+ vertical scroll on vertical tablist. Native scrollbar hidden — the
86
+ active trigger is brought into view programmatically via scrollIntoView.
87
+ The tablist itself keeps its tabindex={0}, so it remains keyboard-scrollable. */
88
+ .sp-tabs-list[data-overflow='scroll'][data-orientation='horizontal'] {
89
+ overflow-x: auto;
90
+ overflow-y: hidden;
91
+ scrollbar-width: none; /* Firefox */
92
+ }
93
+
94
+ .sp-tabs-list[data-overflow='scroll'][data-orientation='vertical'] {
95
+ overflow-x: hidden;
96
+ overflow-y: auto;
97
+ scrollbar-width: none;
98
+ }
99
+
100
+ .sp-tabs-list[data-overflow='scroll']::-webkit-scrollbar {
101
+ display: none; /* WebKit */
102
+ }
103
+
104
+ /* `overflow="wrap"`: triggers wrap onto multiple lines. We also unset
105
+ `white-space: nowrap` on every trigger (set in tabs-trigger.svelte) via
106
+ a scoped `:global` selector — without this the trigger labels would
107
+ still refuse to wrap inside their pill, defeating the point of wrap. */
108
+ .sp-tabs-list[data-overflow='wrap'] {
109
+ flex-wrap: wrap;
110
+ }
111
+
112
+ .sp-tabs-list[data-overflow='wrap'] :global(.sp-tabs-trigger) {
113
+ white-space: normal;
114
+ }
57
115
  </style>
@@ -1 +1 @@
1
- {"version":3,"file":"tabs-list.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tabs/components/tabs-list.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AA4ChD,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"tabs-list.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tabs/components/tabs-list.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAqEhD,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -2,5 +2,5 @@ export { default as Content } from './components/tabs-content.svelte';
2
2
  export { default as List } from './components/tabs-list.svelte';
3
3
  export { default as Root } from './components/tabs-root.svelte';
4
4
  export { default as Trigger } from './components/tabs-trigger.svelte';
5
- export type { TabsActivationMode, TabsContentProps, TabsListProps, TabsOrientation, TabsProps, TabsTriggerProps, } from './types.js';
5
+ export type { TabsActivationMode, TabsContentProps, TabsListOverflow, TabsListProps, TabsOrientation, TabsProps, TabsTriggerProps, } from './types.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tabs/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kCAAkC,CAAA;AACrE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kCAAkC,CAAA;AACrE,YAAY,EACV,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tabs/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kCAAkC,CAAA;AACrE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kCAAkC,CAAA;AACrE,YAAY,EACV,kBAAkB,EAClB,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,SAAS,EACT,gBAAgB,GACjB,MAAM,YAAY,CAAA"}
@@ -1,6 +1,27 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  export type TabsOrientation = 'horizontal' | 'vertical';
3
3
  export type TabsActivationMode = 'automatic' | 'manual';
4
+ /**
5
+ * Strategy used by `Tabs.List` when its triggers exceed the available space.
6
+ *
7
+ * - `'scroll'` (default): the tablist becomes horizontally (or vertically, when
8
+ * `orientation="vertical"`) scrollable, hides its native scrollbar, and
9
+ * programmatically scrolls the newly active trigger into view via
10
+ * `scrollIntoView({ inline: 'nearest', block: 'nearest' })` on every value
11
+ * change. Suited to narrow viewports (mobile) and to surfaces with an
12
+ * unknown number of tabs.
13
+ * - `'wrap'`: the tablist wraps its triggers onto multiple lines and unsets
14
+ * `white-space: nowrap` on the trigger labels so long labels remain
15
+ * readable on multi-row. No `scrollIntoView` is performed because every
16
+ * trigger is already visible.
17
+ *
18
+ * The previous behaviour (no overflow handling at all — long labels silently
19
+ * truncated and the user had no scroll affordance) is intentionally not
20
+ * exposed in the API: consumers who need it can still use `class` to override
21
+ * the styling locally, but the design system does not offer a mode whose
22
+ * documented behaviour is "silently broken on narrow viewports".
23
+ */
24
+ export type TabsListOverflow = 'scroll' | 'wrap';
4
25
  export interface TabsProps {
5
26
  /** The currently active tab value. */
6
27
  value?: string;
@@ -20,6 +41,11 @@ export interface TabsProps {
20
41
  children?: Snippet;
21
42
  }
22
43
  export interface TabsListProps {
44
+ /**
45
+ * Strategy used when triggers exceed the available space. Defaults to
46
+ * `'scroll'`. See {@link TabsListOverflow}.
47
+ */
48
+ overflow?: TabsListOverflow;
23
49
  /** Additional CSS class. */
24
50
  class?: string;
25
51
  /** Content snippet. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tabs/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAErC,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAA;AACvD,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,QAAQ,CAAA;AAEvD,MAAM,WAAW,SAAS;IACxB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2CAA2C;IAC3C,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;;;OAIG;IACH,cAAc,CAAC,EAAE,kBAAkB,CAAA;IACnC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;IACb,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tabs/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAErC,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,UAAU,CAAA;AACvD,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,QAAQ,CAAA;AAEvD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,MAAM,CAAA;AAEhD,MAAM,WAAW,SAAS;IACxB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2CAA2C;IAC3C,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;;;OAIG;IACH,cAAc,CAAC,EAAE,kBAAkB,CAAA;IACnC,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,QAAQ,CAAC,EAAE,gBAAgB,CAAA;IAC3B,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAA;IACb,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;IACb,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uBAAuB;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB"}
@@ -0,0 +1,21 @@
1
+ <script lang='ts'>
2
+ import * as Tree from '../index.js'
3
+ </script>
4
+
5
+ <!--
6
+ Test fixture for the `labelContent` snippet on Tree.Item. Renders a
7
+ custom HTML structure (icon stand-in + text span) in place of the
8
+ built-in plain-text label, so the test can assert that the snippet
9
+ output reaches the DOM exactly where the `label` string used to.
10
+ -->
11
+ <Tree.Root selectionMode='single' aria-label='Label-content tree'>
12
+ <Tree.Item id='lib' hasChildren textValue='My Library'>
13
+ {#snippet labelContent()}
14
+ <svg data-testid='lib-icon' aria-hidden='true' width='16' height='16' viewBox='0 0 24 24'>
15
+ <path d='M3 21h18M5 21V7l7-4 7 4v14' />
16
+ </svg>
17
+ <span data-testid='lib-label'>My Library</span>
18
+ {/snippet}
19
+ <Tree.Item id='col-1' label='Design' />
20
+ </Tree.Item>
21
+ </Tree.Root>
@@ -1,5 +1,6 @@
1
1
  import { flushSync, mount, unmount } from 'svelte';
2
2
  import { afterEach, describe, expect, it } from 'vitest';
3
+ import TreeLabelContentTest from './TreeLabelContentTest.svelte';
3
4
  import TreeTest from './TreeTest.svelte';
4
5
  describe('tree', () => {
5
6
  let target;
@@ -196,4 +197,33 @@ describe('tree', () => {
196
197
  flushSync();
197
198
  expect(target.querySelector('[data-tree-item="root-b"][data-selected]')).toBeNull();
198
199
  });
200
+ describe('labelContent snippet', () => {
201
+ let snippetTarget;
202
+ let snippetInstance;
203
+ function setupSnippet() {
204
+ snippetTarget = document.createElement('div');
205
+ document.body.appendChild(snippetTarget);
206
+ snippetInstance = mount(TreeLabelContentTest, { target: snippetTarget });
207
+ flushSync();
208
+ }
209
+ afterEach(() => {
210
+ if (snippetInstance)
211
+ unmount(snippetInstance);
212
+ snippetInstance = undefined;
213
+ snippetTarget?.remove();
214
+ });
215
+ it('renders the labelContent snippet inside .sp-tree-item-content', () => {
216
+ setupSnippet();
217
+ const item = snippetTarget.querySelector('[data-tree-item="lib"]');
218
+ const content = item?.querySelector('.sp-tree-item-content');
219
+ expect(content).toBeTruthy();
220
+ // The custom snippet's children land inside the content slot —
221
+ // both the icon and the named label span are reachable.
222
+ expect(content?.querySelector('[data-testid="lib-icon"]')).toBeTruthy();
223
+ expect(content?.querySelector('[data-testid="lib-label"]')?.textContent).toBe('My Library');
224
+ // The plain-text label fallback path is bypassed when the snippet
225
+ // is provided — the content slot has no stray text-only child.
226
+ expect(content?.textContent?.trim()).toBe('My Library');
227
+ });
228
+ });
199
229
  });
@@ -6,6 +6,7 @@
6
6
  const {
7
7
  id,
8
8
  label,
9
+ labelContent,
9
10
  textValue,
10
11
  hasChildren = false,
11
12
  disabled = false,
@@ -81,7 +82,15 @@
81
82
  aria-hidden='true'
82
83
  onclick={onChevronClick}
83
84
  >
84
- <svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' aria-hidden='true'>
85
+ <svg
86
+ viewBox='0 0 24 24'
87
+ fill='none'
88
+ stroke='currentColor'
89
+ stroke-width='2'
90
+ stroke-linecap='round'
91
+ stroke-linejoin='round'
92
+ aria-hidden='true'
93
+ >
85
94
  <polyline points='9 18 15 12 9 6'></polyline>
86
95
  </svg>
87
96
  </button>
@@ -89,7 +98,11 @@
89
98
  <span class='sp-tree-item-spacer'></span>
90
99
  {/if}
91
100
  <span class='sp-tree-item-content'>
92
- {label ?? ''}
101
+ {#if labelContent}
102
+ {@render labelContent()}
103
+ {:else}
104
+ {label ?? ''}
105
+ {/if}
93
106
  </span>
94
107
  </div>
95
108
  {#if hasChildren && isExpanded}
@@ -127,13 +140,25 @@
127
140
  );
128
141
  }
129
142
 
143
+ /*
144
+ * Selected row colours. Themes may override
145
+ * `--sp-comp-tree-item-selected-{bg,fg}` to ship a WCAG AA-compliant
146
+ * combination — `carbon` dark does, because the default
147
+ * `--sp-sys-color-primary` / `--sp-sys-color-on-primary` pair fails
148
+ * 4.5:1 in that theme (#fff on #5c7cfa ≈ 3.67:1). Other themes keep
149
+ * the previous default via the var() fallback chain.
150
+ */
130
151
  .sp-tree-item[data-selected] > .sp-tree-item-row {
131
- background: var(--sp-sys-color-primary);
132
- color: var(--sp-sys-color-on-primary);
152
+ background: var(--sp-comp-tree-item-selected-bg, var(--sp-sys-color-primary));
153
+ color: var(--sp-comp-tree-item-selected-fg, var(--sp-sys-color-on-primary));
133
154
  }
134
155
 
135
156
  .sp-tree-item[data-selected] > .sp-tree-item-row:hover {
136
- background: color-mix(in oklch, var(--sp-sys-color-primary) 90%, var(--sp-sys-color-on-primary));
157
+ background: color-mix(
158
+ in oklch,
159
+ var(--sp-comp-tree-item-selected-bg, var(--sp-sys-color-primary)) 90%,
160
+ var(--sp-comp-tree-item-selected-fg, var(--sp-sys-color-on-primary))
161
+ );
137
162
  }
138
163
 
139
164
  .sp-tree-item:focus-visible > .sp-tree-item-row {
@@ -169,7 +194,8 @@
169
194
  color: inherit;
170
195
  cursor: pointer;
171
196
  flex-shrink: 0;
172
- transition: rotate var(--sp-sys-motion-duration-short2, 100ms) var(--sp-sys-motion-easing-standard, ease);
197
+ transition: rotate var(--sp-sys-motion-duration-short2, 100ms)
198
+ var(--sp-sys-motion-easing-standard, ease);
173
199
  }
174
200
 
175
201
  .sp-tree-item-chevron svg {
@@ -186,14 +212,29 @@
186
212
  flex-shrink: 0;
187
213
  }
188
214
 
215
+ /* Row content slot. Inline-flex (with gap) makes the `labelContent`
216
+ snippet usage natural — `<Icon/><span>label</span>` aligns out of the
217
+ box. For the plain-text `label` case the inline-flex container is
218
+ idempotent (a single inline child renders identically to a `display:
219
+ inline` parent). The ellipsis is scoped to the inner text via the
220
+ child rule below so an icon + text combo still truncates cleanly. */
189
221
  .sp-tree-item-content {
190
222
  flex: 1;
191
223
  min-width: 0;
224
+ display: inline-flex;
225
+ align-items: center;
226
+ gap: 0.5rem;
192
227
  white-space: nowrap;
193
228
  overflow: hidden;
194
229
  text-overflow: ellipsis;
195
230
  }
196
231
 
232
+ .sp-tree-item-content > :global(span) {
233
+ min-width: 0;
234
+ overflow: hidden;
235
+ text-overflow: ellipsis;
236
+ }
237
+
197
238
  .sp-tree-item-children {
198
239
  /* Children are nested inside the item */
199
240
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tree-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tree/components/tree-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAoFhD,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"tree-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tree/components/tree-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAyFhD,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -16,9 +16,15 @@
16
16
  }: TreeProps = $props()
17
17
 
18
18
  const { context } = createTree({
19
- get selectionMode() { return selectionMode },
20
- get selectedKeys() { return selectedKeys },
21
- get expandedKeys() { return expandedKeys },
19
+ get selectionMode() {
20
+ return selectionMode
21
+ },
22
+ get selectedKeys() {
23
+ return selectedKeys
24
+ },
25
+ get expandedKeys() {
26
+ return expandedKeys
27
+ },
22
28
  onSelectionChange: (keys) => {
23
29
  selectedKeys = keys
24
30
  onSelectionChange?.(keys)
@@ -27,8 +33,12 @@
27
33
  expandedKeys = keys
28
34
  onExpandedChange?.(keys)
29
35
  },
30
- get disabled() { return disabled },
31
- get direction() { return direction },
36
+ get disabled() {
37
+ return disabled
38
+ },
39
+ get direction() {
40
+ return direction
41
+ },
32
42
  })
33
43
 
34
44
  setTreeContext(context)
@@ -1 +1 @@
1
- {"version":3,"file":"tree-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tree/components/tree-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AA+C5C,QAAA,MAAM,QAAQ,4GAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"tree-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/tree/components/tree-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAyD5C,QAAA,MAAM,QAAQ,4GAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -1,4 +1,4 @@
1
1
  export { default as Item } from './components/tree-item.svelte';
2
2
  export { default as Root } from './components/tree-root.svelte';
3
- export type { TreeItemProps, TreeNode, TreeProps, } from './types.js';
3
+ export type { TreeItemProps, TreeNode, TreeProps } from './types.js';
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,YAAY,EACV,aAAa,EACb,QAAQ,EACR,SAAS,GACV,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,+BAA+B,CAAA;AAC/D,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"tree.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/tree.svelte.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,MAAM,WAAW,WAAW;IAC1B,sBAAsB;IACtB,aAAa,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAC7C,oCAAoC;IACpC,QAAQ,EAAE,OAAO,CAAA;IACjB,sBAAsB;IACtB,SAAS,EAAE,KAAK,GAAG,KAAK,CAAA;IACxB,mCAAmC;IACnC,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAA;IACnC,yCAAyC;IACzC,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,mCAAmC;IACnC,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAA;IACnC,mCAAmC;IACnC,eAAe,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IAC5E,gDAAgD;IAChD,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IAClG,0BAA0B;IAC1B,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,8CAA8C;IAC9C,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAC7D,wCAAwC;IACxC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAA;CACjC;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAErD;AAED,wBAAgB,cAAc,IAAI,WAAW,CAE5C;AAID,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAC7C,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAC/C,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAC9C,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,KAAK,GAAG,KAAK,CAAA;CACzB;AAQD,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB;;EA+MjD"}
1
+ {"version":3,"file":"tree.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/tree.svelte.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,MAAM,WAAW,WAAW;IAC1B,sBAAsB;IACtB,aAAa,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAC7C,oCAAoC;IACpC,QAAQ,EAAE,OAAO,CAAA;IACjB,sBAAsB;IACtB,SAAS,EAAE,KAAK,GAAG,KAAK,CAAA;IACxB,mCAAmC;IACnC,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAA;IACnC,yCAAyC;IACzC,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,mCAAmC;IACnC,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAA;IACnC,mCAAmC;IACnC,eAAe,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IAC5E,gDAAgD;IAChD,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;IAClG,0BAA0B;IAC1B,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,8CAA8C;IAC9C,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,KAAK,IAAI,CAAA;IAC7D,wCAAwC;IACxC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAA;CACjC;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAErD;AAED,wBAAgB,cAAc,IAAI,WAAW,CAE5C;AAID,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAC7C,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACzB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAC/C,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAC9C,QAAQ,EAAE,OAAO,CAAA;IACjB,SAAS,EAAE,KAAK,GAAG,KAAK,CAAA;CACzB;AAQD,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB;;EAqNjD"}
@@ -178,9 +178,15 @@ export function createTree(opts) {
178
178
  }
179
179
  // ── Context ──────────────────────────────────────────────────────────
180
180
  const context = {
181
- get selectionMode() { return opts.selectionMode; },
182
- get disabled() { return opts.disabled; },
183
- get direction() { return opts.direction; },
181
+ get selectionMode() {
182
+ return opts.selectionMode;
183
+ },
184
+ get disabled() {
185
+ return opts.disabled;
186
+ },
187
+ get direction() {
188
+ return opts.direction;
189
+ },
184
190
  isExpanded,
185
191
  toggleExpanded,
186
192
  isSelected,
@@ -30,6 +30,27 @@ export interface TreeItemProps {
30
30
  id: string;
31
31
  /** Display label for the item row. */
32
32
  label?: string;
33
+ /**
34
+ * Optional snippet that overrides the plain-text `label` for the row
35
+ * content. Use when the row needs more than text — for example a leading
36
+ * icon, a trailing badge, or a multi-segment label.
37
+ *
38
+ * When provided, the snippet is rendered inside `.sp-tree-item-content`
39
+ * (which is flex-styled) so a typical pattern is
40
+ * `<Icon/> <span>label text</span>` :
41
+ *
42
+ * <Tree.Item id="lib" hasChildren>
43
+ * {#snippet labelContent()}
44
+ * <Library size={16} />
45
+ * <span>My Library</span>
46
+ * {/snippet}
47
+ * ...children
48
+ * </Tree.Item>
49
+ *
50
+ * `textValue` should still be set (or `label` left in place as a
51
+ * fallback) so typeahead and accessibility have a string to work with.
52
+ */
53
+ labelContent?: Snippet;
33
54
  /** Text value for typeahead and accessibility (defaults to label). */
34
55
  textValue?: string;
35
56
  /** Whether this item has children (shows expand chevron). */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAIrC,MAAM,WAAW,QAAQ;IACvB,yBAAyB;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,MAAM,WAAW,SAAS,CAAC,EAAE,SAAS,QAAQ,GAAG,QAAQ;IACvD,sBAAsB;IACtB,eAAe,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAChD,yCAAyC;IACzC,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC5B,yCAAyC;IACzC,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC5B,qCAAqC;IACrC,mBAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IACjD,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAChD,sBAAsB;IACtB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,sBAAsB;IACtB,WAAW,CAAC,EAAE,KAAK,GAAG,KAAK,CAAA;IAC3B,wBAAwB;IACxB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,4BAA4B;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAID,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAID,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/lib/components/tree/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;AAIrC,MAAM,WAAW,QAAQ;IACvB,yBAAyB;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,MAAM,WAAW,SAAS,CAAC,EAAE,SAAS,QAAQ,GAAG,QAAQ;IACvD,sBAAsB;IACtB,eAAe,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;IAChD,yCAAyC;IACzC,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC5B,yCAAyC;IACzC,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IAC5B,qCAAqC;IACrC,mBAAmB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IACjD,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,CAAA;IAChD,sBAAsB;IACtB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,sBAAsB;IACtB,WAAW,CAAC,EAAE,KAAK,GAAG,KAAK,CAAA;IAC3B,wBAAwB;IACxB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,4BAA4B;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAID,MAAM,WAAW,aAAa;IAC5B,sBAAsB;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;;;;;;;;;;;;;OAmBG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6DAA6D;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAID,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"
@@ -0,0 +1,169 @@
1
+ /*
2
+ * Carbon dark — WCAG AA color-contrast regression test.
3
+ *
4
+ * The hand-tuned dark block of `themes/carbon.css` previously had three
5
+ * surfaces that failed WCAG 2.0 AA (≥ 4.5:1 for normal text):
6
+ *
7
+ * - Tree.Item selected: `--sp-sys-color-on-primary` (#fff) on
8
+ * `--sp-sys-color-primary` (#5c7cfa) ≈ 3.67:1
9
+ * - Tabs.Trigger active: `--sp-sys-color-primary` (#5c7cfa) on
10
+ * `--sp-sys-color-surface-container` (#3c3c3c) ≈ 3.00:1
11
+ * - Badge outlined primary: `--sp-sys-color-primary` (#5c7cfa) on
12
+ * surface (~3c3c3c) ≈ 3.00:1
13
+ *
14
+ * Fix surfaces these via three carbon-dark-specific comp tokens, expected
15
+ * to be ≥ 4.5:1 against their relevant background. This test asserts the
16
+ * tokens exist and the ratios pass — running purely on hex values read
17
+ * back from a live `[data-theme="carbon"].dark` element through
18
+ * `getComputedStyle().getPropertyValue(...)`, which jsdom resolves
19
+ * correctly for hex-valued custom properties (unlike rendered `color:`,
20
+ * which axe-core needs and jsdom cannot provide).
21
+ *
22
+ * Badge tonal secondary (#166) is intentionally not covered here: a
23
+ * theoretical OKLCH ratio puts it at ~16:1; we keep the existing ref-palette
24
+ * mapping until a runtime repro lands.
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs'
28
+ import { dirname, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest'
31
+
32
+ // jsdom + Vitest does not actually evaluate `import "../carbon.css"` against
33
+ // the DOM (CSS modules become inert objects), so we inject the theme as a
34
+ // <style> tag instead — that path *is* understood by jsdom's CSS engine and
35
+ // makes the custom properties below resolve via getComputedStyle().
36
+ const __filename = fileURLToPath(import.meta.url)
37
+ const __dirname = dirname(__filename)
38
+ const CARBON_CSS = readFileSync(resolve(__dirname, '../carbon.css'), 'utf-8')
39
+
40
+ function hexToRgb(hex: string): [number, number, number] {
41
+ const clean = hex.replace(/^#/, '')
42
+ if (clean.length !== 6)
43
+ throw new Error(`Expected 6-digit hex, got "${hex}"`)
44
+ return [
45
+ Number.parseInt(clean.slice(0, 2), 16),
46
+ Number.parseInt(clean.slice(2, 4), 16),
47
+ Number.parseInt(clean.slice(4, 6), 16),
48
+ ]
49
+ }
50
+
51
+ function relativeLuminance([r, g, b]: [number, number, number]): number {
52
+ const transform = (v: number) => {
53
+ const u = v / 255
54
+ return u <= 0.03928 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4
55
+ }
56
+ return 0.2126 * transform(r) + 0.7152 * transform(g) + 0.0722 * transform(b)
57
+ }
58
+
59
+ function wcagRatio(hexA: string, hexB: string): number {
60
+ const lA = relativeLuminance(hexToRgb(hexA))
61
+ const lB = relativeLuminance(hexToRgb(hexB))
62
+ const max = Math.max(lA, lB)
63
+ const min = Math.min(lA, lB)
64
+ return (max + 0.05) / (min + 0.05)
65
+ }
66
+
67
+ describe('carbon dark — WCAG AA color-contrast', () => {
68
+ let host: HTMLElement
69
+ let cs: CSSStyleDeclaration
70
+
71
+ beforeAll(() => {
72
+ const style = document.createElement('style')
73
+ style.textContent = CARBON_CSS
74
+ document.head.appendChild(style)
75
+ host = document.createElement('div')
76
+ host.setAttribute('data-theme', 'carbon')
77
+ host.classList.add('dark')
78
+ document.body.appendChild(host)
79
+ cs = getComputedStyle(host)
80
+ })
81
+
82
+ afterEach(() => {
83
+ // Each test reads from the same host; nothing to clean per-test.
84
+ })
85
+
86
+ function readToken(name: string): string {
87
+ const v = cs.getPropertyValue(name).trim()
88
+ if (!v)
89
+ throw new Error(`Custom property "${name}" is not set on the carbon dark host. Either the token is missing from themes/carbon.css or the CSS import did not load.`)
90
+ return v
91
+ }
92
+
93
+ it('tree-item selected pair (--sp-comp-tree-item-selected-bg vs -fg) passes AA (≥ 4.5)', () => {
94
+ const bg = readToken('--sp-comp-tree-item-selected-bg')
95
+ const fg = readToken('--sp-comp-tree-item-selected-fg')
96
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
97
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
98
+ const ratio = wcagRatio(bg, fg)
99
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
100
+ })
101
+
102
+ it('tabs-trigger active label (--sp-comp-tabs-active-label-color on --sp-sys-color-surface-container) passes AA (≥ 4.5)', () => {
103
+ const fg = readToken('--sp-comp-tabs-active-label-color')
104
+ const bg = readToken('--sp-sys-color-surface-container')
105
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
106
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
107
+ const ratio = wcagRatio(bg, fg)
108
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
109
+ })
110
+
111
+ it('badge outlined primary (--sp-comp-badge-outlined-color on --sp-sys-color-surface-container) passes AA (≥ 4.5)', () => {
112
+ // `--sp-comp-badge-outlined-color` is an outer-cascade override token
113
+ // applied by themes (here carbon dark) and read with a fallback inside
114
+ // `.sp-badge.outlined { color: var(--sp-comp-badge-outlined-color,
115
+ // var(--sp-comp-badge-outline-fg)) }`. It lets the theme replace the
116
+ // outlined-foreground without fighting the local `--sp-comp-badge-*`
117
+ // tokens declared on `.sp-badge` itself (custom-property scope rules).
118
+ const fg = readToken('--sp-comp-badge-outlined-color')
119
+ const bg = readToken('--sp-sys-color-surface-container')
120
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
121
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
122
+ const ratio = wcagRatio(bg, fg)
123
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
124
+ })
125
+
126
+ it('none of the carbon-dark comp tokens resolves to the legacy primary #5c7cfa', () => {
127
+ // Sanity check: none of the "fixed" surfaces should resolve to the
128
+ // legacy #5c7cfa fallback. If they do, the fix didn't actually replace
129
+ // the cascade — most likely the comp token landed in carbon.css but the
130
+ // component file still reads the system token directly.
131
+ expect(readToken('--sp-comp-tree-item-selected-bg').toLowerCase()).not.toBe('#5c7cfa')
132
+ expect(readToken('--sp-comp-tabs-active-label-color').toLowerCase()).not.toBe('#5c7cfa')
133
+ expect(readToken('--sp-comp-badge-outlined-color').toLowerCase()).not.toBe('#5c7cfa')
134
+ expect(readToken('--sp-comp-button-filled-container-color').toLowerCase()).not.toBe('#5c7cfa')
135
+ expect(readToken('--sp-comp-button-outlined-label-color').toLowerCase()).not.toBe('#5c7cfa')
136
+ expect(readToken('--sp-comp-button-text-label-color').toLowerCase()).not.toBe('#5c7cfa')
137
+ })
138
+
139
+ it('button filled primary (--sp-comp-button-filled-container-color vs label-color) passes AA (≥ 4.5)', () => {
140
+ const bg = readToken('--sp-comp-button-filled-container-color')
141
+ const fg = readToken('--sp-comp-button-filled-label-color')
142
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
143
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
144
+ const ratio = wcagRatio(bg, fg)
145
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
146
+ })
147
+
148
+ it('button outlined primary (--sp-comp-button-outlined-label-color on --sp-sys-color-surface-container) passes AA (≥ 4.5)', () => {
149
+ // .sp-button.outlined renders with `background-color: transparent` by
150
+ // default, so the relevant background for the contrast check is the
151
+ // ancestor surface a typical button sits on — that's surface-container
152
+ // (#3c3c3c) in the carbon dark hand-tuned block.
153
+ const fg = readToken('--sp-comp-button-outlined-label-color')
154
+ const bg = readToken('--sp-sys-color-surface-container')
155
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
156
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
157
+ const ratio = wcagRatio(bg, fg)
158
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
159
+ })
160
+
161
+ it('button text primary (--sp-comp-button-text-label-color on --sp-sys-color-surface-container) passes AA (≥ 4.5)', () => {
162
+ const fg = readToken('--sp-comp-button-text-label-color')
163
+ const bg = readToken('--sp-sys-color-surface-container')
164
+ expect(fg).toMatch(/^#[0-9a-f]{6}$/i)
165
+ expect(bg).toMatch(/^#[0-9a-f]{6}$/i)
166
+ const ratio = wcagRatio(bg, fg)
167
+ expect(ratio).toBeGreaterThanOrEqual(4.5)
168
+ })
169
+ })
@@ -294,5 +294,37 @@
294
294
  --sp-sys-color-success: #28a745;
295
295
  --sp-sys-color-warning: #ffc107;
296
296
  --sp-sys-color-error: #dc3545;
297
+
298
+ /*
299
+ * WCAG AA color-contrast — carbon dark
300
+ *
301
+ * `--sp-sys-color-primary` (#5c7cfa) is kept for branding consistency,
302
+ * but it fails AA in several documented carbon-dark surfaces because
303
+ * white-on-#5c7cfa ≈ 3.67:1 and #5c7cfa-on-#3c3c3c ≈ 3.00:1, both below
304
+ * the 4.5:1 normal-text threshold:
305
+ *
306
+ * Tree.Item selected: white on #5c7cfa ≈ 3.67:1
307
+ * Tabs.Trigger active: #5c7cfa on #3c3c3c ≈ 3.00:1
308
+ * Badge outlined: #5c7cfa on #3c3c3c ≈ 3.00:1
309
+ * Button filled primary: white on #5c7cfa ≈ 3.67:1
310
+ * Button outlined/text: #5c7cfa on #3c3c3c ≈ 3.00:1
311
+ *
312
+ * Each surface gets a dedicated comp token here so the fix is scoped
313
+ * to carbon dark — other themes keep their existing tokens and visuals
314
+ * unchanged. Targets ≥ 4.5:1 (normal text). Two paired values reused
315
+ * across surfaces for visual coherence:
316
+ *
317
+ * #3949ab on #ffffff ≈ 7.8:1 (filled / selected surfaces: darker indigo bg + white fg)
318
+ * #a4b4ff on #3c3c3c ≈ 5.5:1 (label-on-dark-surface: lighter indigo fg)
319
+ */
320
+ --sp-comp-tree-item-selected-bg: #3949ab;
321
+ --sp-comp-tree-item-selected-fg: #ffffff;
322
+ --sp-comp-tabs-active-label-color: #a4b4ff;
323
+ --sp-comp-badge-outlined-color: #a4b4ff;
324
+ --sp-comp-button-filled-container-color: #3949ab;
325
+ --sp-comp-button-filled-label-color: #ffffff;
326
+ --sp-comp-button-outlined-label-color: #a4b4ff;
327
+ --sp-comp-button-outlined-outline-color: #a4b4ff;
328
+ --sp-comp-button-text-label-color: #a4b4ff;
297
329
  }
298
330