@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.
- package/dist/components/badge/badge.svelte +11 -3
- package/dist/components/context-menu/__tests__/ContextMenuNonInteractiveTest.svelte +34 -0
- package/dist/components/context-menu/__tests__/context-menu.test.svelte.js +74 -1
- package/dist/components/context-menu/components/context-menu-trigger.svelte +4 -4
- package/dist/components/context-menu/types.d.ts +15 -0
- package/dist/components/context-menu/types.d.ts.map +1 -1
- package/dist/components/tabs/__tests__/TabsTest.svelte +4 -2
- package/dist/components/tabs/__tests__/tabs.test.svelte.js +74 -1
- package/dist/components/tabs/components/tabs-list.svelte +59 -1
- package/dist/components/tabs/components/tabs-list.svelte.d.ts.map +1 -1
- package/dist/components/tabs/index.d.ts +1 -1
- package/dist/components/tabs/index.d.ts.map +1 -1
- package/dist/components/tabs/types.d.ts +26 -0
- package/dist/components/tabs/types.d.ts.map +1 -1
- package/dist/components/tree/__tests__/TreeLabelContentTest.svelte +21 -0
- package/dist/components/tree/__tests__/tree.test.svelte.js +30 -0
- package/dist/components/tree/components/tree-item.svelte +47 -6
- package/dist/components/tree/components/tree-item.svelte.d.ts.map +1 -1
- package/dist/components/tree/components/tree-root.svelte +15 -5
- package/dist/components/tree/components/tree-root.svelte.d.ts.map +1 -1
- package/dist/components/tree/index.d.ts +1 -1
- package/dist/components/tree/index.d.ts.map +1 -1
- package/dist/components/tree/tree.svelte.d.ts.map +1 -1
- package/dist/components/tree/tree.svelte.js +9 -3
- package/dist/components/tree/types.d.ts +21 -0
- package/dist/components/tree/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/themes/__tests__/carbon-dark-contrast.test.ts +169 -0
- 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
|
|
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;
|
|
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
|
|
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
|
-
{
|
|
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(
|
|
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)
|
|
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;
|
|
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() {
|
|
20
|
-
|
|
21
|
-
|
|
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() {
|
|
31
|
-
|
|
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;
|
|
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
|
|
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,
|
|
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;;
|
|
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() {
|
|
182
|
-
|
|
183
|
-
|
|
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
|
@@ -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
|
+
})
|
package/src/themes/carbon.css
CHANGED
|
@@ -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
|
|