@spectral_labs/ui 1.1.2 → 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.
@@ -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"}
@@ -140,16 +140,24 @@
140
140
  );
141
141
  }
142
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
+ */
143
151
  .sp-tree-item[data-selected] > .sp-tree-item-row {
144
- background: var(--sp-sys-color-primary);
145
- 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));
146
154
  }
147
155
 
148
156
  .sp-tree-item[data-selected] > .sp-tree-item-row:hover {
149
157
  background: color-mix(
150
158
  in oklch,
151
- var(--sp-sys-color-primary) 90%,
152
- var(--sp-sys-color-on-primary)
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))
153
161
  );
154
162
  }
155
163
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.1.2",
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