@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for SvCarousel, exercising the shared createCarousel core:
|
|
3
|
+
* slide/dot rendering, arrow navigation with looping, autoplay advance, and the
|
|
4
|
+
* persistent play/pause stop toggle.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
|
|
8
|
+
import SvCarousel from './SvCarousel.svelte'
|
|
9
|
+
|
|
10
|
+
const slide = createRawSnippet(() => ({ render: () => `<div class="slide"></div>` }))
|
|
11
|
+
|
|
12
|
+
function mountCarousel(props: Record<string, unknown>) {
|
|
13
|
+
const target = document.createElement('div')
|
|
14
|
+
document.body.appendChild(target)
|
|
15
|
+
const app = mount(SvCarousel, { target, props: { slide, ...props } as any })
|
|
16
|
+
flushSync()
|
|
17
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
18
|
+
}
|
|
19
|
+
const activeDot = (target: HTMLElement) =>
|
|
20
|
+
[...target.querySelectorAll('.sv-carousel__dot')].findIndex((d) => d.classList.contains('is-active'))
|
|
21
|
+
const click = (el: Element | null | undefined) => { el?.dispatchEvent(new MouseEvent('click', { bubbles: true })); flushSync() }
|
|
22
|
+
|
|
23
|
+
describe('SvCarousel', () => {
|
|
24
|
+
beforeEach(() => vi.useFakeTimers())
|
|
25
|
+
afterEach(() => vi.useRealTimers())
|
|
26
|
+
|
|
27
|
+
it('renders one slide + dot per item, first active', () => {
|
|
28
|
+
const { target, destroy } = mountCarousel({ count: 3 })
|
|
29
|
+
try {
|
|
30
|
+
expect(target.querySelectorAll('.sv-carousel__slide').length).toBe(3)
|
|
31
|
+
expect(target.querySelectorAll('.sv-carousel__dot').length).toBe(3)
|
|
32
|
+
expect(activeDot(target)).toBe(0)
|
|
33
|
+
} finally { destroy() }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('the next arrow advances and loops back to the first', () => {
|
|
37
|
+
const { target, destroy } = mountCarousel({ count: 2, loop: true })
|
|
38
|
+
try {
|
|
39
|
+
click(target.querySelector('.sv-carousel__arrow.is-next'))
|
|
40
|
+
expect(activeDot(target)).toBe(1)
|
|
41
|
+
click(target.querySelector('.sv-carousel__arrow.is-next'))
|
|
42
|
+
expect(activeDot(target)).toBe(0) // wrapped
|
|
43
|
+
} finally { destroy() }
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('a dot jumps directly to its slide', () => {
|
|
47
|
+
const { target, destroy } = mountCarousel({ count: 4 })
|
|
48
|
+
try {
|
|
49
|
+
click(target.querySelectorAll('.sv-carousel__dot')[2])
|
|
50
|
+
expect(activeDot(target)).toBe(2)
|
|
51
|
+
} finally { destroy() }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('autoplay advances on the interval and the toggle stops it', () => {
|
|
55
|
+
const { target, destroy } = mountCarousel({ count: 3, autoplay: 1000 })
|
|
56
|
+
try {
|
|
57
|
+
expect(activeDot(target)).toBe(0)
|
|
58
|
+
vi.advanceTimersByTime(1000)
|
|
59
|
+
flushSync()
|
|
60
|
+
expect(activeDot(target)).toBe(1)
|
|
61
|
+
// Stop toggle: autoplay no longer advances.
|
|
62
|
+
click(target.querySelector('.sv-carousel__playpause'))
|
|
63
|
+
vi.advanceTimersByTime(3000)
|
|
64
|
+
flushSync()
|
|
65
|
+
expect(activeDot(target)).toBe(1)
|
|
66
|
+
} finally { destroy() }
|
|
67
|
+
})
|
|
68
|
+
})
|
package/src/SvColorInput.svelte
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
let {
|
|
32
|
-
value = '#3b82f6',
|
|
32
|
+
value = $bindable('#3b82f6'),
|
|
33
33
|
onChange,
|
|
34
34
|
disabled = false,
|
|
35
35
|
readonly = false,
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
|
|
55
55
|
const col = createColorInput({
|
|
56
56
|
value: () => value,
|
|
57
|
-
|
|
57
|
+
// Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
|
|
58
|
+
onChange: (hex) => { value = hex; onChange?.(hex) },
|
|
58
59
|
disabled: () => disabled,
|
|
59
60
|
readonly: () => readonly,
|
|
60
61
|
palette: () => palette as string[],
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
|
|
69
70
|
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
70
71
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
71
|
-
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
72
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
72
73
|
|
|
73
74
|
function updatePos() {
|
|
74
75
|
if (!triggerEl) return
|
package/src/SvComboBox.svelte
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
let inputEl = $state<HTMLInputElement | null>(null)
|
|
66
66
|
let fieldEl = $state<HTMLDivElement | null>(null)
|
|
67
67
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
68
|
-
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
68
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
69
69
|
|
|
70
70
|
// Remote data source: fetch options as the query changes (debounced).
|
|
71
71
|
let remoteOptions = $state<ListOption[]>([])
|
|
@@ -111,7 +111,11 @@
|
|
|
111
111
|
|
|
112
112
|
function updatePos() {
|
|
113
113
|
if (!fieldEl) return
|
|
114
|
-
|
|
114
|
+
// estimatedHeight only picks the flip direction (up vs down); the panel's
|
|
115
|
+
// real max-height comes from availHeight below, so it takes its natural
|
|
116
|
+
// content height (options + group labels) and scrolls only when it truly
|
|
117
|
+
// overflows the room - never from an estimate that under-counts a row.
|
|
118
|
+
rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: Math.min(combo.filtered.length, 8) * 34 + 12 })
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
// Position + reposition + outside-click close are render concerns (need the DOM).
|
|
@@ -150,7 +154,7 @@
|
|
|
150
154
|
</SvField>
|
|
151
155
|
|
|
152
156
|
{#if combo.open}
|
|
153
|
-
<div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} {...combo.listboxProps()}>
|
|
157
|
+
<div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={rect.openUpward ? undefined : `${rect.top}px`} style:bottom={rect.openUpward ? `${rect.bottom}px` : undefined} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${Math.min(rect.availHeight, 288)}px`} {...combo.listboxProps()}>
|
|
154
158
|
{#if loading}
|
|
155
159
|
<div class="sv-ddl__empty sv-ddl__loading"><svg class="sv-ddl__spin" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>{M.loading}</div>
|
|
156
160
|
{:else if loadOptions && !searched && combo.query.trim().length < minLength}
|
package/src/SvCommand.svelte
CHANGED
|
@@ -16,10 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { CommandItem } from './ui-app.types'
|
|
18
18
|
import { portalToBody, popIn } from './popover'
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { createDismissableLayer } from './a11y/dismissable'
|
|
22
|
-
import { fuzzyScore } from './fuzzy'
|
|
19
|
+
import { createOverlay } from './createOverlay.svelte'
|
|
20
|
+
import { createCommand } from './createCommand.svelte'
|
|
23
21
|
|
|
24
22
|
type Props = {
|
|
25
23
|
open?: boolean
|
|
@@ -42,72 +40,29 @@
|
|
|
42
40
|
hotkey = 'mod+k',
|
|
43
41
|
}: Props = $props()
|
|
44
42
|
|
|
45
|
-
let query = $state('')
|
|
46
|
-
let active = $state(0)
|
|
47
43
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
48
44
|
let inputEl = $state<HTMLInputElement | null>(null)
|
|
49
45
|
|
|
50
|
-
const results = $derived.by(() => {
|
|
51
|
-
const q = query.trim()
|
|
52
|
-
const list = commands.filter((c) => !c.disabled)
|
|
53
|
-
if (!q) return list
|
|
54
|
-
return list
|
|
55
|
-
.map((c) => ({ c, s: fuzzyScore(`${c.label} ${c.keywords ?? ''} ${c.group ?? ''}`, q) }))
|
|
56
|
-
.filter((x) => x.s !== null)
|
|
57
|
-
.sort((a, b) => (b.s as number) - (a.s as number))
|
|
58
|
-
.map((x) => x.c)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
46
|
function close() { open = false; onClose?.() }
|
|
62
|
-
function run(cmd: CommandItem | undefined) {
|
|
63
|
-
if (!cmd || cmd.disabled) return
|
|
64
|
-
close()
|
|
65
|
-
onRun?.(cmd)
|
|
66
|
-
cmd.onRun?.()
|
|
67
|
-
}
|
|
68
|
-
function move(delta: number) {
|
|
69
|
-
const n = results.length
|
|
70
|
-
if (!n) return
|
|
71
|
-
active = (active + delta + n) % n
|
|
72
|
-
queueMicrotask(() =>
|
|
73
|
-
panelEl?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }),
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
function onKeydown(e: KeyboardEvent) {
|
|
77
|
-
if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
|
|
78
|
-
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
|
|
79
|
-
else if (e.key === 'Enter') { e.preventDefault(); run(results[active]) }
|
|
80
|
-
// Escape + outside-click are owned by the dismissable layer.
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Reset active whenever the query changes.
|
|
84
|
-
$effect(() => { void query; active = 0 })
|
|
85
47
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
48
|
+
// Fuzzy filter + roving + run + hotkey live in the command core; the overlay
|
|
49
|
+
// lifecycle (focus-trap into the input, scroll-lock, Escape/outside dismissal)
|
|
50
|
+
// is the shared dialog core.
|
|
51
|
+
const cmd = createCommand({
|
|
52
|
+
commands: () => commands,
|
|
53
|
+
open: () => open,
|
|
54
|
+
onRun,
|
|
55
|
+
onClose: close,
|
|
56
|
+
onToggleOpen: () => (open = !open),
|
|
57
|
+
hotkey: () => hotkey,
|
|
58
|
+
scrollActiveIntoView: (i) =>
|
|
59
|
+
queueMicrotask(() => panelEl?.querySelector(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' })),
|
|
97
60
|
})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const onKey = (e: KeyboardEvent) => {
|
|
104
|
-
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
|
|
105
|
-
e.preventDefault()
|
|
106
|
-
open = !open
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
window.addEventListener('keydown', onKey)
|
|
110
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
61
|
+
const overlay = createOverlay({
|
|
62
|
+
open: () => open,
|
|
63
|
+
getDialog: () => panelEl,
|
|
64
|
+
onClose: close,
|
|
65
|
+
initialFocus: () => inputEl,
|
|
111
66
|
})
|
|
112
67
|
</script>
|
|
113
68
|
|
|
@@ -117,11 +72,8 @@
|
|
|
117
72
|
bind:this={panelEl}
|
|
118
73
|
class="sv-cmd"
|
|
119
74
|
use:popIn={{}}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
aria-label="Command palette"
|
|
123
|
-
tabindex="-1"
|
|
124
|
-
onkeydown={onKeydown}
|
|
75
|
+
{...overlay.dialogProps({ label: 'Command palette' })}
|
|
76
|
+
onkeydown={cmd.onKeydown}
|
|
125
77
|
>
|
|
126
78
|
<div class="sv-cmd__search">
|
|
127
79
|
<span class="sv-cmd__searchicon" aria-hidden="true">⌕</span>
|
|
@@ -129,29 +81,20 @@
|
|
|
129
81
|
bind:this={inputEl}
|
|
130
82
|
class="sv-cmd__input"
|
|
131
83
|
type="text"
|
|
132
|
-
|
|
133
|
-
aria-expanded="true"
|
|
134
|
-
aria-controls="sv-cmd-list"
|
|
135
|
-
aria-activedescendant={results[active] ? `sv-cmd-opt-${active}` : undefined}
|
|
84
|
+
aria-label="Command palette search"
|
|
136
85
|
{placeholder}
|
|
137
|
-
|
|
138
|
-
oninput={(e) => (query = e.currentTarget.value)}
|
|
86
|
+
{...cmd.inputProps()}
|
|
139
87
|
/>
|
|
140
88
|
</div>
|
|
141
|
-
<ul class="sv-cmd__list"
|
|
142
|
-
{#each results as c, i (c.id)}
|
|
143
|
-
{#if !query.trim() && c.group && c.group !== results[i - 1]?.group}
|
|
89
|
+
<ul class="sv-cmd__list" aria-label="Commands" {...cmd.listProps()}>
|
|
90
|
+
{#each cmd.results as c, i (c.id)}
|
|
91
|
+
{#if !cmd.query.trim() && c.group && c.group !== cmd.results[i - 1]?.group}
|
|
144
92
|
<li class="sv-cmd__group" role="presentation">{c.group}</li>
|
|
145
93
|
{/if}
|
|
146
94
|
<li
|
|
147
|
-
id={`sv-cmd-opt-${i}`}
|
|
148
|
-
data-idx={i}
|
|
149
95
|
class="sv-cmd__opt"
|
|
150
|
-
class:is-active={i
|
|
151
|
-
|
|
152
|
-
aria-selected={i === active}
|
|
153
|
-
onpointermove={() => (active = i)}
|
|
154
|
-
onclick={() => run(c)}
|
|
96
|
+
class:is-active={cmd.isActive(i)}
|
|
97
|
+
{...cmd.optionProps(i)}
|
|
155
98
|
>
|
|
156
99
|
{#if c.icon}<span class="sv-cmd__icon" aria-hidden="true">{c.icon}</span>{/if}
|
|
157
100
|
<span class="sv-cmd__label">{c.label}</span>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for SvCommand, exercising the shared createCommand core: the
|
|
3
|
+
* global hotkey toggles it open, typing fuzzy-filters the list, arrow+Enter runs
|
|
4
|
+
* the active command, and clicking a command runs it and closes the palette.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
8
|
+
import SvCommand, { type CommandItem } from './SvCommand.svelte'
|
|
9
|
+
|
|
10
|
+
function mountCmd(props: Record<string, unknown>) {
|
|
11
|
+
const target = document.createElement('div')
|
|
12
|
+
document.body.appendChild(target)
|
|
13
|
+
const app = mount(SvCommand, { target, props: props as any })
|
|
14
|
+
flushSync()
|
|
15
|
+
return { destroy: () => { unmount(app); target.remove() } }
|
|
16
|
+
}
|
|
17
|
+
const palette = () => document.querySelector<HTMLElement>('.sv-cmd')
|
|
18
|
+
const options = () => [...document.querySelectorAll<HTMLElement>('.sv-cmd__opt')]
|
|
19
|
+
const input = () => document.querySelector<HTMLInputElement>('.sv-cmd__input')
|
|
20
|
+
afterEach(() => document.querySelectorAll('.sv-cmd__backdrop').forEach((n) => n.remove()))
|
|
21
|
+
|
|
22
|
+
const commands: CommandItem[] = [
|
|
23
|
+
{ id: 'a', label: 'New File' },
|
|
24
|
+
{ id: 'b', label: 'Open Folder' },
|
|
25
|
+
{ id: 'c', label: 'Save All' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
describe('SvCommand', () => {
|
|
29
|
+
it('opens on the Cmd/Ctrl+K hotkey', () => {
|
|
30
|
+
const { destroy } = mountCmd({ open: false, commands })
|
|
31
|
+
try {
|
|
32
|
+
expect(palette()).toBeNull()
|
|
33
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }))
|
|
34
|
+
flushSync()
|
|
35
|
+
expect(palette()).not.toBeNull()
|
|
36
|
+
expect(options().length).toBe(3)
|
|
37
|
+
} finally { destroy() }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('fuzzy-filters as you type', () => {
|
|
41
|
+
const { destroy } = mountCmd({ open: true, commands })
|
|
42
|
+
try {
|
|
43
|
+
const el = input()!
|
|
44
|
+
el.value = 'save'
|
|
45
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
46
|
+
flushSync()
|
|
47
|
+
const labels = options().map((o) => o.textContent)
|
|
48
|
+
expect(labels.length).toBe(1)
|
|
49
|
+
expect(labels[0]).toContain('Save All')
|
|
50
|
+
} finally { destroy() }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('runs the active command on Enter and closes', () => {
|
|
54
|
+
const onRun = vi.fn()
|
|
55
|
+
const { destroy } = mountCmd({ open: true, commands, onRun })
|
|
56
|
+
try {
|
|
57
|
+
const el = input()!
|
|
58
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
|
59
|
+
flushSync()
|
|
60
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
|
61
|
+
flushSync()
|
|
62
|
+
expect(onRun).toHaveBeenCalledTimes(1)
|
|
63
|
+
expect(onRun.mock.calls[0]![0].id).toBe('b') // ArrowDown moved 0 -> 1
|
|
64
|
+
expect(palette()).toBeNull()
|
|
65
|
+
} finally { destroy() }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('runs a command on click and closes', () => {
|
|
69
|
+
const onRun = vi.fn()
|
|
70
|
+
const { destroy } = mountCmd({ open: true, commands, onRun })
|
|
71
|
+
try {
|
|
72
|
+
options()[2]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
73
|
+
flushSync()
|
|
74
|
+
expect(onRun.mock.calls[0]![0].id).toBe('c')
|
|
75
|
+
expect(palette()).toBeNull()
|
|
76
|
+
} finally { destroy() }
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
55
55
|
let searchEl = $state<HTMLInputElement | null>(null)
|
|
56
56
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
57
|
-
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
57
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
58
58
|
|
|
59
59
|
const ci = createCountryInput({
|
|
60
60
|
value: () => value,
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
// --- Portalled popover positioning (DOM-bound; stays in the component) -------
|
|
119
119
|
let triggerEl = $state<HTMLDivElement | null>(null)
|
|
120
120
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
121
|
-
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
121
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
122
122
|
|
|
123
123
|
function updatePos() {
|
|
124
124
|
if (!triggerEl) return
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
stepMinutes?: number
|
|
55
55
|
/** Open the dropdown as soon as the field is focused (grid in-cell editing). */
|
|
56
56
|
autoOpen?: boolean
|
|
57
|
+
/** Stretch the field to fill its container (100% width) instead of the fixed
|
|
58
|
+
* default width - used when mounted as a grid cell editor. */
|
|
59
|
+
block?: boolean
|
|
57
60
|
/** Animate the calendar's month/drill navigation (honors reduced-motion). */
|
|
58
61
|
animate?: boolean | 'slide' | 'fade'
|
|
59
62
|
/** Override the built-in strings (tabs, dialog, clear/open aria-labels). */
|
|
@@ -82,6 +85,7 @@
|
|
|
82
85
|
spinButtons = false,
|
|
83
86
|
stepMinutes = 1,
|
|
84
87
|
autoOpen = false,
|
|
88
|
+
block = false,
|
|
85
89
|
animate = false,
|
|
86
90
|
ariaLabel,
|
|
87
91
|
invalid = false,
|
|
@@ -130,7 +134,7 @@
|
|
|
130
134
|
// --- Portalled dropdown positioning (DOM-bound; stays in the component) -----
|
|
131
135
|
let triggerEl = $state<HTMLDivElement | null>(null)
|
|
132
136
|
let panelEl = $state<HTMLDivElement | null>(null)
|
|
133
|
-
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
137
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
134
138
|
|
|
135
139
|
function updatePos() {
|
|
136
140
|
if (!triggerEl) return
|
|
@@ -171,8 +175,8 @@
|
|
|
171
175
|
}
|
|
172
176
|
</script>
|
|
173
177
|
|
|
174
|
-
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
175
|
-
<div class="sv-dtp" class:sv-dtp--disabled={disabled} dir={resolvedDir} onfocusout={onRootFocusOut}>
|
|
178
|
+
<SvField id={uid} {label} {hint} {error} {required} {dir} {block}>
|
|
179
|
+
<div class="sv-dtp" class:sv-dtp--disabled={disabled} class:sv-dtp--block={block} dir={resolvedDir} onfocusout={onRootFocusOut}>
|
|
176
180
|
<div class="sv-dtp__field" class:is-invalid={invalid} bind:this={triggerEl}>
|
|
177
181
|
{#if spinButtons}
|
|
178
182
|
<div class="sv-dtp__spin">
|
|
@@ -256,6 +260,8 @@
|
|
|
256
260
|
--_radius: var(--sg-radius, 8px);
|
|
257
261
|
display: inline-block; position: relative; width: 240px; color: var(--_fg);
|
|
258
262
|
}
|
|
263
|
+
/* Fill the container (grid cell editor) instead of the fixed default width. */
|
|
264
|
+
.sv-dtp--block { display: block; width: 100%; }
|
|
259
265
|
.sv-dtp--disabled { opacity: 0.6; }
|
|
260
266
|
.sv-dtp__field {
|
|
261
267
|
display: flex; align-items: center; gap: 2px;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvDockLayout - a basic docking layout: nested resizable split groups and
|
|
4
|
+
* tabbed leaves, with drag-to-dock. Drag a pane's tab onto another pane and
|
|
5
|
+
* drop it on an edge (split) or the centre (add as a tab); drag the splitters
|
|
6
|
+
* to resize; close panes. The whole layout is a plain `DockNode` tree you pass
|
|
7
|
+
* in and bind - every gesture is a pure transform from `dock-model`, so state
|
|
8
|
+
* is serializable and testable. Inspired by Smart's `smart-layout`.
|
|
9
|
+
*
|
|
10
|
+
* Usage: build a tree with `dockGroup` / `dockTabs` / `dockPane`, bind it, and
|
|
11
|
+
* provide a `pane` snippet that renders each pane's content by id:
|
|
12
|
+
*
|
|
13
|
+
* let layout = $state(dockGroup('row', [
|
|
14
|
+
* dockTabs([dockPane('files', 'Files')]),
|
|
15
|
+
* dockTabs([dockPane('editor', 'Editor')]),
|
|
16
|
+
* ]))
|
|
17
|
+
*
|
|
18
|
+
* <SvDockLayout bind:layout>
|
|
19
|
+
* {#snippet pane(p)} ...render by p.id... {/snippet}
|
|
20
|
+
* </SvDockLayout>
|
|
21
|
+
*/
|
|
22
|
+
import { setContext, type Snippet } from 'svelte'
|
|
23
|
+
import { DOCK_CONTEXT, type DockContext } from './dock-context'
|
|
24
|
+
import DockNodeView from './DockNodeView.svelte'
|
|
25
|
+
import {
|
|
26
|
+
setActive,
|
|
27
|
+
setSizes,
|
|
28
|
+
removePane,
|
|
29
|
+
movePane,
|
|
30
|
+
type DockNode,
|
|
31
|
+
type DockPane,
|
|
32
|
+
type DockZone,
|
|
33
|
+
} from './dock-model'
|
|
34
|
+
|
|
35
|
+
type Props = {
|
|
36
|
+
/** The layout tree. Bindable - every dock/resize/close writes a new tree. */
|
|
37
|
+
layout: DockNode
|
|
38
|
+
/** Renders each pane's content, keyed off the `DockPane`. */
|
|
39
|
+
pane: Snippet<[DockPane]>
|
|
40
|
+
/** Notified after any structural change (dock / resize / close). */
|
|
41
|
+
onLayoutChange?: (layout: DockNode) => void
|
|
42
|
+
/** Minimum pane size in px along the split axis. Default 80. */
|
|
43
|
+
minSize?: number
|
|
44
|
+
/** Surface id stamped on the root (`data-dock-surface`) - used by
|
|
45
|
+
* SvDockManager to hit-test which layout a drag is over. Default 'main'. */
|
|
46
|
+
surface?: string
|
|
47
|
+
/**
|
|
48
|
+
* Manager mode: when provided, SvDockLayout hands OFF tab-drag to the parent
|
|
49
|
+
* (SvDockManager) instead of running its own drag-to-dock, so a drag can flow
|
|
50
|
+
* across the main area, floating windows and edge strips. Return value is
|
|
51
|
+
* ignored; the manager owns the gesture from here.
|
|
52
|
+
*/
|
|
53
|
+
onBeginDrag?: (event: PointerEvent, paneId: string, tabsId: string) => void
|
|
54
|
+
/** Manager mode: the externally-computed dock guide/highlight to paint. */
|
|
55
|
+
externalDrop?: { tabsId: string; zone: DockZone | null; centerOnly?: boolean } | null
|
|
56
|
+
/** Manager mode: the externally-computed tab-reorder insertion indicator. */
|
|
57
|
+
externalReorder?: { tabsId: string; index: number } | null
|
|
58
|
+
/** Stack-header controls for a leaf (maximize / float / pop-out), rendered
|
|
59
|
+
* by the manager once per leaf at the right of its tab strip. */
|
|
60
|
+
leafActions?: Snippet<[import('./dock-model').DockTabs]>
|
|
61
|
+
/** Manager mode: route tab activation to the parent (controlled). */
|
|
62
|
+
onActivate?: (tabsId: string, index: number) => void
|
|
63
|
+
/** Manager mode: route pane close to the parent (controlled). */
|
|
64
|
+
onClose?: (paneId: string) => void
|
|
65
|
+
/** Manager mode: route splitter resize to the parent (controlled). */
|
|
66
|
+
onResize?: (groupId: string, sizes: number[]) => void
|
|
67
|
+
/** Which side of each leaf the tab strip sits on. Default `'top'`. */
|
|
68
|
+
headerPosition?: 'top' | 'bottom' | 'left' | 'right'
|
|
69
|
+
/** Manager mode: id of the focused leaf, for the active-panel highlight. */
|
|
70
|
+
focusedLeaf?: string | null
|
|
71
|
+
/** Keep inactive tabs mounted (hidden) so their content state persists. */
|
|
72
|
+
keepAlive?: boolean
|
|
73
|
+
/** In a single-pane leaf, hide the redundant tab button (auto-hide fly-out). */
|
|
74
|
+
hideSingleTab?: boolean
|
|
75
|
+
/** Locked / split mode: tabs cannot be dragged (no reorder / dock / float) and
|
|
76
|
+
* panel action buttons are hidden - only splitter resize + tab switching remain.
|
|
77
|
+
* Turns the layout into a fixed set of resizable panes. */
|
|
78
|
+
locked?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let {
|
|
82
|
+
layout = $bindable(),
|
|
83
|
+
pane,
|
|
84
|
+
onLayoutChange,
|
|
85
|
+
minSize = 80,
|
|
86
|
+
surface = 'main',
|
|
87
|
+
onBeginDrag,
|
|
88
|
+
externalDrop = null,
|
|
89
|
+
externalReorder = null,
|
|
90
|
+
leafActions,
|
|
91
|
+
onActivate,
|
|
92
|
+
onClose,
|
|
93
|
+
onResize,
|
|
94
|
+
headerPosition = 'top',
|
|
95
|
+
focusedLeaf = null,
|
|
96
|
+
keepAlive = false,
|
|
97
|
+
hideSingleTab = false,
|
|
98
|
+
locked = false,
|
|
99
|
+
}: Props = $props()
|
|
100
|
+
|
|
101
|
+
const managed = $derived(!!onBeginDrag)
|
|
102
|
+
|
|
103
|
+
// Ids for nodes created by docking. The per-instance base (from a module
|
|
104
|
+
// counter) keeps them unique across instances and clear of caller ids.
|
|
105
|
+
let nextId = instanceBase()
|
|
106
|
+
const genId = () => `dk-${nextId++}`
|
|
107
|
+
|
|
108
|
+
function commit(next: DockNode) {
|
|
109
|
+
layout = next
|
|
110
|
+
onLayoutChange?.(next)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const activate = (tabsId: string, index: number) =>
|
|
114
|
+
onActivate ? onActivate(tabsId, index) : commit(setActive(layout, tabsId, index))
|
|
115
|
+
const resize = (groupId: string, sizes: number[]) =>
|
|
116
|
+
onResize ? onResize(groupId, sizes) : commit(setSizes(layout, groupId, sizes))
|
|
117
|
+
const close = (paneId: string) => {
|
|
118
|
+
if (onClose) { onClose(paneId); return }
|
|
119
|
+
const { root } = removePane(layout, paneId)
|
|
120
|
+
if (root) commit(root)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- drag-to-dock ------------------------------------------------------
|
|
124
|
+
type Drag = { paneId: string; sourceTabsId: string; title: string; x: number; y: number }
|
|
125
|
+
let drag = $state<Drag | null>(null)
|
|
126
|
+
let dropTarget = $state<{ tabsId: string; zone: DockZone } | null>(null)
|
|
127
|
+
let candidate: { paneId: string; sourceTabsId: string; title: string; sx: number; sy: number } | null = null
|
|
128
|
+
|
|
129
|
+
function paneTitle(paneId: string): string {
|
|
130
|
+
const walk = (n: DockNode): string | null => {
|
|
131
|
+
if (n.type === 'tabs') return n.panes.find((p) => p.id === paneId)?.title ?? null
|
|
132
|
+
for (const c of n.children) { const t = walk(c); if (t) return t }
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
return walk(layout) ?? ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function beginDrag(e: PointerEvent, paneId: string, tabsId: string) {
|
|
139
|
+
if (e.button !== 0) return
|
|
140
|
+
// Locked / split mode: tabs are fixed - a pointerdown still activates the tab
|
|
141
|
+
// (handled separately), but starts no drag.
|
|
142
|
+
if (locked) return
|
|
143
|
+
// Manager mode: delegate the whole gesture to the parent.
|
|
144
|
+
if (onBeginDrag) { onBeginDrag(e, paneId, tabsId); return }
|
|
145
|
+
candidate = { paneId, sourceTabsId: tabsId, title: paneTitle(paneId), sx: e.clientX, sy: e.clientY }
|
|
146
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
147
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Which leaf is under the pointer, and which of its 5 zones. */
|
|
151
|
+
function hitTest(x: number, y: number): { tabsId: string; zone: DockZone } | null {
|
|
152
|
+
const el = document.elementFromPoint(x, y)?.closest('[data-dock-tabs]') as HTMLElement | null
|
|
153
|
+
if (!el) return null
|
|
154
|
+
const tabsId = el.dataset.dockTabs!
|
|
155
|
+
const r = el.getBoundingClientRect()
|
|
156
|
+
if (r.width <= 0 || r.height <= 0) return null
|
|
157
|
+
const rx = (x - r.left) / r.width
|
|
158
|
+
const ry = (y - r.top) / r.height
|
|
159
|
+
const edge = 0.25
|
|
160
|
+
const m = Math.min(rx, 1 - rx, ry, 1 - ry)
|
|
161
|
+
let zone: DockZone = 'center'
|
|
162
|
+
if (m <= edge) {
|
|
163
|
+
const nearest = Math.min(rx, 1 - rx, ry, 1 - ry)
|
|
164
|
+
zone = nearest === rx ? 'left' : nearest === 1 - rx ? 'right' : nearest === ry ? 'top' : 'bottom'
|
|
165
|
+
}
|
|
166
|
+
return { tabsId, zone }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onPointerMove(e: PointerEvent) {
|
|
170
|
+
if (!candidate) return
|
|
171
|
+
if (!drag) {
|
|
172
|
+
// Small threshold so a click-to-activate does not start a drag.
|
|
173
|
+
if (Math.hypot(e.clientX - candidate.sx, e.clientY - candidate.sy) < 5) return
|
|
174
|
+
drag = { paneId: candidate.paneId, sourceTabsId: candidate.sourceTabsId, title: candidate.title, x: e.clientX, y: e.clientY }
|
|
175
|
+
document.body.style.userSelect = 'none'
|
|
176
|
+
document.body.style.cursor = 'grabbing'
|
|
177
|
+
}
|
|
178
|
+
drag = { ...drag, x: e.clientX, y: e.clientY }
|
|
179
|
+
dropTarget = hitTest(e.clientX, e.clientY)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onPointerUp() {
|
|
183
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
184
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
185
|
+
if (drag && dropTarget) {
|
|
186
|
+
commit(movePane(layout, drag.paneId, dropTarget.tabsId, dropTarget.zone, genId))
|
|
187
|
+
}
|
|
188
|
+
drag = null
|
|
189
|
+
dropTarget = null
|
|
190
|
+
candidate = null
|
|
191
|
+
document.body.style.userSelect = ''
|
|
192
|
+
document.body.style.cursor = ''
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setContext<DockContext>(DOCK_CONTEXT, {
|
|
196
|
+
get pane() { return pane },
|
|
197
|
+
get leafActions() { return locked ? undefined : leafActions },
|
|
198
|
+
activate,
|
|
199
|
+
close,
|
|
200
|
+
beginDrag,
|
|
201
|
+
resize,
|
|
202
|
+
minSize: () => minSize,
|
|
203
|
+
// In manager mode the highlight is driven from outside.
|
|
204
|
+
dropTarget: () => (managed ? externalDrop : dropTarget),
|
|
205
|
+
reorderTarget: () => (managed ? externalReorder : null),
|
|
206
|
+
headerPosition: () => headerPosition,
|
|
207
|
+
focusedLeaf: () => focusedLeaf,
|
|
208
|
+
keepAlive: () => keepAlive,
|
|
209
|
+
hideSingleTab: () => hideSingleTab,
|
|
210
|
+
})
|
|
211
|
+
</script>
|
|
212
|
+
|
|
213
|
+
<div class="sv-dock" class:is-dragging={!!drag} data-dock-surface={surface}>
|
|
214
|
+
<DockNodeView node={layout} />
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{#if drag}
|
|
218
|
+
<div class="sv-dock__ghost" style:left={`${drag.x + 12}px`} style:top={`${drag.y + 12}px`}>
|
|
219
|
+
{drag.title}
|
|
220
|
+
</div>
|
|
221
|
+
{/if}
|
|
222
|
+
|
|
223
|
+
<script lang="ts" module>
|
|
224
|
+
// Per-instance id base so concurrent layouts never mint the same node id.
|
|
225
|
+
let SEQ = 0
|
|
226
|
+
function instanceBase(): number {
|
|
227
|
+
SEQ += 1
|
|
228
|
+
return SEQ * 1_000_000
|
|
229
|
+
}
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<style>
|
|
233
|
+
.sv-dock { width: 100%; height: 100%; min-width: 0; min-height: 0; display: flex; }
|
|
234
|
+
.sv-dock > :global(*) { flex: 1; min-width: 0; min-height: 0; }
|
|
235
|
+
.sv-dock.is-dragging { cursor: grabbing; }
|
|
236
|
+
.sv-dock.is-dragging :global(.sv-dock__content) { pointer-events: none; }
|
|
237
|
+
|
|
238
|
+
.sv-dock__ghost {
|
|
239
|
+
position: fixed; z-index: 2147483646; pointer-events: none;
|
|
240
|
+
padding: 5px 10px; font-size: 12.5px; font-weight: 600;
|
|
241
|
+
color: var(--sg-on-accent, #fff); background: var(--sg-accent, #2563eb);
|
|
242
|
+
border-radius: 6px; box-shadow: 0 8px 24px -6px rgba(15,23,42,0.5); opacity: 0.95;
|
|
243
|
+
}
|
|
244
|
+
</style>
|