@spectral_labs/ui 1.4.0 → 1.5.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.
@@ -55,7 +55,7 @@
55
55
  align-items: center;
56
56
  gap: 0.5rem;
57
57
  padding: 0.5rem 0.75rem;
58
- border-radius: var(--sp-sys-shape-corner-xs, 4px);
58
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
59
59
  font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
60
60
  color: var(--sp-sys-color-on-surface);
61
61
  cursor: pointer;
@@ -2,24 +2,49 @@
2
2
  import DropdownMenuContent from '../components/dropdown-menu-content.svelte'
3
3
  import DropdownMenuItem from '../components/dropdown-menu-item.svelte'
4
4
  import DropdownMenuRoot from '../components/dropdown-menu-root.svelte'
5
+ import DropdownMenuSubContent from '../components/dropdown-menu-sub-content.svelte'
6
+ import DropdownMenuSubTrigger from '../components/dropdown-menu-sub-trigger.svelte'
7
+ import DropdownMenuSub from '../components/dropdown-menu-sub.svelte'
5
8
  import DropdownMenuTrigger from '../components/dropdown-menu-trigger.svelte'
6
9
 
7
10
  let {
8
11
  open = $bindable(false),
9
12
  portalTo,
10
13
  onOpenChange,
14
+ withSub = false,
15
+ subFirst = false,
16
+ subDisabled = false,
11
17
  }: {
12
18
  open?: boolean
13
19
  portalTo?: HTMLElement | string
14
20
  onOpenChange?: (open: boolean) => void
21
+ withSub?: boolean
22
+ subFirst?: boolean
23
+ subDisabled?: boolean
15
24
  } = $props()
16
25
  </script>
17
26
 
27
+ {#snippet subBlock()}
28
+ <DropdownMenuSub>
29
+ <DropdownMenuSubTrigger disabled={subDisabled}>More options</DropdownMenuSubTrigger>
30
+ <DropdownMenuSubContent>
31
+ <DropdownMenuItem>Sub Item 1</DropdownMenuItem>
32
+ <DropdownMenuItem>Sub Item 2</DropdownMenuItem>
33
+ </DropdownMenuSubContent>
34
+ </DropdownMenuSub>
35
+ {/snippet}
36
+
18
37
  <DropdownMenuRoot bind:open {onOpenChange}>
19
38
  <DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
20
39
  <DropdownMenuContent {portalTo}>
40
+ {#if withSub && subFirst}
41
+ {@render subBlock()}
42
+ {/if}
21
43
  <DropdownMenuItem>Item 1</DropdownMenuItem>
22
44
  <DropdownMenuItem>Item 2</DropdownMenuItem>
23
45
  <DropdownMenuItem>Item 3</DropdownMenuItem>
46
+ {#if withSub && !subFirst}
47
+ {@render subBlock()}
48
+ {/if}
24
49
  </DropdownMenuContent>
25
50
  </DropdownMenuRoot>
@@ -60,4 +60,71 @@ describe('dropdown-menu', () => {
60
60
  flushSync();
61
61
  expect(calls).toEqual([true, false]);
62
62
  });
63
+ describe('subTrigger', () => {
64
+ function pressKey(key) {
65
+ const el = document.activeElement;
66
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
67
+ flushSync();
68
+ }
69
+ it('participates in roving focus (data-roving-focus-item + tabindex -1)', () => {
70
+ setup({ open: true, withSub: true });
71
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
72
+ expect(subTrigger).toBeTruthy();
73
+ expect(subTrigger.hasAttribute('data-roving-focus-item')).toBe(true);
74
+ expect(subTrigger.getAttribute('tabindex')).toBe('-1');
75
+ });
76
+ it('carries both item and sub-trigger classes (visual parity hook)', () => {
77
+ setup({ open: true, withSub: true });
78
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
79
+ expect(subTrigger.classList.contains('sp-dropdown-menu-item')).toBe(true);
80
+ expect(subTrigger.classList.contains('sp-dropdown-sub-trigger')).toBe(true);
81
+ });
82
+ it('is autofocused on open when it is the first row', () => {
83
+ setup({ open: true, withSub: true, subFirst: true });
84
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
85
+ expect(document.activeElement).toBe(subTrigger);
86
+ });
87
+ it('is reached by ArrowDown/ArrowUp roving among plain items', () => {
88
+ setup({ open: true, withSub: true });
89
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
90
+ // Autofocus lands on Item 1; sub-trigger is the 4th row.
91
+ expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
92
+ pressKey('ArrowDown');
93
+ pressKey('ArrowDown');
94
+ pressKey('ArrowDown');
95
+ expect(document.activeElement).toBe(subTrigger);
96
+ pressKey('ArrowUp');
97
+ expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
98
+ });
99
+ it('is skipped by autofocus and roving when disabled', () => {
100
+ setup({ open: true, withSub: true, subFirst: true, subDisabled: true });
101
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
102
+ expect(subTrigger.hasAttribute('data-disabled')).toBe(true);
103
+ // Autofocus skips the disabled sub-trigger even though it is first.
104
+ expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
105
+ // Roving loops over the three plain items without ever landing on it.
106
+ pressKey('ArrowDown');
107
+ pressKey('ArrowDown');
108
+ pressKey('ArrowDown');
109
+ expect(document.activeElement?.textContent?.trim()).toBe('Item 1');
110
+ pressKey('ArrowUp');
111
+ expect(document.activeElement?.textContent?.trim()).toBe('Item 3');
112
+ });
113
+ it('opens the sub content on ArrowRight and reflects aria-expanded', () => {
114
+ setup({ open: true, withSub: true, subFirst: true });
115
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
116
+ expect(document.activeElement).toBe(subTrigger);
117
+ expect(subTrigger.getAttribute('aria-expanded')).toBe('false');
118
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
119
+ pressKey('ArrowRight');
120
+ expect(subTrigger.getAttribute('aria-expanded')).toBe('true');
121
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
122
+ });
123
+ it('opens the sub content on Enter', () => {
124
+ setup({ open: true, withSub: true, subFirst: true });
125
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeNull();
126
+ pressKey('Enter');
127
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
128
+ });
129
+ });
63
130
  });
@@ -57,7 +57,7 @@
57
57
  align-items: center;
58
58
  gap: 0.5rem;
59
59
  padding: 0.5rem 0.75rem;
60
- border-radius: var(--sp-sys-shape-corner-xs, 4px);
60
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
61
61
  font-family: var(--sp-sys-typescale-body-medium-font, inherit);
62
62
  font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
63
63
  color: var(--sp-sys-color-on-surface);
@@ -45,7 +45,7 @@
45
45
  align-items: center;
46
46
  gap: 0.5rem;
47
47
  padding: 0.5rem 0.75rem;
48
- border-radius: var(--sp-sys-shape-corner-xs, 4px);
48
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
49
49
  font-family: var(--sp-sys-typescale-body-medium-font, inherit);
50
50
  font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
51
51
  color: var(--sp-sys-color-on-surface);
@@ -29,6 +29,12 @@
29
29
  if (disabled) return
30
30
  if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
31
31
  e.preventDefault()
32
+ // The key is fully handled here: stop it from bubbling to ancestor menu
33
+ // keydown handlers (menubar-content treats ArrowRight as "close menu +
34
+ // open the next menubar menu", which would immediately undo openSub;
35
+ // Space would also leak into content-level typeahead). Same pattern as
36
+ // dropdown-menu-sub-content.svelte for ArrowLeft/Escape.
37
+ e.stopPropagation()
32
38
  sub.openSub()
33
39
  }
34
40
  }
@@ -47,7 +53,8 @@
47
53
  aria-expanded={sub.open}
48
54
  aria-disabled={disabled || undefined}
49
55
  data-disabled={disabled || undefined}
50
- tabindex={disabled ? -1 : 0}
56
+ data-roving-focus-item
57
+ tabindex={-1}
51
58
  onpointerenter={onPointerEnter}
52
59
  onpointerleave={onPointerLeave}
53
60
  onkeydown={onKeyDown}
@@ -71,11 +78,40 @@
71
78
  </div>
72
79
 
73
80
  <style>
81
+ /* Svelte scoped styles don't cross components: sharing the `sp-dropdown-menu-item`
82
+ class name inherits nothing from dropdown-menu-item.svelte. The item base styles
83
+ are duplicated here (same pattern as dropdown-menu-checkbox-item.svelte) and must
84
+ stay in sync with dropdown-menu-item.svelte. */
74
85
  .sp-dropdown-sub-trigger {
75
86
  display: flex;
76
87
  align-items: center;
77
88
  justify-content: space-between;
78
89
  gap: 0.5rem;
90
+ padding: 0.5rem 0.75rem;
91
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
92
+ font-family: var(--sp-sys-typescale-body-medium-font, inherit);
93
+ font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
94
+ color: var(--sp-sys-color-on-surface);
95
+ cursor: pointer;
96
+ user-select: none;
97
+ outline: none;
98
+ transition: background-color var(--sp-sys-motion-duration-short2, 100ms)
99
+ var(--sp-sys-motion-easing-effects, ease);
100
+ }
101
+
102
+ .sp-dropdown-sub-trigger:focus,
103
+ .sp-dropdown-sub-trigger:hover:not([data-disabled]),
104
+ .sp-dropdown-sub-trigger[aria-expanded='true'] {
105
+ background-color: color-mix(
106
+ in oklch,
107
+ var(--sp-sys-color-on-surface) calc(var(--sp-sys-state-hover-opacity, 0.08) * 100%),
108
+ transparent
109
+ );
110
+ }
111
+
112
+ .sp-dropdown-sub-trigger[data-disabled] {
113
+ opacity: 0.38;
114
+ cursor: not-allowed;
79
115
  }
80
116
 
81
117
  .sp-dropdown-sub-trigger-label {
@@ -1 +1 @@
1
- {"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAyD9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
1
+ {"version":3,"file":"dropdown-menu-sub-trigger.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AA+D9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -1,12 +1,19 @@
1
1
  <script lang="ts">
2
2
  import DropdownMenuItem from '../../dropdown-menu/components/dropdown-menu-item.svelte'
3
3
  import DropdownMenuSeparator from '../../dropdown-menu/components/dropdown-menu-separator.svelte'
4
+ import DropdownMenuSubContent from '../../dropdown-menu/components/dropdown-menu-sub-content.svelte'
5
+ import DropdownMenuSubTrigger from '../../dropdown-menu/components/dropdown-menu-sub-trigger.svelte'
6
+ import DropdownMenuSub from '../../dropdown-menu/components/dropdown-menu-sub.svelte'
4
7
  import MenubarContent from '../components/menubar-content.svelte'
5
8
  import MenubarMenu from '../components/menubar-menu.svelte'
6
9
  import MenubarRoot from '../components/menubar-root.svelte'
7
10
  import MenubarTrigger from '../components/menubar-trigger.svelte'
8
11
 
9
- const { loop = true, portalTo }: { loop?: boolean; portalTo?: HTMLElement | string } = $props()
12
+ const {
13
+ loop = true,
14
+ portalTo,
15
+ withSub = false,
16
+ }: { loop?: boolean; portalTo?: HTMLElement | string; withSub?: boolean } = $props()
10
17
  </script>
11
18
 
12
19
  <MenubarRoot {loop}>
@@ -17,6 +24,15 @@
17
24
  <DropdownMenuItem>Open</DropdownMenuItem>
18
25
  <DropdownMenuSeparator />
19
26
  <DropdownMenuItem>Save</DropdownMenuItem>
27
+ {#if withSub}
28
+ <DropdownMenuSub>
29
+ <DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
30
+ <DropdownMenuSubContent>
31
+ <DropdownMenuItem>Email</DropdownMenuItem>
32
+ <DropdownMenuItem>Copy Link</DropdownMenuItem>
33
+ </DropdownMenuSubContent>
34
+ </DropdownMenuSub>
35
+ {/if}
20
36
  </MenubarContent>
21
37
  </MenubarMenu>
22
38
  <MenubarMenu value="edit">
@@ -195,4 +195,39 @@ describe('menubar', () => {
195
195
  // No menu should open from hover alone
196
196
  expect(target.querySelector('[role="menu"]')).toBeNull();
197
197
  });
198
+ // --- SubTrigger inside a menubar menu ---
199
+ describe('subTrigger', () => {
200
+ function pressKey(key) {
201
+ const el = document.activeElement;
202
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
203
+ flushSync();
204
+ }
205
+ it('arrowRight on a plain item moves to the next menubar menu', () => {
206
+ setup({ withSub: true });
207
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
208
+ triggers[0].click();
209
+ flushSync();
210
+ // Autofocus lands on the first item of the opened menu
211
+ expect(document.activeElement?.textContent?.trim()).toBe('New');
212
+ pressKey('ArrowRight');
213
+ expect(triggers[0].getAttribute('data-state')).toBe('closed');
214
+ expect(triggers[1].getAttribute('data-state')).toBe('open');
215
+ });
216
+ it('arrowRight on the sub-trigger opens the submenu without switching menubar menus', () => {
217
+ setup({ withSub: true });
218
+ const triggers = target.querySelectorAll('[data-menubar-trigger]');
219
+ triggers[0].click();
220
+ flushSync();
221
+ const subTrigger = target.querySelector('.sp-dropdown-sub-trigger');
222
+ expect(subTrigger).toBeTruthy();
223
+ subTrigger.focus();
224
+ pressKey('ArrowRight');
225
+ // Submenu opened…
226
+ expect(subTrigger.getAttribute('aria-expanded')).toBe('true');
227
+ expect(target.querySelector('.sp-dropdown-menu-sub-content')).toBeTruthy();
228
+ // …and the menubar neither closed the current menu nor advanced to the next one
229
+ expect(triggers[0].getAttribute('data-state')).toBe('open');
230
+ expect(triggers[1].getAttribute('data-state')).toBe('closed');
231
+ });
232
+ });
198
233
  });
@@ -65,7 +65,7 @@
65
65
  align-items: center;
66
66
  gap: 0.5rem;
67
67
  padding: 0.5rem 0.75rem;
68
- border-radius: var(--sp-sys-shape-corner-xs, 4px);
68
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
69
69
  font-family: var(--sp-sys-typescale-body-medium-font, inherit);
70
70
  font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
71
71
  color: var(--sp-sys-color-on-surface);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spectral_labs/ui",
3
3
  "type": "module",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
7
7
  "access": "public"