@spectral_labs/ui 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/components/context-menu/components/context-menu-content.svelte +8 -7
  2. package/dist/components/context-menu/components/context-menu-content.svelte.d.ts.map +1 -1
  3. package/dist/components/context-menu/components/context-menu-root.svelte +9 -7
  4. package/dist/components/context-menu/components/context-menu-root.svelte.d.ts.map +1 -1
  5. package/dist/components/context-menu/index.d.ts +3 -0
  6. package/dist/components/context-menu/index.d.ts.map +1 -1
  7. package/dist/components/context-menu/index.js +2 -0
  8. package/dist/components/dropdown-menu/__tests__/DropdownMenuRadioTest.svelte +90 -0
  9. package/dist/components/dropdown-menu/__tests__/DropdownMenuTest.svelte +22 -1
  10. package/dist/components/dropdown-menu/__tests__/dropdown-menu-radio-group.test.svelte.js +306 -0
  11. package/dist/components/dropdown-menu/__tests__/dropdown-menu.test.svelte.js +438 -1
  12. package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte +10 -0
  13. package/dist/components/dropdown-menu/components/dropdown-menu-checkbox-item.svelte.d.ts.map +1 -1
  14. package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte +8 -7
  15. package/dist/components/dropdown-menu/components/dropdown-menu-content.svelte.d.ts.map +1 -1
  16. package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte +8 -0
  17. package/dist/components/dropdown-menu/components/dropdown-menu-item.svelte.d.ts.map +1 -1
  18. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte +45 -0
  19. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts +5 -0
  20. package/dist/components/dropdown-menu/components/dropdown-menu-radio-group.svelte.d.ts.map +1 -0
  21. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte +143 -0
  22. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts +5 -0
  23. package/dist/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.d.ts.map +1 -0
  24. package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte +9 -7
  25. package/dist/components/dropdown-menu/components/dropdown-menu-root.svelte.d.ts.map +1 -1
  26. package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte +94 -4
  27. package/dist/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.d.ts.map +1 -1
  28. package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte +32 -7
  29. package/dist/components/dropdown-menu/components/dropdown-menu-sub-trigger.svelte.d.ts.map +1 -1
  30. package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte +180 -6
  31. package/dist/components/dropdown-menu/components/dropdown-menu-sub.svelte.d.ts.map +1 -1
  32. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts +48 -2
  33. package/dist/components/dropdown-menu/dropdown-menu.svelte.d.ts.map +1 -1
  34. package/dist/components/dropdown-menu/dropdown-menu.svelte.js +16 -0
  35. package/dist/components/dropdown-menu/index.d.ts +3 -1
  36. package/dist/components/dropdown-menu/index.d.ts.map +1 -1
  37. package/dist/components/dropdown-menu/index.js +2 -0
  38. package/dist/components/dropdown-menu/types.d.ts +37 -1
  39. package/dist/components/dropdown-menu/types.d.ts.map +1 -1
  40. package/dist/components/menubar/__tests__/menubar.test.svelte.js +95 -0
  41. package/dist/components/menubar/components/menubar-content.svelte +8 -7
  42. package/dist/components/menubar/components/menubar-content.svelte.d.ts.map +1 -1
  43. package/dist/components/menubar/components/menubar-menu.svelte +8 -6
  44. package/dist/components/menubar/components/menubar-menu.svelte.d.ts.map +1 -1
  45. package/dist/components/menubar/index.d.ts +3 -1
  46. package/dist/components/menubar/index.d.ts.map +1 -1
  47. package/dist/components/menubar/index.js +2 -0
  48. package/dist/internal/escape-layer/__tests__/escape-layer.test.js +29 -0
  49. package/dist/internal/escape-layer/escape-layer.svelte.d.ts +5 -1
  50. package/dist/internal/escape-layer/escape-layer.svelte.d.ts.map +1 -1
  51. package/dist/internal/escape-layer/escape-layer.svelte.js +20 -13
  52. package/dist/internal/menu-context/index.d.ts +2 -2
  53. package/dist/internal/menu-context/index.d.ts.map +1 -1
  54. package/dist/internal/menu-context/index.js +1 -1
  55. package/dist/internal/menu-context/menu-context.svelte.d.ts +64 -2
  56. package/dist/internal/menu-context/menu-context.svelte.d.ts.map +1 -1
  57. package/dist/internal/menu-context/menu-context.svelte.js +59 -0
  58. package/dist/internal/roving-focus/roving-focus.svelte.d.ts.map +1 -1
  59. package/dist/internal/roving-focus/roving-focus.svelte.js +2 -1
  60. package/dist/internal/roving-focus/types.d.ts +6 -0
  61. package/dist/internal/roving-focus/types.d.ts.map +1 -1
  62. package/package.json +1 -1
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import type { DropdownMenuRadioItemProps } from '../types.js'
3
+ import { getMenuContext } from '../../../internal/menu-context/index.js'
4
+ import { getDropdownMenuRadioGroupContext } from '../dropdown-menu.svelte.js'
5
+
6
+ const {
7
+ value,
8
+ disabled = false,
9
+ textValue,
10
+ closeOnSelect = true,
11
+ onSelect,
12
+ class: className,
13
+ children,
14
+ // Pulled out of `rest` so the built-in behaviour can COMPOSE with them
15
+ // instead of being clobbered by the spread.
16
+ onclick,
17
+ onkeydown,
18
+ onpointerenter,
19
+ ...rest
20
+ }: DropdownMenuRadioItemProps = $props()
21
+
22
+ const group = getDropdownMenuRadioGroupContext()
23
+ const menuCtx = getMenuContext()
24
+
25
+ const checked = $derived(group.value === value)
26
+
27
+ /** Svelte's DOM attribute handlers narrow `currentTarget` to the host
28
+ * element; a bare `MouseEvent` is not assignable to that. */
29
+ type RowEvent<E extends Event> = E & { currentTarget: EventTarget & HTMLDivElement }
30
+
31
+ function select(event: Event) {
32
+ if (disabled) return
33
+ group.select(value)
34
+ onSelect?.(event)
35
+ // Re-selecting the current row still dismisses: picking the option you
36
+ // already have is a legitimate way to close the menu.
37
+ if (closeOnSelect) menuCtx.close()
38
+ }
39
+
40
+ // A disabled row is INERT, which has to include the consumer's handler:
41
+ // running it would let a dimmed, unselectable row still fire an application
42
+ // effect. Activation paths bail before anything runs.
43
+ function handleClick(event: RowEvent<MouseEvent>) {
44
+ if (disabled) return
45
+ onclick?.(event)
46
+ if (event.defaultPrevented) return
47
+ select(event)
48
+ }
49
+
50
+ function handleKeydown(event: RowEvent<KeyboardEvent>) {
51
+ if (disabled) return
52
+ onkeydown?.(event)
53
+ if (event.defaultPrevented) return
54
+ if (event.key === 'Enter' || event.key === ' ') {
55
+ event.preventDefault()
56
+ select(event)
57
+ }
58
+ }
59
+
60
+ // Deliberately NOT guarded on `disabled`: hovering a dimmed row must still
61
+ // close open sibling submenus, exactly as `Item` does.
62
+ function handlePointerEnter(event: RowEvent<PointerEvent>) {
63
+ onpointerenter?.(event)
64
+ if (event.defaultPrevented) return
65
+ // Pointer coordination: entering any row of this menu level closes open
66
+ // sibling submenus (unless the pointer travels through a grace area).
67
+ // Runs even when disabled — hovering a disabled row still closes subs.
68
+ menuCtx.onItemEnter(event)
69
+ }
70
+ </script>
71
+
72
+ <!-- `{...rest}` FIRST: a consumer may add attributes, never override the
73
+ row's identity (role, checked state, roving hooks) — those come after and
74
+ win. -->
75
+ <div
76
+ {...rest}
77
+ class="sp-dropdown-menu-item radio {className ?? ''}"
78
+ role="menuitemradio"
79
+ aria-checked={checked}
80
+ aria-disabled={disabled || undefined}
81
+ tabindex={-1}
82
+ data-roving-focus-item
83
+ data-value={value}
84
+ data-text-value={textValue}
85
+ data-disabled={disabled || undefined}
86
+ data-state={checked ? 'checked' : 'unchecked'}
87
+ onpointerenter={handlePointerEnter}
88
+ onclick={handleClick}
89
+ onkeydown={handleKeydown}
90
+ >
91
+ <span class="sp-dropdown-menu-dot">
92
+ {#if checked}
93
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
94
+ <circle cx="12" cy="12" r="5"></circle>
95
+ </svg>
96
+ {/if}
97
+ </span>
98
+ {#if children}{@render children()}{/if}
99
+ </div>
100
+
101
+ <style>
102
+ /* Same row metrics as `.checkbox` — the two families must sit on one rhythm
103
+ when a menu mixes them (Preparo's Affichage menu does). */
104
+ .sp-dropdown-menu-item.radio {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 0.5rem;
108
+ padding: 0.5rem 0.75rem;
109
+ border-radius: var(--sp-sys-shape-corner-md, 12px);
110
+ font-family: var(--sp-sys-typescale-body-medium-font, inherit);
111
+ font-size: var(--sp-sys-typescale-body-medium-size, 0.875rem);
112
+ color: var(--sp-sys-color-on-surface);
113
+ cursor: pointer;
114
+ user-select: none;
115
+ outline: none;
116
+ }
117
+
118
+ .sp-dropdown-menu-item.radio:focus-visible,
119
+ .sp-dropdown-menu-item.radio:hover:not([data-disabled]) {
120
+ background-color: color-mix(
121
+ in oklch,
122
+ var(--sp-sys-color-on-surface) calc(var(--sp-sys-state-hover-opacity, 0.08) * 100%),
123
+ transparent
124
+ );
125
+ }
126
+
127
+ .sp-dropdown-menu-item.radio[data-disabled] {
128
+ opacity: 0.38;
129
+ cursor: not-allowed;
130
+ }
131
+
132
+ .sp-dropdown-menu-dot {
133
+ width: 16px;
134
+ height: 16px;
135
+ flex-shrink: 0;
136
+ color: var(--sp-sys-color-primary);
137
+ }
138
+
139
+ .sp-dropdown-menu-dot svg {
140
+ width: 16px;
141
+ height: 16px;
142
+ }
143
+ </style>
@@ -0,0 +1,5 @@
1
+ import type { DropdownMenuRadioItemProps } from '../types.js';
2
+ declare const DropdownMenuRadioItem: import("svelte").Component<DropdownMenuRadioItemProps, {}, "">;
3
+ type DropdownMenuRadioItem = ReturnType<typeof DropdownMenuRadioItem>;
4
+ export default DropdownMenuRadioItem;
5
+ //# sourceMappingURL=dropdown-menu-radio-item.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dropdown-menu-radio-item.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-radio-item.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AA2F7D,QAAA,MAAM,qBAAqB,gEAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { DropdownMenuProps } from '../types.js'
3
- import { setMenuContext } from '../../../internal/menu-context/index.js'
3
+ import { createMenuContext, setMenuContext } from '../../../internal/menu-context/index.js'
4
4
  import { createDropdownMenuIds, setDropdownMenuContext } from '../dropdown-menu.svelte.js'
5
5
 
6
6
  let {
@@ -13,12 +13,14 @@
13
13
  const ids = createDropdownMenuIds()
14
14
  let triggerEl: HTMLElement | null = $state(null)
15
15
 
16
- setMenuContext({
17
- close() {
18
- open = false
19
- onOpenChange?.(false)
20
- },
21
- })
16
+ setMenuContext(
17
+ createMenuContext({
18
+ close() {
19
+ open = false
20
+ onOpenChange?.(false)
21
+ },
22
+ }),
23
+ )
22
24
 
23
25
  setDropdownMenuContext({
24
26
  get open() {
@@ -1 +1 @@
1
- {"version":3,"file":"dropdown-menu-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAwDpD,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"dropdown-menu-root.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-root.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AA0DpD,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -1,6 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { DropdownMenuSubContentProps } from '../types.js'
3
+ import { EscapeLayerState } from '../../../internal/escape-layer/index.js'
3
4
  import { isBrowser, kbd } from '../../../internal/helpers/index.js'
5
+ import { getOwnLevelMenuItems, isOwnLevelMenuItem } from '../../../internal/menu-context/index.js'
6
+ import { RovingFocusGroup } from '../../../internal/roving-focus/index.js'
7
+ import { TypeaheadSearch } from '../../../internal/typeahead/index.js'
4
8
  import { getDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
5
9
 
6
10
  const { class: className, children }: DropdownMenuSubContentProps = $props()
@@ -9,6 +13,59 @@
9
13
 
10
14
  let contentEl: HTMLElement | undefined = $state()
11
15
 
16
+ const typeahead = new TypeaheadSearch()
17
+ const rovingFocus = new RovingFocusGroup({
18
+ orientation: 'vertical',
19
+ loop: true,
20
+ candidateSelector: '[data-roving-focus-item]',
21
+ // Scope to our own rows: exclude rows nested in a deeper open submenu.
22
+ candidateFilter: (el) => !contentEl || isOwnLevelMenuItem(contentEl, el),
23
+ })
24
+
25
+ // The submenu mounts its own escape layer while open, so it sits on top of
26
+ // the parent content's layer in the escape stack. APG: Escape closes the
27
+ // menu containing FOCUS — behavior is resolved per keypress:
28
+ // - focus inside the sub content → 'close': Escape closes ONLY the submenu
29
+ // and restores focus to its trigger;
30
+ // - focus elsewhere (hover-opened sub while keyboard focus stayed in the
31
+ // parent menu) → 'defer-otherwise-close': the same keypress falls through
32
+ // to the parent menu's layer, which closes the whole menu (this inline
33
+ // sub unmounts with it) — no focus teleport to the sub trigger.
34
+ const escapeLayer = new EscapeLayerState({
35
+ enabled: true,
36
+ get behavior() {
37
+ return contentEl?.contains(document.activeElement) ? 'close' : 'defer-otherwise-close'
38
+ },
39
+ onEscapeKeydown() {
40
+ sub.closeSub({ restoreFocus: true })
41
+ },
42
+ })
43
+
44
+ $effect(() => {
45
+ if (sub.open && isBrowser && contentEl) {
46
+ escapeLayer.mount()
47
+ }
48
+ return () => escapeLayer.unmount()
49
+ })
50
+
51
+ // Report the content element to the Sub coordinator (grace area target,
52
+ // pointer-ownership checks).
53
+ $effect(() => {
54
+ sub.setContentEl(contentEl ?? null)
55
+ return () => sub.setContentEl(null)
56
+ })
57
+
58
+ // Keyboard open (ArrowRight/Enter/Space on the trigger) moves focus to the
59
+ // first enabled item once the content is mounted. Pointer open never sets
60
+ // the request, so hover never steals focus (APG).
61
+ $effect(() => {
62
+ if (sub.open && contentEl && sub.focusContentOnOpen) {
63
+ const firstItem = getOwnLevelMenuItems(contentEl)[0]
64
+ firstItem?.focus()
65
+ sub.consumeFocusContentRequest()
66
+ }
67
+ })
68
+
12
69
  // Position the sub-content next to the trigger using fixed positioning
13
70
  const posStyle = $derived.by(() => {
14
71
  if (!sub.open || !sub.triggerEl || !isBrowser) return ''
@@ -21,10 +78,40 @@
21
78
  })
22
79
 
23
80
  function onKeyDown(e: KeyboardEvent) {
24
- if (e.key === kbd.ARROW_LEFT || e.key === kbd.ESCAPE) {
25
- e.preventDefault()
26
- e.stopPropagation()
27
- sub.closeSub()
81
+ if (!contentEl) return
82
+
83
+ switch (e.key) {
84
+ case kbd.ARROW_LEFT:
85
+ case kbd.ESCAPE: {
86
+ e.preventDefault()
87
+ // Fully handled here: without stopPropagation, menubar-content would
88
+ // treat ArrowLeft as "switch to the previous menubar menu".
89
+ e.stopPropagation()
90
+ sub.closeSub({ restoreFocus: true })
91
+ break
92
+ }
93
+ case kbd.ARROW_DOWN:
94
+ case kbd.ARROW_UP:
95
+ case kbd.HOME:
96
+ case kbd.END: {
97
+ // Navigate our own rows; stop the event so the parent content's
98
+ // roving group (which excludes our rows) never handles it.
99
+ e.stopPropagation()
100
+ rovingFocus.handleKeydown(contentEl, e)
101
+ break
102
+ }
103
+ default: {
104
+ // Typeahead over our own rows only.
105
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
106
+ e.stopPropagation()
107
+ const items = getOwnLevelMenuItems(contentEl)
108
+ const labels = items.map(
109
+ (el) => el.getAttribute('data-text-value') ?? el.textContent?.trim() ?? '',
110
+ )
111
+ const match = typeahead.handleTypeahead(e.key, labels)
112
+ if (match >= 0) items[match]?.focus()
113
+ }
114
+ }
28
115
  }
29
116
  }
30
117
  </script>
@@ -33,7 +120,10 @@
33
120
  <div
34
121
  bind:this={contentEl}
35
122
  class="sp-dropdown-menu-sub-content {className ?? ''}"
123
+ id={sub.contentId}
36
124
  role="menu"
125
+ aria-orientation="vertical"
126
+ aria-labelledby={sub.triggerId}
37
127
  tabindex="-1"
38
128
  style={posStyle}
39
129
  onkeydown={onKeyDown}
@@ -1 +1 @@
1
- {"version":3,"file":"dropdown-menu-sub-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AA6C9D,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-content.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub-content.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAwI9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -2,7 +2,12 @@
2
2
  import type { DropdownMenuSubTriggerProps } from '../types.js'
3
3
  import { getDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
4
4
 
5
- const { disabled = false, class: className, children }: DropdownMenuSubTriggerProps = $props()
5
+ const {
6
+ disabled = false,
7
+ textValue,
8
+ class: className,
9
+ children,
10
+ }: DropdownMenuSubTriggerProps = $props()
6
11
 
7
12
  const sub = getDropdownMenuSubContext()
8
13
 
@@ -13,18 +18,34 @@
13
18
  if (el) sub.setTriggerEl(el)
14
19
  })
15
20
 
16
- function onPointerEnter() {
17
- if (disabled) return
18
- hoverTimer = setTimeout(() => sub.openSub(), 150)
19
- }
21
+ // Cancel a pending hover-open when the trigger unmounts mid-delay.
22
+ $effect(() => {
23
+ return () => clearHoverTimer()
24
+ })
20
25
 
21
- function onPointerLeave() {
26
+ function clearHoverTimer() {
22
27
  if (hoverTimer) {
23
28
  clearTimeout(hoverTimer)
24
29
  hoverTimer = null
25
30
  }
26
31
  }
27
32
 
33
+ function onPointerEnter(e: PointerEvent) {
34
+ // Pointer coordination runs even when disabled: hovering a disabled sub
35
+ // trigger still closes sibling submenus (it is a row of the parent menu).
36
+ sub.onTriggerEnter(e)
37
+ if (disabled) return
38
+ if (sub.open) return
39
+ clearHoverTimer()
40
+ hoverTimer = setTimeout(() => sub.openSub(), 150)
41
+ }
42
+
43
+ function onPointerLeave(e: PointerEvent) {
44
+ clearHoverTimer()
45
+ // If the submenu is open, arm the grace area toward the content.
46
+ sub.onTriggerLeave(e)
47
+ }
48
+
28
49
  function onKeyDown(e: KeyboardEvent) {
29
50
  if (disabled) return
30
51
  if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
@@ -35,7 +56,8 @@
35
56
  // Space would also leak into content-level typeahead). Same pattern as
36
57
  // dropdown-menu-sub-content.svelte for ArrowLeft/Escape.
37
58
  e.stopPropagation()
38
- sub.openSub()
59
+ // Keyboard open moves focus into the submenu (APG); pointer open never does.
60
+ sub.openSub({ focusContent: true })
39
61
  }
40
62
  }
41
63
 
@@ -48,12 +70,15 @@
48
70
  <div
49
71
  bind:this={el}
50
72
  class="sp-dropdown-menu-item sp-dropdown-sub-trigger {className ?? ''}"
73
+ id={sub.triggerId}
51
74
  role="menuitem"
52
75
  aria-haspopup="menu"
53
76
  aria-expanded={sub.open}
77
+ aria-controls={sub.open ? sub.contentId : undefined}
54
78
  aria-disabled={disabled || undefined}
55
79
  data-disabled={disabled || undefined}
56
80
  data-roving-focus-item
81
+ data-text-value={textValue}
57
82
  tabindex={-1}
58
83
  onpointerenter={onPointerEnter}
59
84
  onpointerleave={onPointerLeave}
@@ -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;AA+D9D,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;AAqF9D,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -1,31 +1,205 @@
1
1
  <script lang="ts">
2
+ import type { MenuSubHandle } from '../../../internal/menu-context/index.js'
2
3
  import type { DropdownMenuSubProps } from '../types.js'
4
+ import { GraceAreaState } from '../../../internal/grace-area/index.js'
5
+ import { useId } from '../../../internal/helpers/index.js'
6
+ import {
7
+ createMenuContext,
8
+ getMenuContext,
9
+ setMenuContext,
10
+ } from '../../../internal/menu-context/index.js'
3
11
  import { setDropdownMenuSubContext } from '../dropdown-menu.svelte.js'
4
12
 
5
13
  const { open: openProp = false, class: className, children }: DropdownMenuSubProps = $props()
6
14
 
15
+ // Parent menu level (root menu context, or the enclosing sub for nested subs).
16
+ // Captured BEFORE overriding the context for our own descendants.
17
+ const parentMenu = getMenuContext()
18
+
19
+ // Trigger ↔ content ARIA wiring (same id plumbing as the parent level:
20
+ // aria-controls on the trigger, aria-labelledby on the content).
21
+ const triggerId = useId('sp-dm-sub-trigger')
22
+ const contentId = useId('sp-dm-sub-content')
23
+
7
24
  let open = $state(openProp)
8
25
  let triggerEl: HTMLElement | null = $state(null)
26
+ let contentEl: HTMLElement | null = $state(null)
27
+ let focusContentOnOpen = $state(false)
28
+
29
+ // Grace area between the trigger and the sub content: when the pointer
30
+ // leaves the trigger toward the content, the triangular polygon from the
31
+ // exit point to the content's near edge keeps the submenu open during the
32
+ // diagonal travel (same layer as tooltip-trigger / link-preview-trigger).
33
+ const graceArea = new GraceAreaState({
34
+ get triggerElement() {
35
+ return triggerEl
36
+ },
37
+ get contentElement() {
38
+ return contentEl
39
+ },
40
+ get enabled() {
41
+ return open
42
+ },
43
+ })
44
+
45
+ let graceCleanup: (() => void) | null = null
46
+
47
+ function stopGraceTracking() {
48
+ if (graceCleanup) {
49
+ graceCleanup()
50
+ graceCleanup = null
51
+ }
52
+ graceArea.reset()
53
+ }
54
+
55
+ function ownsTarget(target: EventTarget | null): boolean {
56
+ if (!(target instanceof Node)) return false
57
+ return Boolean(triggerEl?.contains(target) || contentEl?.contains(target))
58
+ }
59
+
60
+ function startGraceTracking(exitPoint: { x: number; y: number }) {
61
+ stopGraceTracking()
62
+ graceArea.setPointerExit(exitPoint)
63
+
64
+ const onPointerMove = (e: PointerEvent) => {
65
+ // Pointer reached the sub content (or came back on the trigger):
66
+ // resolved — stay open and stop tracking.
67
+ if (ownsTarget(e.target)) {
68
+ stopGraceTracking()
69
+ return
70
+ }
71
+ // Still traveling through the grace polygon: stay open, keep tracking.
72
+ if (graceArea.isPointInGraceArea({ x: e.clientX, y: e.clientY })) return
73
+ // Left trigger, polygon and content: close.
74
+ stopGraceTracking()
75
+ closeSub()
76
+ }
77
+ document.addEventListener('pointermove', onPointerMove)
78
+
79
+ // Safety: if the pointer never resolves the travel (e.g. stalls inside
80
+ // the polygon), close after a timeout — mirrors the tooltip/link-preview
81
+ // grace wiring, with the timer cleaned up rigorously on resolution.
82
+ const safetyTimer = setTimeout(() => {
83
+ stopGraceTracking()
84
+ closeSub()
85
+ }, 500)
86
+
87
+ graceCleanup = () => {
88
+ document.removeEventListener('pointermove', onPointerMove)
89
+ clearTimeout(safetyTimer)
90
+ }
91
+ }
92
+
93
+ // Handle registered on the parent menu level (see the registration effect
94
+ // below) so sibling rows (plain items, checkbox items, other sub triggers)
95
+ // close this submenu on pointerenter — unless they own it or the pointer is
96
+ // traveling through the grace area. `openSub` also passes it to
97
+ // `closeOtherSubs` to enforce the single-open-sub invariant.
98
+ const handle: MenuSubHandle = {
99
+ ownsTarget,
100
+ isPointerInGrace(point) {
101
+ return graceArea.isPointInGraceArea(point)
102
+ },
103
+ closeSub() {
104
+ closeSub()
105
+ },
106
+ }
107
+
108
+ function openSub(options?: { focusContent?: boolean }) {
109
+ // Single open sub per level (Radix semantics): opening this sub
110
+ // force-closes every sibling at the parent level. Covers the paths that
111
+ // never go through `onItemEnter` pointer coordination — keyboard opens
112
+ // (ArrowRight/Enter/Space) — and a hover-open fired from inside a
113
+ // sibling's grace polygon (the sibling was spared by the grace check but
114
+ // must yield once this sub actually opens). Closing the sibling also
115
+ // cancels its grace tracking and safety timer (`closeSub` →
116
+ // `stopGraceTracking`).
117
+ parentMenu?.closeOtherSubs(handle)
118
+ if (options?.focusContent) focusContentOnOpen = true
119
+ open = true
120
+ }
121
+
122
+ function closeSub(options?: { restoreFocus?: boolean }) {
123
+ stopGraceTracking()
124
+ focusContentOnOpen = false
125
+ // Mixed-modality guard: pointer paths close without asking for a focus
126
+ // restore, but if keyboard focus currently lives inside the sub content,
127
+ // unmounting it would drop focus to <body> — arrow keys and typeahead go
128
+ // dead because the parent menu's keydown handler lives on its content
129
+ // element (the exact #197 dead-end). Capture BEFORE flipping `open`
130
+ // (the close effect nulls contentEl on unmount).
131
+ const focusWasInside = Boolean(
132
+ contentEl && document.activeElement && contentEl.contains(document.activeElement),
133
+ )
134
+ open = false
135
+ // Restore focus to the trigger (a row of the parent menu, so parent
136
+ // roving/typeahead keep working) when explicitly requested (keyboard
137
+ // paths) or whenever focus would otherwise be orphaned.
138
+ if (options?.restoreFocus || focusWasInside) triggerEl?.focus()
139
+ }
140
+
141
+ $effect(() => {
142
+ if (!parentMenu) return
143
+ return parentMenu.registerSub(handle)
144
+ })
145
+
146
+ // Stop grace tracking whenever the submenu closes or unmounts (no leaked
147
+ // document listeners / timers).
148
+ $effect(() => {
149
+ if (open) {
150
+ return () => stopGraceTracking()
151
+ }
152
+ })
153
+
154
+ // Our own descendants (rows inside the sub content) coordinate nested
155
+ // submenus against THIS level, while `close()` still dismisses the whole
156
+ // menu tree via the parent context.
157
+ setMenuContext(
158
+ createMenuContext({
159
+ close() {
160
+ parentMenu?.close()
161
+ },
162
+ }),
163
+ )
9
164
 
10
165
  setDropdownMenuSubContext({
166
+ triggerId,
167
+ contentId,
11
168
  get open() {
12
169
  return open
13
170
  },
14
171
  get triggerEl() {
15
172
  return triggerEl
16
173
  },
17
- toggle() {
18
- open = !open
174
+ get contentEl() {
175
+ return contentEl
19
176
  },
20
- openSub() {
21
- open = true
177
+ get focusContentOnOpen() {
178
+ return focusContentOnOpen
22
179
  },
23
- closeSub() {
24
- open = false
180
+ toggle() {
181
+ if (open) closeSub()
182
+ else openSub()
183
+ },
184
+ openSub,
185
+ closeSub,
186
+ consumeFocusContentRequest() {
187
+ focusContentOnOpen = false
25
188
  },
26
189
  setTriggerEl(el: HTMLElement) {
27
190
  triggerEl = el
28
191
  },
192
+ setContentEl(el: HTMLElement | null) {
193
+ contentEl = el
194
+ },
195
+ onTriggerEnter(event: PointerEvent) {
196
+ stopGraceTracking()
197
+ parentMenu?.onItemEnter(event)
198
+ },
199
+ onTriggerLeave(event: PointerEvent) {
200
+ if (!open || !contentEl) return
201
+ startGraceTracking({ x: event.clientX, y: event.clientY })
202
+ },
29
203
  })
30
204
  </script>
31
205
 
@@ -1 +1 @@
1
- {"version":3,"file":"dropdown-menu-sub.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA0CvD,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"dropdown-menu-sub.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/dropdown-menu/components/dropdown-menu-sub.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AA2NvD,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -14,13 +14,59 @@ export declare function createDropdownMenuIds(): {
14
14
  triggerId: string;
15
15
  };
16
16
  export interface DropdownMenuSubContext {
17
+ /** Id of the sub trigger row (aria-labelledby target of the sub content). */
18
+ readonly triggerId: string;
19
+ /** Id of the sub content (aria-controls target of the sub trigger). */
20
+ readonly contentId: string;
17
21
  readonly open: boolean;
18
22
  readonly triggerEl: HTMLElement | null;
23
+ readonly contentEl: HTMLElement | null;
24
+ /**
25
+ * Whether the next content mount should focus its first item
26
+ * (set by keyboard open — APG focus-into-sub; never by pointer open).
27
+ */
28
+ readonly focusContentOnOpen: boolean;
19
29
  toggle: () => void;
20
- openSub: () => void;
21
- closeSub: () => void;
30
+ openSub: (options?: {
31
+ focusContent?: boolean;
32
+ }) => void;
33
+ closeSub: (options?: {
34
+ restoreFocus?: boolean;
35
+ }) => void;
36
+ /** Marks the pending focus-into-sub request as handled. */
37
+ consumeFocusContentRequest: () => void;
22
38
  setTriggerEl: (el: HTMLElement) => void;
39
+ setContentEl: (el: HTMLElement | null) => void;
40
+ /**
41
+ * Pointer entered the sub trigger: closes sibling submenus at the parent
42
+ * level and cancels any pending grace-area close tracking.
43
+ */
44
+ onTriggerEnter: (event: PointerEvent) => void;
45
+ /**
46
+ * Pointer left the sub trigger: if the submenu is open, starts grace-area
47
+ * tracking — the submenu stays open while the pointer travels through the
48
+ * grace polygon toward the content, and closes otherwise.
49
+ */
50
+ onTriggerLeave: (event: PointerEvent) => void;
23
51
  }
24
52
  export declare function setDropdownMenuSubContext(state: DropdownMenuSubContext): void;
25
53
  export declare function getDropdownMenuSubContext(): DropdownMenuSubContext;
54
+ /**
55
+ * The single-choice contract shared by a `RadioGroup` and its `RadioItem`
56
+ * rows. `value` is a GETTER, never a snapshot: the group owns a `$bindable`
57
+ * prop, and a row reading `ctx.value` while rendering must see the current
58
+ * selection — including one changed from OUTSIDE the menu.
59
+ */
60
+ export interface DropdownMenuRadioGroupContext {
61
+ readonly value: string | undefined;
62
+ /** Selects `value`. A no-op — and no notification — when already selected. */
63
+ select: (value: string) => void;
64
+ }
65
+ export declare function setDropdownMenuRadioGroupContext(state: DropdownMenuRadioGroupContext): void;
66
+ /**
67
+ * Throws a NAMED error instead of returning `undefined`: an orphan
68
+ * `RadioItem` would otherwise fail later with an opaque property-access
69
+ * TypeError, at a point that no longer names the mistake.
70
+ */
71
+ export declare function getDropdownMenuRadioGroupContext(): DropdownMenuRadioGroupContext;
26
72
  //# sourceMappingURL=dropdown-menu.svelte.d.ts.map