@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.
- package/README.md +9 -2
- package/dist/DockNodeView.svelte +306 -0
- package/dist/DockNodeView.svelte.d.ts +7 -0
- package/dist/DockPopoutHost.svelte +17 -0
- package/dist/DockPopoutHost.svelte.d.ts +14 -0
- package/dist/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +20 -42
- package/dist/SvColorInput.svelte +4 -3
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- package/dist/SvCountryInput.svelte +1 -1
- package/dist/SvDateRangeInput.svelte +1 -1
- package/dist/SvDateTimePicker.svelte +9 -3
- package/dist/SvDateTimePicker.svelte.d.ts +3 -0
- package/dist/SvDockLayout.svelte +244 -0
- package/dist/SvDockLayout.svelte.d.ts +77 -0
- package/dist/SvDockManager.svelte +922 -0
- package/dist/SvDockManager.svelte.d.ts +106 -0
- package/dist/SvDrawer.svelte +69 -47
- package/dist/SvDrawer.svelte.d.ts +4 -0
- package/dist/SvDropDownList.svelte +85 -13
- package/dist/SvDropDownList.svelte.d.ts +3 -0
- package/dist/SvField.svelte +21 -4
- package/dist/SvField.svelte.d.ts +1 -0
- package/dist/SvForm.svelte +201 -91
- package/dist/SvForm.svelte.d.ts +15 -22
- package/dist/SvGrid.controller.svelte.d.ts +32 -1
- package/dist/SvGrid.controller.svelte.js +156 -27
- package/dist/SvGrid.css +193 -5
- package/dist/SvGrid.svelte +357 -12
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridDropdown.svelte +8 -1
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +1 -1
- package/dist/SvMenuList.svelte +23 -71
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +4 -3
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- package/dist/SvPagination.svelte +18 -18
- package/dist/SvPasswordInput.svelte +3 -2
- package/dist/SvPasswordInput.svelte.d.ts +1 -1
- package/dist/SvPhoneInput.svelte +3 -2
- package/dist/SvPhoneInput.svelte.d.ts +1 -1
- package/dist/SvPopover.svelte +1 -1
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +12 -23
- package/dist/SvTour.svelte +32 -4
- package/dist/SvTreeSelect.svelte +1 -0
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10907 -6444
- package/dist/cdn/svgrid.svelte-external.js +9622 -5475
- package/dist/columns.js +6 -0
- package/dist/conditional-formatting.d.ts +67 -3
- package/dist/conditional-formatting.js +124 -17
- package/dist/createCarousel.svelte.d.ts +72 -0
- package/dist/createCarousel.svelte.js +80 -0
- package/dist/createCommand.svelte.d.ts +62 -0
- package/dist/createCommand.svelte.js +109 -0
- package/dist/createForm.svelte.d.ts +62 -0
- package/dist/createForm.svelte.js +374 -0
- package/dist/createMenu.svelte.d.ts +88 -0
- package/dist/createMenu.svelte.js +153 -0
- package/dist/createOverlay.svelte.d.ts +38 -0
- package/dist/createOverlay.svelte.js +68 -0
- package/dist/createPagination.d.ts +74 -0
- package/dist/createPagination.js +71 -0
- package/dist/createPopoverSelect.svelte.js +6 -12
- package/dist/createStepper.d.ts +44 -0
- package/dist/createStepper.js +26 -0
- package/dist/createTooltip.svelte.d.ts +32 -0
- package/dist/createTooltip.svelte.js +61 -0
- package/dist/datetime/timezone.d.ts +36 -0
- package/dist/datetime/timezone.js +110 -0
- package/dist/dock-context.d.ts +52 -0
- package/dist/dock-context.js +1 -0
- package/dist/dock-manager-model.d.ts +136 -0
- package/dist/dock-manager-model.js +514 -0
- package/dist/dock-model.d.ts +105 -0
- package/dist/dock-model.js +288 -0
- package/dist/dock-popout.d.ts +30 -0
- package/dist/dock-popout.js +81 -0
- package/dist/editor-contract.d.ts +2 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +35 -2
- package/dist/filtering/excel-filters.d.ts +18 -1
- package/dist/filtering/excel-filters.js +56 -0
- package/dist/form-field.d.ts +73 -0
- package/dist/form-field.js +15 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +40 -4
- package/dist/list-nav.d.ts +18 -0
- package/dist/list-nav.js +30 -0
- package/dist/list-option.d.ts +2 -0
- package/dist/menu-item.d.ts +20 -0
- package/dist/menu-item.js +1 -0
- package/dist/menus.d.ts +5 -0
- package/dist/menus.js +68 -16
- package/dist/panel-resize.d.ts +25 -0
- package/dist/panel-resize.js +40 -0
- package/dist/popover.d.ts +29 -3
- package/dist/popover.js +22 -6
- package/dist/recurrence.d.ts +33 -7
- package/dist/recurrence.js +151 -13
- package/dist/row-drag.d.ts +8 -0
- package/dist/row-drag.js +11 -0
- package/dist/scheduler-ical.d.ts +30 -0
- package/dist/scheduler-ical.js +180 -0
- package/dist/scheduler-model.d.ts +269 -0
- package/dist/scheduler-model.js +602 -0
- package/dist/scheduler-view.svelte.d.ts +22 -0
- package/dist/scheduler-view.svelte.js +13 -0
- package/dist/spreadsheet.js +20 -12
- package/dist/ui-app.types.d.ts +3 -0
- package/package.json +5 -1
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +20 -42
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +4 -3
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- package/src/SvCountryInput.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvDateTimePicker.svelte +9 -3
- package/src/SvDockLayout.svelte +244 -0
- package/src/SvDockManager.svelte +922 -0
- package/src/SvDrawer.svelte +69 -47
- package/src/SvDropDownList.svelte +85 -13
- package/src/SvField.svelte +21 -4
- package/src/SvForm.svelte +201 -91
- package/src/SvForm.test.ts +299 -0
- package/src/SvGrid.controller.svelte.ts +152 -28
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +357 -12
- package/src/SvGrid.types.ts +463 -1
- package/src/SvGridDropdown.svelte +8 -1
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +1 -1
- package/src/SvMenu.test.ts +24 -0
- package/src/SvMenuList.svelte +23 -71
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +4 -3
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +1 -1
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +12 -23
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +32 -4
- package/src/SvTreeSelect.svelte +1 -0
- package/src/build-api.ts +74 -51
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCommand.svelte.ts +125 -0
- package/src/createForm.svelte.ts +333 -0
- package/src/createMenu.svelte.ts +202 -0
- package/src/createOverlay.svelte.ts +101 -0
- package/src/createPagination.test.ts +65 -0
- package/src/createPagination.ts +84 -0
- package/src/createPopoverSelect.svelte.ts +5 -9
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- package/src/datetime/timezone.test.ts +69 -0
- package/src/datetime/timezone.ts +134 -0
- package/src/dock-context.ts +47 -0
- package/src/dock-manager-model.test.ts +275 -0
- package/src/dock-manager-model.ts +593 -0
- package/src/dock-model.test.ts +163 -0
- package/src/dock-model.ts +368 -0
- package/src/dock-popout.ts +94 -0
- package/src/editor-contract.ts +2 -0
- package/src/filter-operators.test.ts +59 -0
- package/src/filter-operators.ts +35 -2
- package/src/filtering/excel-filters.test.ts +86 -1
- package/src/filtering/excel-filters.ts +65 -0
- package/src/form-field.ts +87 -0
- package/src/index.ts +158 -4
- package/src/list-nav.test.ts +49 -0
- package/src/list-nav.ts +29 -0
- package/src/list-option.ts +2 -0
- package/src/menu-item.ts +21 -0
- package/src/menus.test.ts +48 -0
- package/src/menus.ts +73 -16
- package/src/panel-resize.ts +52 -0
- package/src/popover.bounds.test.ts +93 -0
- package/src/popover.ts +51 -7
- package/src/recurrence.test.ts +88 -1
- package/src/recurrence.ts +166 -15
- package/src/row-drag.ts +10 -0
- package/src/scheduler-ical.test.ts +68 -0
- package/src/scheduler-ical.ts +180 -0
- package/src/scheduler-model.test.ts +546 -0
- package/src/scheduler-model.ts +853 -0
- package/src/scheduler-view.svelte.ts +33 -0
- package/src/spreadsheet.ts +20 -12
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
package/src/SvMenu.test.ts
CHANGED
|
@@ -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 {
|
package/src/SvMenuList.svelte
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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}
|
|
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={
|
|
108
|
-
|
|
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 &&
|
|
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={() =>
|
|
82
|
+
oncollapse={() => menu.focus(i)}
|
|
131
83
|
{dir}
|
|
132
84
|
submenu
|
|
133
85
|
/>
|
package/src/SvModal.svelte
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/SvMultiSelect.svelte
CHANGED
package/src/SvNumberInput.svelte
CHANGED
|
@@ -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
|
-
|
|
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}
|
package/src/SvPagination.svelte
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* <SvPagination page={p} pageCount={20} onChange={(n) => (p = n)} />
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
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
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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}"
|
|
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"
|
|
55
|
+
<li><button type="button" class="sv-pg__btn" {...pg.firstProps()}>«</button></li>
|
|
53
56
|
{/if}
|
|
54
57
|
{#if showPrevNext}
|
|
55
|
-
<li><button type="button" class="sv-pg__btn"
|
|
58
|
+
<li><button type="button" class="sv-pg__btn" {...pg.prevProps()}>‹</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">…</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
|
|
66
|
-
|
|
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"
|
|
75
|
+
<li><button type="button" class="sv-pg__btn" {...pg.nextProps()}>›</button></li>
|
|
76
76
|
{/if}
|
|
77
77
|
{#if showFirstLast}
|
|
78
|
-
<li><button type="button" class="sv-pg__btn"
|
|
78
|
+
<li><button type="button" class="sv-pg__btn" {...pg.lastProps()}>»</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
|
-
|
|
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,
|
package/src/SvPhoneInput.svelte
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/SvPopover.svelte
CHANGED
|
@@ -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) }
|
package/src/SvStepper.svelte
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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}
|
package/src/SvTagsInput.svelte
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/SvTooltip.svelte
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|