@svgrid/grid 2.2.0 → 2.2.1
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/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +178 -178
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvColorInput.svelte +187 -187
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvGrid.svelte +3131 -3131
- package/dist/SvGridBoard.svelte +2339 -2339
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridDropdown.svelte +689 -689
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +117 -117
- package/dist/SvNumberInput.svelte +131 -131
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPopover.svelte +139 -139
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvTooltip.svelte +127 -127
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +377 -377
- package/dist/SvTreeSelect.svelte +230 -230
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/chart-export.js +8 -8
- package/package.json +10 -10
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +178 -178
- package/src/SvCarousel.svelte +141 -141
- package/src/SvColorInput.svelte +187 -187
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvDrawer.svelte +250 -250
- package/src/SvGrid.controller.svelte.ts +3158 -3158
- package/src/SvGrid.svelte +3131 -3131
- package/src/SvGrid.types.ts +1489 -1489
- package/src/SvGridBoard.svelte +2339 -2339
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridDropdown.svelte +689 -689
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +117 -117
- package/src/SvNumberInput.svelte +131 -131
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPopover.svelte +139 -139
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvTooltip.svelte +127 -127
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +377 -377
- package/src/SvTreeSelect.svelte +230 -230
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/build-api.ts +870 -870
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +179 -179
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +162 -162
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +202 -202
- package/src/createPopoverSelect.svelte.ts +206 -206
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/flex-render.ts +3 -3
- package/src/index.ts +623 -623
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/menus.ts +639 -639
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +501 -501
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +527 -527
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +159 -159
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
package/src/SvPopover.svelte
CHANGED
|
@@ -1,139 +1,139 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* SvPopover - a floating panel anchored to a trigger, portalled to <body> so it
|
|
4
|
-
* escapes any `overflow:hidden` ancestor. Open on click (default), hover, or
|
|
5
|
-
* manually via `open`. Flips above the trigger when there's no room below,
|
|
6
|
-
* closes on outside-click / Escape, and animates in. Parity: Smart popover.
|
|
7
|
-
*
|
|
8
|
-
* ```svelte
|
|
9
|
-
* <SvPopover>
|
|
10
|
-
* {#snippet anchor()}<button>Open</button>{/snippet}
|
|
11
|
-
* <p>Panel content</p>
|
|
12
|
-
* </SvPopover>
|
|
13
|
-
* ```
|
|
14
|
-
*/
|
|
15
|
-
import type { Snippet } from 'svelte'
|
|
16
|
-
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
17
|
-
import { createDismissableLayer } from './a11y/dismissable'
|
|
18
|
-
|
|
19
|
-
type Props = {
|
|
20
|
-
/** Controlled open state (bindable). */
|
|
21
|
-
open?: boolean
|
|
22
|
-
onOpenChange?: (open: boolean) => void
|
|
23
|
-
/** How the anchor opens the panel. `manual` = only `open` drives it. */
|
|
24
|
-
trigger?: 'click' | 'hover' | 'manual'
|
|
25
|
-
/** Estimated panel height for flip decisions. */
|
|
26
|
-
estimatedHeight?: number
|
|
27
|
-
/** Force a minimum panel width (else it hugs its content). */
|
|
28
|
-
minWidth?: number
|
|
29
|
-
/** Close when clicking outside. Default true. */
|
|
30
|
-
closeOnOutsideClick?: boolean
|
|
31
|
-
ariaLabel?: string
|
|
32
|
-
/** The anchor element(s) that open the panel. */
|
|
33
|
-
anchor?: Snippet
|
|
34
|
-
/** Panel content. */
|
|
35
|
-
children?: Snippet
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
let {
|
|
39
|
-
open = $bindable(false),
|
|
40
|
-
onOpenChange,
|
|
41
|
-
trigger = 'click',
|
|
42
|
-
estimatedHeight = 220,
|
|
43
|
-
minWidth,
|
|
44
|
-
closeOnOutsideClick = true,
|
|
45
|
-
ariaLabel,
|
|
46
|
-
anchor,
|
|
47
|
-
children,
|
|
48
|
-
}: Props = $props()
|
|
49
|
-
|
|
50
|
-
let anchorEl = $state<HTMLSpanElement | null>(null)
|
|
51
|
-
let panelEl = $state<HTMLDivElement | null>(null)
|
|
52
|
-
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
53
|
-
|
|
54
|
-
function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
|
|
55
|
-
function toggle() { setOpen(!open) }
|
|
56
|
-
|
|
57
|
-
function updatePos() {
|
|
58
|
-
if (!anchorEl) return
|
|
59
|
-
rect = anchoredRect(anchorEl.getBoundingClientRect(), { estimatedHeight, minWidth })
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
$effect(() => {
|
|
63
|
-
if (!open) return
|
|
64
|
-
updatePos()
|
|
65
|
-
const rp = () => updatePos()
|
|
66
|
-
window.addEventListener('scroll', rp, true)
|
|
67
|
-
window.addEventListener('resize', rp)
|
|
68
|
-
// Escape + outside-click dismissal via the shared layer stack (so a popover
|
|
69
|
-
// nested in a dialog closes top-first). The trigger is passed alongside the
|
|
70
|
-
// panel so clicking it toggles rather than dismiss-then-reopen.
|
|
71
|
-
const layer = createDismissableLayer({
|
|
72
|
-
element: () => [anchorEl, panelEl],
|
|
73
|
-
onDismiss: () => setOpen(false),
|
|
74
|
-
closeOnOutside: closeOnOutsideClick,
|
|
75
|
-
})
|
|
76
|
-
layer.activate()
|
|
77
|
-
return () => {
|
|
78
|
-
window.removeEventListener('scroll', rp, true)
|
|
79
|
-
window.removeEventListener('resize', rp)
|
|
80
|
-
layer.release()
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
let hoverTimer: ReturnType<typeof setTimeout> | undefined
|
|
85
|
-
function onEnter() { if (trigger === 'hover') { clearTimeout(hoverTimer); setOpen(true) } }
|
|
86
|
-
function onLeave() { if (trigger === 'hover') { hoverTimer = setTimeout(() => setOpen(false), 120) } }
|
|
87
|
-
|
|
88
|
-
/** Fallback accessible name for the panel when `ariaLabel` isn't given: the
|
|
89
|
-
* anchor's own aria-label, else its trimmed text content. */
|
|
90
|
-
function anchorFallbackLabel(): string | undefined {
|
|
91
|
-
const label = anchorEl?.getAttribute('aria-label')
|
|
92
|
-
if (label) return label
|
|
93
|
-
return anchorEl?.textContent?.trim() || undefined
|
|
94
|
-
}
|
|
95
|
-
</script>
|
|
96
|
-
|
|
97
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
98
|
-
<span
|
|
99
|
-
bind:this={anchorEl}
|
|
100
|
-
class="sv-pop__anchor"
|
|
101
|
-
onclick={() => trigger === 'click' && toggle()}
|
|
102
|
-
onpointerenter={onEnter}
|
|
103
|
-
onpointerleave={onLeave}
|
|
104
|
-
onfocusin={onEnter}
|
|
105
|
-
onfocusout={onLeave}
|
|
106
|
-
>
|
|
107
|
-
{@render anchor?.()}
|
|
108
|
-
</span>
|
|
109
|
-
|
|
110
|
-
{#if open}
|
|
111
|
-
<div
|
|
112
|
-
bind:this={panelEl}
|
|
113
|
-
class="sv-pop__panel"
|
|
114
|
-
use:portalToBody
|
|
115
|
-
use:popIn={{ up: rect.openUpward }}
|
|
116
|
-
style:position="fixed"
|
|
117
|
-
style:top={`${rect.top}px`}
|
|
118
|
-
style:left={`${rect.left}px`}
|
|
119
|
-
style:min-width={minWidth ? `${rect.width}px` : undefined}
|
|
120
|
-
role="dialog"
|
|
121
|
-
aria-label={ariaLabel ?? anchorFallbackLabel()}
|
|
122
|
-
onpointerenter={onEnter}
|
|
123
|
-
onpointerleave={onLeave}
|
|
124
|
-
onfocusin={onEnter}
|
|
125
|
-
onfocusout={onLeave}
|
|
126
|
-
>
|
|
127
|
-
{@render children?.()}
|
|
128
|
-
</div>
|
|
129
|
-
{/if}
|
|
130
|
-
|
|
131
|
-
<style>
|
|
132
|
-
.sv-pop__anchor { display: inline-flex; }
|
|
133
|
-
:global(.sv-pop__panel) {
|
|
134
|
-
z-index: 2147483646; max-width: min(92vw, 360px); padding: 12px 14px;
|
|
135
|
-
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
136
|
-
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
|
|
137
|
-
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px; line-height: 1.55;
|
|
138
|
-
}
|
|
139
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvPopover - a floating panel anchored to a trigger, portalled to <body> so it
|
|
4
|
+
* escapes any `overflow:hidden` ancestor. Open on click (default), hover, or
|
|
5
|
+
* manually via `open`. Flips above the trigger when there's no room below,
|
|
6
|
+
* closes on outside-click / Escape, and animates in. Parity: Smart popover.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <SvPopover>
|
|
10
|
+
* {#snippet anchor()}<button>Open</button>{/snippet}
|
|
11
|
+
* <p>Panel content</p>
|
|
12
|
+
* </SvPopover>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import type { Snippet } from 'svelte'
|
|
16
|
+
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
17
|
+
import { createDismissableLayer } from './a11y/dismissable'
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
/** Controlled open state (bindable). */
|
|
21
|
+
open?: boolean
|
|
22
|
+
onOpenChange?: (open: boolean) => void
|
|
23
|
+
/** How the anchor opens the panel. `manual` = only `open` drives it. */
|
|
24
|
+
trigger?: 'click' | 'hover' | 'manual'
|
|
25
|
+
/** Estimated panel height for flip decisions. */
|
|
26
|
+
estimatedHeight?: number
|
|
27
|
+
/** Force a minimum panel width (else it hugs its content). */
|
|
28
|
+
minWidth?: number
|
|
29
|
+
/** Close when clicking outside. Default true. */
|
|
30
|
+
closeOnOutsideClick?: boolean
|
|
31
|
+
ariaLabel?: string
|
|
32
|
+
/** The anchor element(s) that open the panel. */
|
|
33
|
+
anchor?: Snippet
|
|
34
|
+
/** Panel content. */
|
|
35
|
+
children?: Snippet
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
open = $bindable(false),
|
|
40
|
+
onOpenChange,
|
|
41
|
+
trigger = 'click',
|
|
42
|
+
estimatedHeight = 220,
|
|
43
|
+
minWidth,
|
|
44
|
+
closeOnOutsideClick = true,
|
|
45
|
+
ariaLabel,
|
|
46
|
+
anchor,
|
|
47
|
+
children,
|
|
48
|
+
}: Props = $props()
|
|
49
|
+
|
|
50
|
+
let anchorEl = $state<HTMLSpanElement | null>(null)
|
|
51
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
52
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
53
|
+
|
|
54
|
+
function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
|
|
55
|
+
function toggle() { setOpen(!open) }
|
|
56
|
+
|
|
57
|
+
function updatePos() {
|
|
58
|
+
if (!anchorEl) return
|
|
59
|
+
rect = anchoredRect(anchorEl.getBoundingClientRect(), { estimatedHeight, minWidth })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (!open) return
|
|
64
|
+
updatePos()
|
|
65
|
+
const rp = () => updatePos()
|
|
66
|
+
window.addEventListener('scroll', rp, true)
|
|
67
|
+
window.addEventListener('resize', rp)
|
|
68
|
+
// Escape + outside-click dismissal via the shared layer stack (so a popover
|
|
69
|
+
// nested in a dialog closes top-first). The trigger is passed alongside the
|
|
70
|
+
// panel so clicking it toggles rather than dismiss-then-reopen.
|
|
71
|
+
const layer = createDismissableLayer({
|
|
72
|
+
element: () => [anchorEl, panelEl],
|
|
73
|
+
onDismiss: () => setOpen(false),
|
|
74
|
+
closeOnOutside: closeOnOutsideClick,
|
|
75
|
+
})
|
|
76
|
+
layer.activate()
|
|
77
|
+
return () => {
|
|
78
|
+
window.removeEventListener('scroll', rp, true)
|
|
79
|
+
window.removeEventListener('resize', rp)
|
|
80
|
+
layer.release()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
let hoverTimer: ReturnType<typeof setTimeout> | undefined
|
|
85
|
+
function onEnter() { if (trigger === 'hover') { clearTimeout(hoverTimer); setOpen(true) } }
|
|
86
|
+
function onLeave() { if (trigger === 'hover') { hoverTimer = setTimeout(() => setOpen(false), 120) } }
|
|
87
|
+
|
|
88
|
+
/** Fallback accessible name for the panel when `ariaLabel` isn't given: the
|
|
89
|
+
* anchor's own aria-label, else its trimmed text content. */
|
|
90
|
+
function anchorFallbackLabel(): string | undefined {
|
|
91
|
+
const label = anchorEl?.getAttribute('aria-label')
|
|
92
|
+
if (label) return label
|
|
93
|
+
return anchorEl?.textContent?.trim() || undefined
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
98
|
+
<span
|
|
99
|
+
bind:this={anchorEl}
|
|
100
|
+
class="sv-pop__anchor"
|
|
101
|
+
onclick={() => trigger === 'click' && toggle()}
|
|
102
|
+
onpointerenter={onEnter}
|
|
103
|
+
onpointerleave={onLeave}
|
|
104
|
+
onfocusin={onEnter}
|
|
105
|
+
onfocusout={onLeave}
|
|
106
|
+
>
|
|
107
|
+
{@render anchor?.()}
|
|
108
|
+
</span>
|
|
109
|
+
|
|
110
|
+
{#if open}
|
|
111
|
+
<div
|
|
112
|
+
bind:this={panelEl}
|
|
113
|
+
class="sv-pop__panel"
|
|
114
|
+
use:portalToBody
|
|
115
|
+
use:popIn={{ up: rect.openUpward }}
|
|
116
|
+
style:position="fixed"
|
|
117
|
+
style:top={`${rect.top}px`}
|
|
118
|
+
style:left={`${rect.left}px`}
|
|
119
|
+
style:min-width={minWidth ? `${rect.width}px` : undefined}
|
|
120
|
+
role="dialog"
|
|
121
|
+
aria-label={ariaLabel ?? anchorFallbackLabel()}
|
|
122
|
+
onpointerenter={onEnter}
|
|
123
|
+
onpointerleave={onLeave}
|
|
124
|
+
onfocusin={onEnter}
|
|
125
|
+
onfocusout={onLeave}
|
|
126
|
+
>
|
|
127
|
+
{@render children?.()}
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
|
|
131
|
+
<style>
|
|
132
|
+
.sv-pop__anchor { display: inline-flex; }
|
|
133
|
+
:global(.sv-pop__panel) {
|
|
134
|
+
z-index: 2147483646; max-width: min(92vw, 360px); padding: 12px 14px;
|
|
135
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
136
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
|
|
137
|
+
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px; line-height: 1.55;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
@@ -1,170 +1,170 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
A row-group panel (like the "drag columns to group" bar in enterprise grids).
|
|
3
|
-
Shows the current group-by columns as chips you can remove or drag to reorder,
|
|
4
|
-
plus a "+ Group by" menu to add one. It is also a drop target: a column drag
|
|
5
|
-
that carries `text/sv-column` (its column id) adds that column. Wire `onChange`
|
|
6
|
-
to a server group model's `setGroupBy`, or to client grouping.
|
|
7
|
-
|
|
8
|
-
<SvRowGroupPanel
|
|
9
|
-
columns={[{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]}
|
|
10
|
-
groupBy={view.groupBy}
|
|
11
|
-
onChange={(g) => ctl.setGroupBy(g)}
|
|
12
|
-
/>
|
|
13
|
-
-->
|
|
14
|
-
<script lang="ts">
|
|
15
|
-
type Col = { id: string; label: string }
|
|
16
|
-
type Props = {
|
|
17
|
-
/** Columns that can be grouped on. */
|
|
18
|
-
columns: Col[]
|
|
19
|
-
/** Current group-by column ids, outer to inner. */
|
|
20
|
-
groupBy: string[]
|
|
21
|
-
/** Called with the new group-by order. */
|
|
22
|
-
onChange: (groupBy: string[]) => void
|
|
23
|
-
/** Text shown when nothing is grouped. */
|
|
24
|
-
placeholder?: string
|
|
25
|
-
}
|
|
26
|
-
let { columns, groupBy, onChange, placeholder = 'Drag a column here to group by it' }: Props = $props()
|
|
27
|
-
|
|
28
|
-
const labelOf = (id: string) => columns.find((c) => c.id === id)?.label ?? id
|
|
29
|
-
const available = $derived(columns.filter((c) => !groupBy.includes(c.id)))
|
|
30
|
-
|
|
31
|
-
let dragIndex = $state<number | null>(null)
|
|
32
|
-
|
|
33
|
-
function remove(id: string) {
|
|
34
|
-
onChange(groupBy.filter((g) => g !== id))
|
|
35
|
-
}
|
|
36
|
-
function add(id: string) {
|
|
37
|
-
if (id && !groupBy.includes(id)) onChange([...groupBy, id])
|
|
38
|
-
}
|
|
39
|
-
function reorder(to: number) {
|
|
40
|
-
if (dragIndex === null || dragIndex === to) return
|
|
41
|
-
const next = [...groupBy]
|
|
42
|
-
const [moved] = next.splice(dragIndex, 1)
|
|
43
|
-
next.splice(to, 0, moved!)
|
|
44
|
-
dragIndex = null
|
|
45
|
-
onChange(next)
|
|
46
|
-
}
|
|
47
|
-
function onPanelDrop(e: DragEvent) {
|
|
48
|
-
const id = e.dataTransfer?.getData('text/sv-column')
|
|
49
|
-
if (id) add(id)
|
|
50
|
-
}
|
|
51
|
-
/** Keyboard equivalent of dragging a chip: moves the chip at `i` one slot earlier/later,
|
|
52
|
-
* reusing `reorder()` (via `dragIndex`) so there is a single source of truth for the move. */
|
|
53
|
-
function moveByKeyboard(i: number, delta: number) {
|
|
54
|
-
const to = i + delta
|
|
55
|
-
if (to < 0 || to >= groupBy.length) return
|
|
56
|
-
dragIndex = i
|
|
57
|
-
reorder(to)
|
|
58
|
-
}
|
|
59
|
-
function onChipKeydown(e: KeyboardEvent, i: number) {
|
|
60
|
-
if (!e.altKey) return
|
|
61
|
-
if (e.key === 'ArrowLeft') {
|
|
62
|
-
e.preventDefault()
|
|
63
|
-
moveByKeyboard(i, -1)
|
|
64
|
-
} else if (e.key === 'ArrowRight') {
|
|
65
|
-
e.preventDefault()
|
|
66
|
-
moveByKeyboard(i, 1)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
</script>
|
|
70
|
-
|
|
71
|
-
<div
|
|
72
|
-
class="sv-rowgroup-panel"
|
|
73
|
-
role="group"
|
|
74
|
-
aria-label="Row groups"
|
|
75
|
-
ondragover={(e) => e.preventDefault()}
|
|
76
|
-
ondrop={onPanelDrop}
|
|
77
|
-
>
|
|
78
|
-
<span class="sv-rgp-label" aria-hidden="true">Group by:</span>
|
|
79
|
-
{#if groupBy.length === 0}
|
|
80
|
-
<span class="sv-rgp-empty">{placeholder}</span>
|
|
81
|
-
{:else}
|
|
82
|
-
{#each groupBy as id, i (id)}
|
|
83
|
-
{#if i > 0}<span class="sv-rgp-sep" aria-hidden="true">›</span>{/if}
|
|
84
|
-
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
85
|
-
<span
|
|
86
|
-
class="sv-rgp-chip"
|
|
87
|
-
class:sv-rgp-chip-drag={dragIndex === i}
|
|
88
|
-
role="group"
|
|
89
|
-
tabindex="0"
|
|
90
|
-
aria-label={`Grouped by ${labelOf(id)}, position ${i + 1} of ${groupBy.length}. Press Alt+Arrow keys to reorder.`}
|
|
91
|
-
draggable="true"
|
|
92
|
-
ondragstart={() => (dragIndex = i)}
|
|
93
|
-
ondragend={() => (dragIndex = null)}
|
|
94
|
-
ondragover={(e) => e.preventDefault()}
|
|
95
|
-
ondrop={() => reorder(i)}
|
|
96
|
-
onkeydown={(e) => onChipKeydown(e, i)}
|
|
97
|
-
>
|
|
98
|
-
<span class="sv-rgp-grip" aria-hidden="true">⠿</span>
|
|
99
|
-
{labelOf(id)}
|
|
100
|
-
<button type="button" class="sv-rgp-x" onclick={() => remove(id)} aria-label={`Stop grouping by ${labelOf(id)}`}>×</button>
|
|
101
|
-
</span>
|
|
102
|
-
{/each}
|
|
103
|
-
{/if}
|
|
104
|
-
{#if available.length}
|
|
105
|
-
<select
|
|
106
|
-
class="sv-rgp-add"
|
|
107
|
-
aria-label="Add a group column"
|
|
108
|
-
onchange={(e) => { add(e.currentTarget.value); e.currentTarget.value = '' }}
|
|
109
|
-
>
|
|
110
|
-
<option value="">+ Group by</option>
|
|
111
|
-
{#each available as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
|
|
112
|
-
</select>
|
|
113
|
-
{/if}
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
<style>
|
|
117
|
-
.sv-rowgroup-panel {
|
|
118
|
-
display: flex;
|
|
119
|
-
align-items: center;
|
|
120
|
-
flex-wrap: wrap;
|
|
121
|
-
gap: 6px;
|
|
122
|
-
padding: 8px 10px;
|
|
123
|
-
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
124
|
-
border-radius: 10px;
|
|
125
|
-
background: var(--sg-header-bg, #f8fafc);
|
|
126
|
-
font-size: 13px;
|
|
127
|
-
}
|
|
128
|
-
.sv-rgp-label { color: var(--sg-muted, #64748b); font-weight: 600; }
|
|
129
|
-
.sv-rgp-empty { color: var(--sg-muted, #94a3b8); font-style: italic; }
|
|
130
|
-
.sv-rgp-sep { color: var(--sg-muted, #94a3b8); }
|
|
131
|
-
.sv-rgp-chip {
|
|
132
|
-
display: inline-flex;
|
|
133
|
-
align-items: center;
|
|
134
|
-
gap: 6px;
|
|
135
|
-
padding: 3px 6px 3px 8px;
|
|
136
|
-
border-radius: 999px;
|
|
137
|
-
background: var(--sg-bg, #fff);
|
|
138
|
-
border: 1px solid var(--sg-border, #e2e8f0);
|
|
139
|
-
cursor: grab;
|
|
140
|
-
user-select: none;
|
|
141
|
-
font-weight: 600;
|
|
142
|
-
}
|
|
143
|
-
.sv-rgp-chip-drag { opacity: 0.5; }
|
|
144
|
-
.sv-rgp-grip { color: var(--sg-muted, #94a3b8); cursor: grab; }
|
|
145
|
-
.sv-rgp-x {
|
|
146
|
-
display: inline-flex;
|
|
147
|
-
align-items: center;
|
|
148
|
-
justify-content: center;
|
|
149
|
-
width: 16px;
|
|
150
|
-
height: 16px;
|
|
151
|
-
border: 0;
|
|
152
|
-
border-radius: 50%;
|
|
153
|
-
background: none;
|
|
154
|
-
color: var(--sg-muted, #64748b);
|
|
155
|
-
cursor: pointer;
|
|
156
|
-
font-size: 14px;
|
|
157
|
-
line-height: 1;
|
|
158
|
-
}
|
|
159
|
-
.sv-rgp-x:hover { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
160
|
-
.sv-rgp-add {
|
|
161
|
-
font: inherit;
|
|
162
|
-
font-size: 13px;
|
|
163
|
-
padding: 4px 8px;
|
|
164
|
-
border-radius: 8px;
|
|
165
|
-
border: 1px solid var(--sg-border, #e2e8f0);
|
|
166
|
-
background: var(--sg-bg, #fff);
|
|
167
|
-
color: var(--sg-accent, #2563eb);
|
|
168
|
-
cursor: pointer;
|
|
169
|
-
}
|
|
170
|
-
</style>
|
|
1
|
+
<!--
|
|
2
|
+
A row-group panel (like the "drag columns to group" bar in enterprise grids).
|
|
3
|
+
Shows the current group-by columns as chips you can remove or drag to reorder,
|
|
4
|
+
plus a "+ Group by" menu to add one. It is also a drop target: a column drag
|
|
5
|
+
that carries `text/sv-column` (its column id) adds that column. Wire `onChange`
|
|
6
|
+
to a server group model's `setGroupBy`, or to client grouping.
|
|
7
|
+
|
|
8
|
+
<SvRowGroupPanel
|
|
9
|
+
columns={[{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]}
|
|
10
|
+
groupBy={view.groupBy}
|
|
11
|
+
onChange={(g) => ctl.setGroupBy(g)}
|
|
12
|
+
/>
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
type Col = { id: string; label: string }
|
|
16
|
+
type Props = {
|
|
17
|
+
/** Columns that can be grouped on. */
|
|
18
|
+
columns: Col[]
|
|
19
|
+
/** Current group-by column ids, outer to inner. */
|
|
20
|
+
groupBy: string[]
|
|
21
|
+
/** Called with the new group-by order. */
|
|
22
|
+
onChange: (groupBy: string[]) => void
|
|
23
|
+
/** Text shown when nothing is grouped. */
|
|
24
|
+
placeholder?: string
|
|
25
|
+
}
|
|
26
|
+
let { columns, groupBy, onChange, placeholder = 'Drag a column here to group by it' }: Props = $props()
|
|
27
|
+
|
|
28
|
+
const labelOf = (id: string) => columns.find((c) => c.id === id)?.label ?? id
|
|
29
|
+
const available = $derived(columns.filter((c) => !groupBy.includes(c.id)))
|
|
30
|
+
|
|
31
|
+
let dragIndex = $state<number | null>(null)
|
|
32
|
+
|
|
33
|
+
function remove(id: string) {
|
|
34
|
+
onChange(groupBy.filter((g) => g !== id))
|
|
35
|
+
}
|
|
36
|
+
function add(id: string) {
|
|
37
|
+
if (id && !groupBy.includes(id)) onChange([...groupBy, id])
|
|
38
|
+
}
|
|
39
|
+
function reorder(to: number) {
|
|
40
|
+
if (dragIndex === null || dragIndex === to) return
|
|
41
|
+
const next = [...groupBy]
|
|
42
|
+
const [moved] = next.splice(dragIndex, 1)
|
|
43
|
+
next.splice(to, 0, moved!)
|
|
44
|
+
dragIndex = null
|
|
45
|
+
onChange(next)
|
|
46
|
+
}
|
|
47
|
+
function onPanelDrop(e: DragEvent) {
|
|
48
|
+
const id = e.dataTransfer?.getData('text/sv-column')
|
|
49
|
+
if (id) add(id)
|
|
50
|
+
}
|
|
51
|
+
/** Keyboard equivalent of dragging a chip: moves the chip at `i` one slot earlier/later,
|
|
52
|
+
* reusing `reorder()` (via `dragIndex`) so there is a single source of truth for the move. */
|
|
53
|
+
function moveByKeyboard(i: number, delta: number) {
|
|
54
|
+
const to = i + delta
|
|
55
|
+
if (to < 0 || to >= groupBy.length) return
|
|
56
|
+
dragIndex = i
|
|
57
|
+
reorder(to)
|
|
58
|
+
}
|
|
59
|
+
function onChipKeydown(e: KeyboardEvent, i: number) {
|
|
60
|
+
if (!e.altKey) return
|
|
61
|
+
if (e.key === 'ArrowLeft') {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
moveByKeyboard(i, -1)
|
|
64
|
+
} else if (e.key === 'ArrowRight') {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
moveByKeyboard(i, 1)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div
|
|
72
|
+
class="sv-rowgroup-panel"
|
|
73
|
+
role="group"
|
|
74
|
+
aria-label="Row groups"
|
|
75
|
+
ondragover={(e) => e.preventDefault()}
|
|
76
|
+
ondrop={onPanelDrop}
|
|
77
|
+
>
|
|
78
|
+
<span class="sv-rgp-label" aria-hidden="true">Group by:</span>
|
|
79
|
+
{#if groupBy.length === 0}
|
|
80
|
+
<span class="sv-rgp-empty">{placeholder}</span>
|
|
81
|
+
{:else}
|
|
82
|
+
{#each groupBy as id, i (id)}
|
|
83
|
+
{#if i > 0}<span class="sv-rgp-sep" aria-hidden="true">›</span>{/if}
|
|
84
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
85
|
+
<span
|
|
86
|
+
class="sv-rgp-chip"
|
|
87
|
+
class:sv-rgp-chip-drag={dragIndex === i}
|
|
88
|
+
role="group"
|
|
89
|
+
tabindex="0"
|
|
90
|
+
aria-label={`Grouped by ${labelOf(id)}, position ${i + 1} of ${groupBy.length}. Press Alt+Arrow keys to reorder.`}
|
|
91
|
+
draggable="true"
|
|
92
|
+
ondragstart={() => (dragIndex = i)}
|
|
93
|
+
ondragend={() => (dragIndex = null)}
|
|
94
|
+
ondragover={(e) => e.preventDefault()}
|
|
95
|
+
ondrop={() => reorder(i)}
|
|
96
|
+
onkeydown={(e) => onChipKeydown(e, i)}
|
|
97
|
+
>
|
|
98
|
+
<span class="sv-rgp-grip" aria-hidden="true">⠿</span>
|
|
99
|
+
{labelOf(id)}
|
|
100
|
+
<button type="button" class="sv-rgp-x" onclick={() => remove(id)} aria-label={`Stop grouping by ${labelOf(id)}`}>×</button>
|
|
101
|
+
</span>
|
|
102
|
+
{/each}
|
|
103
|
+
{/if}
|
|
104
|
+
{#if available.length}
|
|
105
|
+
<select
|
|
106
|
+
class="sv-rgp-add"
|
|
107
|
+
aria-label="Add a group column"
|
|
108
|
+
onchange={(e) => { add(e.currentTarget.value); e.currentTarget.value = '' }}
|
|
109
|
+
>
|
|
110
|
+
<option value="">+ Group by</option>
|
|
111
|
+
{#each available as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
|
|
112
|
+
</select>
|
|
113
|
+
{/if}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<style>
|
|
117
|
+
.sv-rowgroup-panel {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
gap: 6px;
|
|
122
|
+
padding: 8px 10px;
|
|
123
|
+
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
124
|
+
border-radius: 10px;
|
|
125
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
126
|
+
font-size: 13px;
|
|
127
|
+
}
|
|
128
|
+
.sv-rgp-label { color: var(--sg-muted, #64748b); font-weight: 600; }
|
|
129
|
+
.sv-rgp-empty { color: var(--sg-muted, #94a3b8); font-style: italic; }
|
|
130
|
+
.sv-rgp-sep { color: var(--sg-muted, #94a3b8); }
|
|
131
|
+
.sv-rgp-chip {
|
|
132
|
+
display: inline-flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 6px;
|
|
135
|
+
padding: 3px 6px 3px 8px;
|
|
136
|
+
border-radius: 999px;
|
|
137
|
+
background: var(--sg-bg, #fff);
|
|
138
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
139
|
+
cursor: grab;
|
|
140
|
+
user-select: none;
|
|
141
|
+
font-weight: 600;
|
|
142
|
+
}
|
|
143
|
+
.sv-rgp-chip-drag { opacity: 0.5; }
|
|
144
|
+
.sv-rgp-grip { color: var(--sg-muted, #94a3b8); cursor: grab; }
|
|
145
|
+
.sv-rgp-x {
|
|
146
|
+
display: inline-flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
width: 16px;
|
|
150
|
+
height: 16px;
|
|
151
|
+
border: 0;
|
|
152
|
+
border-radius: 50%;
|
|
153
|
+
background: none;
|
|
154
|
+
color: var(--sg-muted, #64748b);
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
line-height: 1;
|
|
158
|
+
}
|
|
159
|
+
.sv-rgp-x:hover { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
160
|
+
.sv-rgp-add {
|
|
161
|
+
font: inherit;
|
|
162
|
+
font-size: 13px;
|
|
163
|
+
padding: 4px 8px;
|
|
164
|
+
border-radius: 8px;
|
|
165
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
166
|
+
background: var(--sg-bg, #fff);
|
|
167
|
+
color: var(--sg-accent, #2563eb);
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
}
|
|
170
|
+
</style>
|