@svgrid/grid 2.1.22 → 2.1.23

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 (219) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/GridMenus.svelte +83 -3
  7. package/dist/SvAutoComplete.svelte +5 -3
  8. package/dist/SvCarousel.svelte +20 -42
  9. package/dist/SvColorInput.svelte +4 -3
  10. package/dist/SvColorInput.svelte.d.ts +1 -1
  11. package/dist/SvComboBox.svelte +7 -3
  12. package/dist/SvCommand.svelte +28 -85
  13. package/dist/SvCountryInput.svelte +1 -1
  14. package/dist/SvDateRangeInput.svelte +1 -1
  15. package/dist/SvDateTimePicker.svelte +9 -3
  16. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  17. package/dist/SvDockLayout.svelte +244 -0
  18. package/dist/SvDockLayout.svelte.d.ts +77 -0
  19. package/dist/SvDockManager.svelte +922 -0
  20. package/dist/SvDockManager.svelte.d.ts +106 -0
  21. package/dist/SvDrawer.svelte +69 -47
  22. package/dist/SvDrawer.svelte.d.ts +4 -0
  23. package/dist/SvDropDownList.svelte +85 -13
  24. package/dist/SvDropDownList.svelte.d.ts +3 -0
  25. package/dist/SvField.svelte +21 -4
  26. package/dist/SvField.svelte.d.ts +1 -0
  27. package/dist/SvForm.svelte +201 -91
  28. package/dist/SvForm.svelte.d.ts +15 -22
  29. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  30. package/dist/SvGrid.controller.svelte.js +156 -27
  31. package/dist/SvGrid.css +193 -5
  32. package/dist/SvGrid.svelte +357 -12
  33. package/dist/SvGrid.types.d.ts +452 -1
  34. package/dist/SvGridDropdown.svelte +8 -1
  35. package/dist/SvGridSelect.svelte +1 -0
  36. package/dist/SvListBox.svelte +36 -14
  37. package/dist/SvMaskedInput.svelte +4 -3
  38. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  39. package/dist/SvMenu.svelte +1 -1
  40. package/dist/SvMenuList.svelte +23 -71
  41. package/dist/SvMenuList.svelte.d.ts +2 -13
  42. package/dist/SvModal.svelte +12 -29
  43. package/dist/SvMultiSelect.svelte +1 -0
  44. package/dist/SvNumberInput.svelte +4 -3
  45. package/dist/SvNumberInput.svelte.d.ts +1 -1
  46. package/dist/SvPagination.svelte +18 -18
  47. package/dist/SvPasswordInput.svelte +3 -2
  48. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  49. package/dist/SvPhoneInput.svelte +3 -2
  50. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  51. package/dist/SvPopover.svelte +1 -1
  52. package/dist/SvStepper.svelte +6 -9
  53. package/dist/SvTagsInput.svelte +3 -2
  54. package/dist/SvTagsInput.svelte.d.ts +1 -1
  55. package/dist/SvTooltip.svelte +12 -23
  56. package/dist/SvTour.svelte +32 -4
  57. package/dist/SvTreeSelect.svelte +1 -0
  58. package/dist/build-api.js +76 -54
  59. package/dist/cdn/svgrid.js +10907 -6444
  60. package/dist/cdn/svgrid.svelte-external.js +9622 -5475
  61. package/dist/columns.js +6 -0
  62. package/dist/conditional-formatting.d.ts +67 -3
  63. package/dist/conditional-formatting.js +124 -17
  64. package/dist/createCarousel.svelte.d.ts +72 -0
  65. package/dist/createCarousel.svelte.js +80 -0
  66. package/dist/createCommand.svelte.d.ts +62 -0
  67. package/dist/createCommand.svelte.js +109 -0
  68. package/dist/createForm.svelte.d.ts +62 -0
  69. package/dist/createForm.svelte.js +374 -0
  70. package/dist/createMenu.svelte.d.ts +88 -0
  71. package/dist/createMenu.svelte.js +153 -0
  72. package/dist/createOverlay.svelte.d.ts +38 -0
  73. package/dist/createOverlay.svelte.js +68 -0
  74. package/dist/createPagination.d.ts +74 -0
  75. package/dist/createPagination.js +71 -0
  76. package/dist/createPopoverSelect.svelte.js +6 -12
  77. package/dist/createStepper.d.ts +44 -0
  78. package/dist/createStepper.js +26 -0
  79. package/dist/createTooltip.svelte.d.ts +32 -0
  80. package/dist/createTooltip.svelte.js +61 -0
  81. package/dist/datetime/timezone.d.ts +36 -0
  82. package/dist/datetime/timezone.js +110 -0
  83. package/dist/dock-context.d.ts +52 -0
  84. package/dist/dock-context.js +1 -0
  85. package/dist/dock-manager-model.d.ts +136 -0
  86. package/dist/dock-manager-model.js +514 -0
  87. package/dist/dock-model.d.ts +105 -0
  88. package/dist/dock-model.js +288 -0
  89. package/dist/dock-popout.d.ts +30 -0
  90. package/dist/dock-popout.js +81 -0
  91. package/dist/editor-contract.d.ts +2 -0
  92. package/dist/filter-operators.d.ts +6 -0
  93. package/dist/filter-operators.js +35 -2
  94. package/dist/filtering/excel-filters.d.ts +18 -1
  95. package/dist/filtering/excel-filters.js +56 -0
  96. package/dist/form-field.d.ts +73 -0
  97. package/dist/form-field.js +15 -0
  98. package/dist/index.d.ts +25 -5
  99. package/dist/index.js +40 -4
  100. package/dist/list-nav.d.ts +18 -0
  101. package/dist/list-nav.js +30 -0
  102. package/dist/list-option.d.ts +2 -0
  103. package/dist/menu-item.d.ts +20 -0
  104. package/dist/menu-item.js +1 -0
  105. package/dist/menus.d.ts +5 -0
  106. package/dist/menus.js +68 -16
  107. package/dist/panel-resize.d.ts +25 -0
  108. package/dist/panel-resize.js +40 -0
  109. package/dist/popover.d.ts +29 -3
  110. package/dist/popover.js +22 -6
  111. package/dist/recurrence.d.ts +33 -7
  112. package/dist/recurrence.js +151 -13
  113. package/dist/row-drag.d.ts +8 -0
  114. package/dist/row-drag.js +11 -0
  115. package/dist/scheduler-ical.d.ts +30 -0
  116. package/dist/scheduler-ical.js +180 -0
  117. package/dist/scheduler-model.d.ts +269 -0
  118. package/dist/scheduler-model.js +602 -0
  119. package/dist/scheduler-view.svelte.d.ts +22 -0
  120. package/dist/scheduler-view.svelte.js +13 -0
  121. package/dist/spreadsheet.js +20 -12
  122. package/dist/ui-app.types.d.ts +3 -0
  123. package/package.json +5 -1
  124. package/src/DockNodeView.svelte +306 -0
  125. package/src/DockPopoutHost.svelte +17 -0
  126. package/src/GridMenus.svelte +83 -3
  127. package/src/SvAutoComplete.svelte +5 -3
  128. package/src/SvCarousel.svelte +20 -42
  129. package/src/SvCarousel.test.ts +68 -0
  130. package/src/SvColorInput.svelte +4 -3
  131. package/src/SvComboBox.svelte +7 -3
  132. package/src/SvCommand.svelte +28 -85
  133. package/src/SvCommand.test.ts +78 -0
  134. package/src/SvCountryInput.svelte +1 -1
  135. package/src/SvDateRangeInput.svelte +1 -1
  136. package/src/SvDateTimePicker.svelte +9 -3
  137. package/src/SvDockLayout.svelte +244 -0
  138. package/src/SvDockManager.svelte +922 -0
  139. package/src/SvDrawer.svelte +69 -47
  140. package/src/SvDropDownList.svelte +85 -13
  141. package/src/SvField.svelte +21 -4
  142. package/src/SvForm.svelte +201 -91
  143. package/src/SvForm.test.ts +299 -0
  144. package/src/SvGrid.controller.svelte.ts +152 -28
  145. package/src/SvGrid.css +193 -5
  146. package/src/SvGrid.svelte +357 -12
  147. package/src/SvGrid.types.ts +463 -1
  148. package/src/SvGridDropdown.svelte +8 -1
  149. package/src/SvGridSelect.svelte +1 -0
  150. package/src/SvListBox.svelte +36 -14
  151. package/src/SvMaskedInput.svelte +4 -3
  152. package/src/SvMenu.svelte +1 -1
  153. package/src/SvMenu.test.ts +24 -0
  154. package/src/SvMenuList.svelte +23 -71
  155. package/src/SvModal.svelte +12 -29
  156. package/src/SvModal.test.ts +82 -0
  157. package/src/SvMultiSelect.svelte +1 -0
  158. package/src/SvNumberInput.svelte +4 -3
  159. package/src/SvPagination.svelte +18 -18
  160. package/src/SvPasswordInput.svelte +3 -2
  161. package/src/SvPhoneInput.svelte +3 -2
  162. package/src/SvPopover.svelte +1 -1
  163. package/src/SvStepper.svelte +6 -9
  164. package/src/SvTagsInput.svelte +3 -2
  165. package/src/SvTooltip.svelte +12 -23
  166. package/src/SvTooltip.test.ts +73 -0
  167. package/src/SvTour.svelte +32 -4
  168. package/src/SvTreeSelect.svelte +1 -0
  169. package/src/build-api.ts +74 -51
  170. package/src/columns.ts +5 -0
  171. package/src/conditional-formatting.test.ts +104 -0
  172. package/src/conditional-formatting.ts +176 -19
  173. package/src/createCarousel.svelte.ts +104 -0
  174. package/src/createCommand.svelte.ts +125 -0
  175. package/src/createForm.svelte.ts +333 -0
  176. package/src/createMenu.svelte.ts +202 -0
  177. package/src/createOverlay.svelte.ts +101 -0
  178. package/src/createPagination.test.ts +65 -0
  179. package/src/createPagination.ts +84 -0
  180. package/src/createPopoverSelect.svelte.ts +5 -9
  181. package/src/createStepper.test.ts +62 -0
  182. package/src/createStepper.ts +61 -0
  183. package/src/createTooltip.svelte.ts +86 -0
  184. package/src/datetime/timezone.test.ts +69 -0
  185. package/src/datetime/timezone.ts +134 -0
  186. package/src/dock-context.ts +47 -0
  187. package/src/dock-manager-model.test.ts +275 -0
  188. package/src/dock-manager-model.ts +593 -0
  189. package/src/dock-model.test.ts +163 -0
  190. package/src/dock-model.ts +368 -0
  191. package/src/dock-popout.ts +94 -0
  192. package/src/editor-contract.ts +2 -0
  193. package/src/filter-operators.test.ts +59 -0
  194. package/src/filter-operators.ts +35 -2
  195. package/src/filtering/excel-filters.test.ts +86 -1
  196. package/src/filtering/excel-filters.ts +65 -0
  197. package/src/form-field.ts +87 -0
  198. package/src/index.ts +158 -4
  199. package/src/list-nav.test.ts +49 -0
  200. package/src/list-nav.ts +29 -0
  201. package/src/list-option.ts +2 -0
  202. package/src/menu-item.ts +21 -0
  203. package/src/menus.test.ts +48 -0
  204. package/src/menus.ts +73 -16
  205. package/src/panel-resize.ts +52 -0
  206. package/src/popover.bounds.test.ts +93 -0
  207. package/src/popover.ts +51 -7
  208. package/src/recurrence.test.ts +88 -1
  209. package/src/recurrence.ts +166 -15
  210. package/src/row-drag.ts +10 -0
  211. package/src/scheduler-ical.test.ts +68 -0
  212. package/src/scheduler-ical.ts +180 -0
  213. package/src/scheduler-model.test.ts +546 -0
  214. package/src/scheduler-model.ts +853 -0
  215. package/src/scheduler-view.svelte.ts +33 -0
  216. package/src/spreadsheet.ts +20 -12
  217. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  218. package/src/ui-app.types.ts +3 -0
  219. package/src/virtual.test.ts +6 -4
@@ -56,6 +56,30 @@ describe('SvMenu', () => {
56
56
  } finally { destroy() }
57
57
  })
58
58
 
59
+ it('ArrowDown moves the roving tab stop and Enter activates via keyboard', () => {
60
+ let chosen: MenuItem | undefined
61
+ const { target, destroy } = mountMenu({ items, onSelect: (i: MenuItem) => (chosen = i) })
62
+ try {
63
+ target.querySelector<HTMLElement>('.trigger')!.click()
64
+ flushSync()
65
+ const btns = () => [...menu()!.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
66
+ const cut = btns().find((b) => b.textContent?.includes('Cut'))!
67
+ // From nothing focused, ArrowDown lands on the first item, then the next.
68
+ cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
69
+ flushSync()
70
+ cut.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
71
+ flushSync()
72
+ const copy = btns().find((b) => b.textContent?.includes('Copy'))!
73
+ expect(copy.tabIndex).toBe(0)
74
+ expect(cut.tabIndex).toBe(-1)
75
+ // Enter on the focused item activates it and closes the menu.
76
+ copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
77
+ flushSync()
78
+ expect(chosen?.label).toBe('Copy')
79
+ expect(menu()).toBeNull()
80
+ } finally { destroy() }
81
+ })
82
+
59
83
  it('opens a submenu on pointer-enter of a parent item', () => {
60
84
  const { target, destroy } = mountMenu({ items })
61
85
  try {
@@ -1,17 +1,8 @@
1
1
  <script lang="ts" module>
2
- import type { Snippet } from 'svelte'
3
- export type MenuItem = {
4
- /** Item text. Omit + set `separator` for a divider. */
5
- label?: string
6
- separator?: boolean
7
- icon?: Snippet
8
- /** Right-aligned hint, e.g. a keyboard shortcut. */
9
- shortcut?: string
10
- disabled?: boolean
11
- /** Nested submenu items (renders a flyout). */
12
- children?: MenuItem[]
13
- onSelect?: () => void
14
- }
2
+ // MenuItem lives in a plain `.ts` module (breaks a Vite dev import cycle); it is
3
+ // re-exported here so existing `import { type MenuItem } from './SvMenuList.svelte'`
4
+ // sites keep working.
5
+ export type { MenuItem } from './menu-item'
15
6
  </script>
16
7
 
17
8
  <script lang="ts">
@@ -22,6 +13,8 @@
22
13
  */
23
14
  import Self from './SvMenuList.svelte'
24
15
  import { type EditorDir } from './editor-contract'
16
+ import type { MenuItem } from './menu-item'
17
+ import { createMenu } from './createMenu.svelte'
25
18
 
26
19
  let {
27
20
  items,
@@ -46,56 +39,24 @@
46
39
  dir?: EditorDir
47
40
  } = $props()
48
41
 
49
- // Indexes of real (non-separator) items, for roving focus.
50
- const focusable = $derived(items.map((it, i) => (it.separator || it.disabled ? -1 : i)).filter((i) => i >= 0))
51
- let active = $state(-1)
52
- let openSub = $state(-1)
53
42
  let listEl = $state<HTMLDivElement | null>(null)
54
43
 
55
- function focusItem(i: number) {
56
- active = i
57
- queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-mi="${i}"]`)?.focus())
58
- }
59
- function move(delta: number) {
60
- if (!focusable.length) return
61
- const pos = focusable.indexOf(active)
62
- const next = focusable[(pos + delta + focusable.length) % focusable.length]
63
- if (next != null) { openSub = -1; focusItem(next) }
64
- }
65
- function choose(item: MenuItem) {
66
- if (item.disabled || item.separator) return
67
- if (item.children?.length) { openSub = items.indexOf(item); return }
68
- item.onSelect?.()
69
- onselect(item)
70
- onclose()
71
- }
72
- function onKeydown(e: KeyboardEvent, i: number) {
73
- const item = items[i]
74
- const rtl = dir === 'rtl'
75
- // Open this item's submenu (opposite key under rtl).
76
- const openThis = () => { if (item?.children?.length) { e.preventDefault(); openSub = i } }
77
- // Collapse just THIS list back into its parent item (opposite key under
78
- // rtl) - `oncollapse` is local to this nesting level, unlike `onclose`.
79
- const collapseThis = () => { if (submenu) { e.preventDefault(); oncollapse?.() } }
80
- switch (e.key) {
81
- case 'ArrowDown': e.preventDefault(); move(1); break
82
- case 'ArrowUp': e.preventDefault(); move(-1); break
83
- case 'Home': e.preventDefault(); focusItem(focusable[0] ?? 0); break
84
- case 'End': e.preventDefault(); focusItem(focusable.at(-1) ?? 0); break
85
- case 'ArrowRight': (rtl ? collapseThis : openThis)(); break
86
- case 'ArrowLeft': (rtl ? openThis : collapseThis)(); break
87
- case 'Enter':
88
- case ' ': e.preventDefault(); if (item) choose(item); break
89
- case 'Escape': e.preventDefault(); onclose(); break
90
- // Tab leaves the menu entirely (WAI-ARIA menu button pattern): close the
91
- // whole tree rather than trapping focus inside a portalled panel.
92
- case 'Tab': onclose(); break
93
- }
94
- }
44
+ // Roving focus, submenu open/collapse and the full keyboard contract live in
45
+ // the shared headless core; this component only renders + drives real DOM
46
+ // focus (the core stays DOM-free). See createMenu.svelte.ts.
47
+ const menu = createMenu({
48
+ items: () => items,
49
+ onSelect: (item) => onselect(item),
50
+ onClose: () => onclose(),
51
+ submenu: () => submenu,
52
+ onCollapse: () => oncollapse?.(),
53
+ dir: () => dir,
54
+ focusItem: (i) => queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-mi="${i}"]`)?.focus()),
55
+ })
95
56
  </script>
96
57
 
97
58
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
98
- <div bind:this={listEl} class="sv-menu__list" class:is-submenu={submenu} role="menu">
59
+ <div bind:this={listEl} class="sv-menu__list" class:is-submenu={submenu} {...menu.listProps()}>
99
60
  {#each items as item, i (i)}
100
61
  {#if item.separator}
101
62
  <div class="sv-menu__sep" role="separator"></div>
@@ -104,30 +65,21 @@
104
65
  <button
105
66
  type="button"
106
67
  class="sv-menu__item"
107
- class:is-active={active === i}
108
- data-mi={i}
109
- role="menuitem"
110
- aria-haspopup={item.children?.length ? 'menu' : undefined}
111
- aria-expanded={item.children?.length ? openSub === i : undefined}
112
- aria-disabled={item.disabled || undefined}
113
- disabled={item.disabled}
114
- tabindex={active === i || (active === -1 && i === focusable[0]) ? 0 : -1}
115
- onpointerenter={() => { if (!item.disabled) { active = i; openSub = item.children?.length ? i : -1 } }}
116
- onkeydown={(e) => onKeydown(e, i)}
117
- onclick={() => choose(item)}
68
+ class:is-active={menu.isActive(i)}
69
+ {...menu.itemProps(i)}
118
70
  >
119
71
  <span class="sv-menu__icon">{#if item.icon}{@render item.icon()}{/if}</span>
120
72
  <span class="sv-menu__label">{item.label}</span>
121
73
  {#if item.shortcut}<span class="sv-menu__shortcut">{item.shortcut}</span>{/if}
122
74
  {#if item.children?.length}<svg class="sv-menu__chev" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>{/if}
123
75
  </button>
124
- {#if item.children?.length && openSub === i}
76
+ {#if item.children?.length && menu.isSubOpen(i)}
125
77
  <div class="sv-menu__flyout">
126
78
  <Self
127
79
  items={item.children}
128
80
  {onclose}
129
81
  {onselect}
130
- oncollapse={() => { openSub = -1; focusItem(i) }}
82
+ oncollapse={() => menu.focus(i)}
131
83
  {dir}
132
84
  submenu
133
85
  />
@@ -15,9 +15,7 @@
15
15
  import type { Snippet } from 'svelte'
16
16
  import { portalToBody } from './popover'
17
17
  import { nextEditorId } from './editor-contract'
18
- import { createFocusTrap } from './a11y/focus-trap'
19
- import { lockScroll } from './a11y/scroll-lock'
20
- import { createDismissableLayer } from './a11y/dismissable'
18
+ import { createOverlay } from './createOverlay.svelte'
21
19
 
22
20
  type Props = {
23
21
  open?: boolean
@@ -55,28 +53,16 @@
55
53
 
56
54
  function close() { open = false; onClose?.() }
57
55
 
58
- // Reset transient drag/resize each time it opens, then hand focus, scroll-lock
59
- // and Escape/backdrop dismissal to the shared a11y primitives (one tested
60
- // implementation, and nested overlays close top-first via the layer stack).
61
- $effect(() => {
62
- if (!open || !dialogEl) return
63
- drag = { x: 0, y: 0 }
64
- box = null
65
- const trap = createFocusTrap(dialogEl)
66
- trap.activate()
67
- const unlockScroll = lockScroll()
68
- const layer = createDismissableLayer({
69
- element: () => dialogEl,
70
- onDismiss: (reason) => {
71
- if (reason === 'escape' ? closeOnEsc : closeOnBackdrop) close()
72
- },
73
- })
74
- layer.activate()
75
- return () => {
76
- layer.release()
77
- unlockScroll()
78
- trap.release()
79
- }
56
+ // Focus trap + scroll lock + Escape/backdrop dismissal come from the shared
57
+ // headless overlay core; this component resets its transient drag/resize on
58
+ // open and renders the panel. See createOverlay.svelte.ts.
59
+ const overlay = createOverlay({
60
+ open: () => open,
61
+ getDialog: () => dialogEl,
62
+ onClose: close,
63
+ closeOnEsc: () => closeOnEsc,
64
+ closeOnBackdrop: () => closeOnBackdrop,
65
+ onOpen: () => { drag = { x: 0, y: 0 }; box = null },
80
66
  })
81
67
 
82
68
  // --- Drag (header) ---------------------------------------------------------
@@ -130,10 +116,7 @@
130
116
  bind:this={dialogEl}
131
117
  class="sv-modal sv-modal--{size}"
132
118
  class:is-resized={!!box}
133
- role="dialog"
134
- aria-modal="true"
135
- aria-labelledby={title ? titleId : undefined}
136
- tabindex="-1"
119
+ {...overlay.dialogProps({ labelledBy: title ? titleId : undefined })}
137
120
  style:transform={`translate(calc(-50% + ${drag.x}px), calc(-50% + ${drag.y}px))`}
138
121
  style:width={box ? `${box.w}px` : undefined}
139
122
  style:height={box ? `${box.h}px` : undefined}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Component tests for SvModal: dialog semantics, scroll lock, and Escape /
3
+ * backdrop dismissal - all inherited from the shared createOverlay core (which
4
+ * SvDrawer shares), plus the modal's title wiring and closeOnEsc opt-out.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvModal from './SvModal.svelte'
9
+
10
+ function mountModal(props: Record<string, unknown>) {
11
+ const target = document.createElement('div')
12
+ document.body.appendChild(target)
13
+ const app = mount(SvModal, { target, props: props as any })
14
+ flushSync()
15
+ return { target, destroy: () => { unmount(app); target.remove() } }
16
+ }
17
+
18
+ const dialog = () => document.body.querySelector<HTMLElement>('.sv-modal')
19
+
20
+ describe('SvModal', () => {
21
+ it('renders nothing when closed', () => {
22
+ const { destroy } = mountModal({ open: false })
23
+ try {
24
+ expect(dialog()).toBeNull()
25
+ } finally { destroy() }
26
+ })
27
+
28
+ it('renders a modal dialog with the title wired to aria-labelledby', () => {
29
+ const { destroy } = mountModal({ open: true, title: 'Edit row' })
30
+ try {
31
+ const el = dialog()!
32
+ expect(el.getAttribute('role')).toBe('dialog')
33
+ expect(el.getAttribute('aria-modal')).toBe('true')
34
+ expect(el.getAttribute('tabindex')).toBe('-1')
35
+ const labelledby = el.getAttribute('aria-labelledby')
36
+ expect(labelledby).toBeTruthy()
37
+ expect(document.getElementById(labelledby!)?.textContent).toBe('Edit row')
38
+ } finally { destroy() }
39
+ })
40
+
41
+ it('locks body scroll while open and restores it on close', () => {
42
+ document.body.style.overflow = 'auto'
43
+ const { destroy } = mountModal({ open: true })
44
+ try {
45
+ expect(document.body.style.overflow).toBe('hidden')
46
+ } finally {
47
+ destroy()
48
+ flushSync()
49
+ expect(document.body.style.overflow).toBe('auto')
50
+ }
51
+ })
52
+
53
+ it('closes on Escape and fires onClose', () => {
54
+ let closed = 0
55
+ const { destroy } = mountModal({ open: true, onClose: () => closed++ })
56
+ try {
57
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
58
+ flushSync()
59
+ expect(closed).toBe(1)
60
+ expect(dialog()).toBeNull()
61
+ } finally { destroy() }
62
+ })
63
+
64
+ it('does not close on Escape when closeOnEsc is false', () => {
65
+ const { destroy } = mountModal({ open: true, closeOnEsc: false })
66
+ try {
67
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
68
+ flushSync()
69
+ expect(dialog()).not.toBeNull()
70
+ } finally { destroy() }
71
+ })
72
+
73
+ it('dismisses on outside (backdrop) pointerdown', () => {
74
+ const { destroy } = mountModal({ open: true })
75
+ try {
76
+ const backdrop = document.body.querySelector<HTMLElement>('.sv-modal__backdrop')!
77
+ backdrop.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
78
+ flushSync()
79
+ expect(dialog()).toBeNull()
80
+ } finally { destroy() }
81
+ })
82
+ })
@@ -178,6 +178,7 @@
178
178
  style:top={`${ms.rect.top}px`}
179
179
  style:left={`${ms.rect.left}px`}
180
180
  style:min-width={`${ms.rect.width}px`}
181
+ style:max-height={`${ms.rect.maxHeight}px`}
181
182
  onkeydown={ms.onPanelKeydown}
182
183
  >
183
184
  {#if searchable}
@@ -31,7 +31,7 @@
31
31
  }
32
32
 
33
33
  let {
34
- value = null,
34
+ value = $bindable<number | null>(null),
35
35
  onChange,
36
36
  min = -Infinity,
37
37
  max = Infinity,
@@ -62,7 +62,8 @@
62
62
 
63
63
  const num = createNumberInput({
64
64
  value: () => value,
65
- onChange: (v) => onChange?.(v),
65
+ // Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
66
+ onChange: (v) => { value = v; onChange?.(v) },
66
67
  min: () => min,
67
68
  max: () => max,
68
69
  step: () => step,
@@ -86,7 +87,7 @@
86
87
  <div class="sv-num sv-num--{size}" class:is-disabled={disabled} class:is-invalid={invalid}>
87
88
  <input class="sv-num__input" {...num.inputProps()} />
88
89
  {#if clearable && value != null && !disabled && !readonly}
89
- <button type="button" class="sv-num__clear" aria-label="Clear" tabindex="-1" onclick={() => onChange?.(null)}>
90
+ <button type="button" class="sv-num__clear" aria-label="Clear" tabindex="-1" onclick={() => { value = null; onChange?.(null) }}>
90
91
  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
91
92
  </button>
92
93
  {/if}
@@ -9,7 +9,7 @@
9
9
  * <SvPagination page={p} pageCount={20} onChange={(n) => (p = n)} />
10
10
  * ```
11
11
  */
12
- import { paginationRange } from './paginate'
12
+ import { createPagination } from './createPagination'
13
13
 
14
14
  type Props = {
15
15
  /** 1-based current page. */
@@ -38,23 +38,26 @@
38
38
  ariaLabel = 'Pagination',
39
39
  }: Props = $props()
40
40
 
41
- const items = $derived(paginationRange({ page, pageCount, siblingCount, boundaryCount }))
42
- const go = (p: number) => {
43
- if (disabled) return
44
- const next = Math.min(Math.max(p, 1), pageCount)
45
- if (next !== page) onChange?.(next)
46
- }
41
+ // Page-range math + navigation live in the shared headless core.
42
+ const pg = createPagination({
43
+ page: () => page,
44
+ pageCount: () => pageCount,
45
+ onChange,
46
+ siblingCount: () => siblingCount,
47
+ boundaryCount: () => boundaryCount,
48
+ disabled: () => disabled,
49
+ })
47
50
  </script>
48
51
 
49
- <nav class="sv-pg sv-pg--{size}" aria-label={ariaLabel}>
52
+ <nav class="sv-pg sv-pg--{size}" {...pg.navProps(ariaLabel)}>
50
53
  <ul class="sv-pg__list">
51
54
  {#if showFirstLast}
52
- <li><button type="button" class="sv-pg__btn" aria-label="First page" onclick={() => go(1)} disabled={disabled || page <= 1}>&laquo;</button></li>
55
+ <li><button type="button" class="sv-pg__btn" {...pg.firstProps()}>&laquo;</button></li>
53
56
  {/if}
54
57
  {#if showPrevNext}
55
- <li><button type="button" class="sv-pg__btn" aria-label="Previous page" onclick={() => go(page - 1)} disabled={disabled || page <= 1}>&lsaquo;</button></li>
58
+ <li><button type="button" class="sv-pg__btn" {...pg.prevProps()}>&lsaquo;</button></li>
56
59
  {/if}
57
- {#each items as it, i (typeof it === 'number' ? `p${it}` : `${it}-${i}`)}
60
+ {#each pg.items as it, i (typeof it === 'number' ? `p${it}` : `${it}-${i}`)}
58
61
  {#if it === 'ellipsis-left' || it === 'ellipsis-right'}
59
62
  <li><span class="sv-pg__ellipsis" aria-hidden="true">&hellip;</span></li>
60
63
  {:else}
@@ -62,20 +65,17 @@
62
65
  <button
63
66
  type="button"
64
67
  class="sv-pg__btn"
65
- class:is-active={it === page}
66
- aria-current={it === page ? 'page' : undefined}
67
- aria-label={`Page ${it}`}
68
- {disabled}
69
- onclick={() => go(it)}
68
+ class:is-active={pg.isActive(it)}
69
+ {...pg.pageButtonProps(it)}
70
70
  >{it}</button>
71
71
  </li>
72
72
  {/if}
73
73
  {/each}
74
74
  {#if showPrevNext}
75
- <li><button type="button" class="sv-pg__btn" aria-label="Next page" onclick={() => go(page + 1)} disabled={disabled || page >= pageCount}>&rsaquo;</button></li>
75
+ <li><button type="button" class="sv-pg__btn" {...pg.nextProps()}>&rsaquo;</button></li>
76
76
  {/if}
77
77
  {#if showFirstLast}
78
- <li><button type="button" class="sv-pg__btn" aria-label="Last page" onclick={() => go(pageCount)} disabled={disabled || page >= pageCount}>&raquo;</button></li>
78
+ <li><button type="button" class="sv-pg__btn" {...pg.lastProps()}>&raquo;</button></li>
79
79
  {/if}
80
80
  </ul>
81
81
  </nav>
@@ -31,7 +31,7 @@
31
31
  }
32
32
 
33
33
  let {
34
- value = '',
34
+ value = $bindable(''),
35
35
  onChange,
36
36
  placeholder,
37
37
  disabled = false,
@@ -58,7 +58,8 @@
58
58
 
59
59
  const pw = createPasswordInput({
60
60
  value: () => value,
61
- onChange: (v) => onChange?.(v),
61
+ // Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
62
+ onChange: (v) => { value = v; onChange?.(v) },
62
63
  placeholder: () => placeholder,
63
64
  disabled: () => disabled,
64
65
  readonly: () => readonly,
@@ -27,7 +27,7 @@
27
27
  }
28
28
 
29
29
  let {
30
- value = '',
30
+ value = $bindable(''),
31
31
  onChange,
32
32
  country = 'US',
33
33
  disabled = false,
@@ -52,7 +52,8 @@
52
52
 
53
53
  const ph = createPhoneInput({
54
54
  value: () => value,
55
- onChange: (v, parts) => onChange?.(v, parts),
55
+ // Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
56
+ onChange: (v, parts) => { value = v; onChange?.(v, parts) },
56
57
  country: () => country,
57
58
  disabled: () => disabled,
58
59
  readonly: () => readonly,
@@ -49,7 +49,7 @@
49
49
 
50
50
  let anchorEl = $state<HTMLSpanElement | null>(null)
51
51
  let panelEl = $state<HTMLDivElement | null>(null)
52
- let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
52
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
53
53
 
54
54
  function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
55
55
  function toggle() { setOpen(!open) }
@@ -14,6 +14,7 @@
14
14
  * ```
15
15
  */
16
16
  import type { StepItem } from './ui-app.types'
17
+ import { createStepper } from './createStepper'
17
18
 
18
19
  type Props = {
19
20
  steps: ReadonlyArray<StepItem>
@@ -27,22 +28,18 @@
27
28
 
28
29
  let { steps, current, onChange, linear = true, orientation = 'horizontal' }: Props = $props()
29
30
 
30
- const statusOf = (i: number): 'complete' | 'active' | 'upcoming' =>
31
- i < current ? 'complete' : i === current ? 'active' : 'upcoming'
32
- const clickable = (i: number) => (linear ? i <= current : true)
33
- const go = (i: number) => { if (clickable(i) && i !== current) onChange?.(i) }
31
+ // Status, clickability and navigation live in the shared headless core.
32
+ const st = createStepper({ count: () => steps.length, current: () => current, onChange, linear: () => linear })
34
33
  </script>
35
34
 
36
35
  <ol class="sv-step sv-step--{orientation}">
37
36
  {#each steps as step, i (i)}
38
- {@const status = statusOf(i)}
39
- <li class="sv-step__item is-{status}" class:is-clickable={clickable(i)}>
37
+ {@const status = st.statusOf(i)}
38
+ <li class="sv-step__item is-{status}" class:is-clickable={st.clickable(i)}>
40
39
  <button
41
40
  type="button"
42
41
  class="sv-step__btn"
43
- aria-current={status === 'active' ? 'step' : undefined}
44
- disabled={!clickable(i)}
45
- onclick={() => go(i)}
42
+ {...st.stepButtonProps(i)}
46
43
  >
47
44
  <span class="sv-step__marker" aria-hidden="true">
48
45
  {#if status === 'complete'}✓{:else}{i + 1}{/if}
@@ -27,7 +27,7 @@
27
27
  }
28
28
 
29
29
  let {
30
- value = [],
30
+ value = $bindable<string[]>([]),
31
31
  onChange,
32
32
  placeholder = 'Add tag…',
33
33
  disabled = false,
@@ -51,7 +51,8 @@
51
51
 
52
52
  const ti = createTagsInput({
53
53
  value: () => value,
54
- onChange: (t) => onChange?.(t),
54
+ // Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
55
+ onChange: (t) => { value = t; onChange?.(t) },
55
56
  disabled: () => disabled,
56
57
  unique: () => unique,
57
58
  max: () => max,
@@ -12,8 +12,8 @@
12
12
  */
13
13
  import type { Snippet } from 'svelte'
14
14
  import { portalToBody, popIn } from './popover'
15
- import { nextEditorId } from './editor-contract'
16
15
  import { getFocusable } from './a11y/focus-trap'
16
+ import { createTooltip } from './createTooltip.svelte'
17
17
 
18
18
  type Props = {
19
19
  text: string
@@ -28,10 +28,12 @@
28
28
 
29
29
  let anchorEl = $state<HTMLSpanElement | null>(null)
30
30
  let tipEl = $state<HTMLDivElement | null>(null)
31
- let open = $state(false)
32
31
  let pos = $state({ top: 0, left: 0, arrowX: 12 })
33
- const tid = nextEditorId('sv-tip')
34
- let showTimer: ReturnType<typeof setTimeout> | undefined
32
+
33
+ // Open state, show-delay and Escape-to-hide live in the shared headless core;
34
+ // this component owns only the DOM concerns (positioning + describedby merge).
35
+ const tip = createTooltip({ text: () => text, disabled: () => disabled, delay: () => delay })
36
+ const tid = tip.tipId
35
37
 
36
38
  function place() {
37
39
  if (!anchorEl) return
@@ -46,25 +48,16 @@
46
48
  pos = { top, left, arrowX }
47
49
  }
48
50
 
49
- function show() {
50
- if (disabled || !text) return
51
- clearTimeout(showTimer)
52
- showTimer = setTimeout(() => { open = true; queueMicrotask(place) }, delay)
53
- }
54
- function hide() { clearTimeout(showTimer); open = false }
55
-
51
+ // Reposition while open (and on scroll/resize). Escape-to-hide is the core's.
56
52
  $effect(() => {
57
- if (!open) return
58
- place()
53
+ if (!tip.open) return
54
+ queueMicrotask(place)
59
55
  const rp = () => place()
60
56
  window.addEventListener('scroll', rp, true)
61
57
  window.addEventListener('resize', rp)
62
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') hide() }
63
- document.addEventListener('keydown', onKey)
64
58
  return () => {
65
59
  window.removeEventListener('scroll', rp, true)
66
60
  window.removeEventListener('resize', rp)
67
- document.removeEventListener('keydown', onKey)
68
61
  }
69
62
  })
70
63
 
@@ -75,7 +68,7 @@
75
68
  // announced. Mirror the id onto that child too, merging with (and later
76
69
  // restoring) any aria-describedby it already carries.
77
70
  $effect(() => {
78
- if (!open || !anchorEl) return
71
+ if (!tip.open || !anchorEl) return
79
72
  const child = getFocusable(anchorEl)[0]
80
73
  if (!child) return
81
74
  const before = child.getAttribute('aria-describedby')
@@ -96,16 +89,12 @@
96
89
  <span
97
90
  bind:this={anchorEl}
98
91
  class="sv-tip__anchor"
99
- aria-describedby={open ? tid : undefined}
100
- onpointerenter={show}
101
- onpointerleave={hide}
102
- onfocusin={show}
103
- onfocusout={hide}
92
+ {...tip.anchorProps()}
104
93
  >
105
94
  {@render children?.()}
106
95
  </span>
107
96
 
108
- {#if open}
97
+ {#if tip.open}
109
98
  <div
110
99
  bind:this={tipEl}
111
100
  id={tid}