@svgrid/grid 3.0.5 → 3.0.7
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 +28 -7
- package/dist/GridMenus.svelte +34 -4
- package/dist/SvGrid.controller.svelte.d.ts +23 -3
- package/dist/SvGrid.controller.svelte.js +235 -54
- package/dist/SvGrid.css +143 -7
- package/dist/SvGrid.helpers.js +2 -4
- package/dist/SvGrid.svelte +223 -38
- package/dist/SvGrid.types.d.ts +77 -4
- package/dist/SvGridDropdown.svelte +20 -0
- package/dist/SvGridDropdown.svelte.d.ts +12 -0
- package/dist/SvModal.svelte +22 -0
- package/dist/SvModal.svelte.d.ts +7 -0
- package/dist/a11y/dismissable.d.ts +10 -0
- package/dist/a11y/dismissable.js +24 -2
- package/dist/build-api.js +81 -31
- package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
- package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
- package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
- package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
- package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
- package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
- package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
- package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
- package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
- package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
- package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
- package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
- package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
- package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
- package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
- package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
- package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
- package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
- package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
- package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
- package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
- package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
- package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
- package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
- package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
- package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
- package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
- package/dist/cdn/svgrid.js +18 -18
- package/dist/cdn/svgrid.svelte-external.js +18 -18
- package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
- package/dist/cell-formatting.d.ts +9 -0
- package/dist/cell-formatting.js +52 -1
- package/dist/cell-render.js +25 -6
- package/dist/clipboard.d.ts +2 -2
- package/dist/clipboard.js +72 -23
- package/dist/columns.js +7 -2
- package/dist/conditional-formatting.d.ts +2 -0
- package/dist/conditional-formatting.js +2 -0
- package/dist/core.d.ts +22 -6
- package/dist/core.js +478 -40
- package/dist/editing.js +24 -0
- package/dist/export-format.d.ts +8 -0
- package/dist/export-format.js +23 -16
- package/dist/grid-messages.d.ts +4 -0
- package/dist/grid-messages.js +4 -0
- package/dist/headless.d.ts +5 -0
- package/dist/headless.js +12 -0
- package/dist/history.d.ts +21 -0
- package/dist/history.js +34 -4
- package/dist/index.d.ts +1 -1
- package/dist/keyboard-handlers.js +24 -1
- package/dist/keyboard.js +4 -0
- package/dist/row-drag-touch.d.ts +1 -1
- package/dist/row-drag.js +35 -1
- package/dist/row-model.d.ts +6 -0
- package/dist/row-resize.js +22 -1
- package/dist/selection.js +1 -1
- package/dist/server-block-cache.d.ts +5 -0
- package/dist/server-block-cache.js +8 -0
- package/dist/server-data-source.d.ts +35 -3
- package/dist/server-data-source.js +11 -2
- package/dist/server.d.ts +1 -1
- package/dist/sparkline.js +7 -5
- package/dist/svgrid-wrapper.types.d.ts +33 -0
- package/dist/validate.js +9 -1
- package/dist/virtualization/column-virtualizer.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +3 -0
- package/dist/virtualization/virtualizer.js +14 -1
- package/package.json +6 -1
- package/src/GridMenus.svelte +34 -4
- package/src/SvGrid.controller.svelte.ts +236 -57
- package/src/SvGrid.css +143 -7
- package/src/SvGrid.helpers.ts +2 -4
- package/src/SvGrid.svelte +223 -38
- package/src/SvGrid.types.ts +77 -4
- package/src/SvGridDropdown.svelte +20 -0
- package/src/SvModal.svelte +22 -0
- package/src/SvModal.test.ts +30 -1
- package/src/a11y/dismissable.test.ts +10 -1
- package/src/a11y/dismissable.ts +22 -2
- package/src/build-api.ts +85 -27
- package/src/cell-formatting.test.ts +17 -0
- package/src/cell-formatting.ts +49 -1
- package/src/cell-render.test.ts +24 -2
- package/src/cell-render.ts +24 -8
- package/src/clipboard.test.ts +107 -0
- package/src/clipboard.ts +62 -23
- package/src/columns.test.ts +4 -4
- package/src/columns.ts +7 -2
- package/src/conditional-formatting.test.ts +8 -0
- package/src/conditional-formatting.ts +3 -0
- package/src/core.sort.test.ts +86 -0
- package/src/core.tick-repair.test.ts +287 -0
- package/src/core.ts +478 -40
- package/src/editing.test.ts +52 -0
- package/src/editing.ts +25 -0
- package/src/export-dates.test.ts +114 -0
- package/src/export-format.ts +26 -14
- package/src/grid-messages.test.ts +7 -0
- package/src/grid-messages.ts +10 -0
- package/src/headless.ts +24 -0
- package/src/history.test.ts +49 -1
- package/src/history.ts +48 -2
- package/src/index.ts +2 -0
- package/src/keyboard-handlers.coverage.test.ts +65 -0
- package/src/keyboard-handlers.ts +26 -1
- package/src/keyboard.ts +3 -0
- package/src/row-drag-touch.ts +2 -2
- package/src/row-drag.test.ts +51 -0
- package/src/row-drag.ts +35 -2
- package/src/row-model.ts +6 -0
- package/src/row-resize.ts +24 -1
- package/src/selection.ts +1 -1
- package/src/server-block-cache.test.ts +27 -0
- package/src/server-block-cache.ts +13 -0
- package/src/server-data-source.infinite.test.ts +62 -0
- package/src/server-data-source.test.ts +19 -0
- package/src/server-data-source.ts +48 -4
- package/src/server.ts +2 -0
- package/src/sparkline.test.ts +7 -0
- package/src/sparkline.ts +6 -4
- package/src/svgrid-wrapper.types.ts +32 -0
- package/src/svgrid.api.test.ts +21 -0
- package/src/svgrid.charting.test.ts +12 -4
- package/src/svgrid.context-menu.test.ts +12 -0
- package/src/svgrid.data-push.svelte.test.ts +94 -0
- package/src/svgrid.detail-rows.svelte.test.ts +187 -0
- package/src/svgrid.filter-menu-scroll.test.ts +9 -0
- package/src/svgrid.gantt-seam.test.ts +1 -1
- package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
- package/src/svgrid.menu-scroll-close.test.ts +10 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
- package/src/svgrid.row-pinning.test.ts +15 -1
- package/src/svgrid.selection-bar-seam.test.ts +1 -1
- package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
- package/src/svgrid.upsell-license.test.ts +1 -1
- package/src/test-setup.ts +12 -0
- package/src/transaction.test.ts +58 -0
- package/src/validate.ts +7 -1
- package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
- package/src/virtualization/virtualizer.test.ts +44 -0
- package/src/virtualization/virtualizer.ts +13 -1
- package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
|
@@ -45,6 +45,18 @@
|
|
|
45
45
|
* loading line instead of "No options", which would read as "nothing to
|
|
46
46
|
* pick" when the truth is "not here yet". */
|
|
47
47
|
loading?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* The trigger's element id, so a form's `<label for>` reaches it and
|
|
50
|
+
* clicking the label focuses it. Without one a select in a form was a
|
|
51
|
+
* button with no name.
|
|
52
|
+
*/
|
|
53
|
+
id?: string
|
|
54
|
+
/** Accessible name for the trigger when no label points at `id`. */
|
|
55
|
+
ariaLabel?: string
|
|
56
|
+
/** Marks the trigger `aria-invalid`, the way an input in the same form is. */
|
|
57
|
+
invalid?: boolean
|
|
58
|
+
/** `aria-describedby` for the trigger: the error or help text of the field. */
|
|
59
|
+
describedBy?: string
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
let {
|
|
@@ -59,6 +71,10 @@
|
|
|
59
71
|
onCommit,
|
|
60
72
|
onCancel,
|
|
61
73
|
loading = false,
|
|
74
|
+
id,
|
|
75
|
+
ariaLabel,
|
|
76
|
+
invalid = false,
|
|
77
|
+
describedBy,
|
|
62
78
|
}: Props = $props()
|
|
63
79
|
|
|
64
80
|
/** Stable per-instance id used to build option ids for
|
|
@@ -348,10 +364,14 @@
|
|
|
348
364
|
<!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
|
|
349
365
|
<button
|
|
350
366
|
type="button"
|
|
367
|
+
{id}
|
|
351
368
|
class="sv-grid-dropdown-trigger"
|
|
352
369
|
class:sv-grid-dropdown-trigger-chips={renderChipsInTrigger && selectedArr.length > 0}
|
|
353
370
|
aria-haspopup="listbox"
|
|
354
371
|
aria-expanded={open}
|
|
372
|
+
aria-label={ariaLabel}
|
|
373
|
+
aria-invalid={invalid || undefined}
|
|
374
|
+
aria-describedby={describedBy}
|
|
355
375
|
aria-activedescendant={open ? optionId(highlighted) : undefined}
|
|
356
376
|
bind:this={triggerEl}
|
|
357
377
|
use:focusTrigger
|
package/src/SvModal.svelte
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { portalToBody } from './popover'
|
|
17
17
|
import { nextEditorId } from './editor-contract'
|
|
18
18
|
import { createOverlay } from './createOverlay.svelte'
|
|
19
|
+
import { getFocusable } from './a11y/focus-trap'
|
|
19
20
|
|
|
20
21
|
type Props = {
|
|
21
22
|
open?: boolean
|
|
@@ -33,6 +34,13 @@
|
|
|
33
34
|
closeOnEsc?: boolean
|
|
34
35
|
/** Hide the header close (x) button. */
|
|
35
36
|
hideClose?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* The element to focus when the dialog opens. Defaults to the first
|
|
39
|
+
* focusable control in the body or footer - never the header Close (x),
|
|
40
|
+
* so opening a dialog lands on its content and Enter does not dismiss it.
|
|
41
|
+
* Pass a getter to target a specific field (e.g. a search input).
|
|
42
|
+
*/
|
|
43
|
+
initialFocus?: () => HTMLElement | null
|
|
36
44
|
children?: Snippet
|
|
37
45
|
footer?: Snippet
|
|
38
46
|
}
|
|
@@ -48,6 +56,7 @@
|
|
|
48
56
|
closeOnBackdrop = true,
|
|
49
57
|
closeOnEsc = true,
|
|
50
58
|
hideClose = false,
|
|
59
|
+
initialFocus,
|
|
51
60
|
children,
|
|
52
61
|
footer,
|
|
53
62
|
}: Props = $props()
|
|
@@ -69,6 +78,19 @@
|
|
|
69
78
|
closeOnEsc: () => closeOnEsc,
|
|
70
79
|
closeOnBackdrop: () => closeOnBackdrop,
|
|
71
80
|
onOpen: () => { drag = { x: 0, y: 0 }; box = null },
|
|
81
|
+
// Land focus on the dialog's content, not the header Close (x). The x is
|
|
82
|
+
// the first focusable in DOM order, so the trap's "first focusable"
|
|
83
|
+
// default sent focus there - where Enter immediately dismissed the
|
|
84
|
+
// dialog and the loud focus ring pointed at "close" over the primary
|
|
85
|
+
// action. Prefer the first focusable outside the header; fall back to the
|
|
86
|
+
// panel itself when the body has no control of its own.
|
|
87
|
+
initialFocus: () => {
|
|
88
|
+
const explicit = initialFocus?.()
|
|
89
|
+
if (explicit) return explicit
|
|
90
|
+
if (!dialogEl) return null
|
|
91
|
+
const first = getFocusable(dialogEl).find((el) => !el.closest('.sv-modal__header'))
|
|
92
|
+
return first ?? dialogEl
|
|
93
|
+
},
|
|
72
94
|
})
|
|
73
95
|
|
|
74
96
|
// --- Drag (header) ---------------------------------------------------------
|
package/src/SvModal.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* SvDrawer shares), plus the modal's title wiring and closeOnEsc opt-out.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, it } from 'vitest'
|
|
7
|
-
import { mount, unmount, flushSync } from 'svelte'
|
|
7
|
+
import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
|
|
8
8
|
import SvModal from './SvModal.svelte'
|
|
9
9
|
|
|
10
10
|
function mountModal(props: Record<string, unknown>) {
|
|
@@ -79,6 +79,35 @@ describe('SvModal', () => {
|
|
|
79
79
|
} finally { destroy() }
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
+
it('focuses the first body control on open, not the header Close (x)', async () => {
|
|
83
|
+
// The close x is the first focusable in DOM order; the trap's plain "first
|
|
84
|
+
// focusable" sent focus there, so Enter dismissed the dialog at once and
|
|
85
|
+
// the focus ring pointed at "close". Focus must land on the content.
|
|
86
|
+
// (The trap focuses on the next microtask, so let it run.)
|
|
87
|
+
const body = createRawSnippet(() => ({
|
|
88
|
+
render: () => `<button type="button" class="probe-ok">OK</button>`,
|
|
89
|
+
}))
|
|
90
|
+
const { destroy } = mountModal({ open: true, title: 'Edit row', children: body })
|
|
91
|
+
try {
|
|
92
|
+
flushSync()
|
|
93
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
94
|
+
const active = document.activeElement as HTMLElement
|
|
95
|
+
expect(active?.classList.contains('sv-modal__x')).toBe(false)
|
|
96
|
+
expect(active?.classList.contains('probe-ok')).toBe(true)
|
|
97
|
+
} finally { destroy() }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('falls back to the panel, still not the Close (x), when the body has no control', async () => {
|
|
101
|
+
const { destroy } = mountModal({ open: true, title: 'Empty' })
|
|
102
|
+
try {
|
|
103
|
+
flushSync()
|
|
104
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
105
|
+
const active = document.activeElement as HTMLElement
|
|
106
|
+
expect(active?.classList.contains('sv-modal__x')).toBe(false)
|
|
107
|
+
expect(active?.classList.contains('sv-modal')).toBe(true)
|
|
108
|
+
} finally { destroy() }
|
|
109
|
+
})
|
|
110
|
+
|
|
82
111
|
it('dismisses on outside (backdrop) pointerdown', () => {
|
|
83
112
|
const { destroy } = mountModal({ open: true })
|
|
84
113
|
try {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
2
|
import { createDismissableLayer, dismissableDepth, onScrollOutside } from './dismissable'
|
|
3
3
|
|
|
4
|
+
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
5
|
+
|
|
4
6
|
function panel(): HTMLElement {
|
|
5
7
|
const el = document.createElement('div')
|
|
6
8
|
document.body.appendChild(el)
|
|
@@ -77,13 +79,20 @@ describe('createDismissableLayer', () => {
|
|
|
77
79
|
expect(dismissableDepth()).toBe(0)
|
|
78
80
|
})
|
|
79
81
|
|
|
80
|
-
it('close-on-scroll ignores scrolling inside the panel', () => {
|
|
82
|
+
it('close-on-scroll ignores scrolling inside the panel', async () => {
|
|
81
83
|
const el = panel()
|
|
82
84
|
const list = document.createElement('div') // e.g. the filter menu's facet list
|
|
83
85
|
el.appendChild(list)
|
|
84
86
|
let closed = 0
|
|
85
87
|
const off = onScrollOutside(() => el, () => closed++)
|
|
86
88
|
|
|
89
|
+
// A scroll that finished before the panel opened is still queued for
|
|
90
|
+
// the next rendering step; it lands after the listener is asked for
|
|
91
|
+
// and must not close what the same click just opened.
|
|
92
|
+
document.body.dispatchEvent(new Event('scroll'))
|
|
93
|
+
expect(closed).toBe(0)
|
|
94
|
+
await nextFrame()
|
|
95
|
+
|
|
87
96
|
// Inner scroll containers don't bubble, so this only reaches the capture
|
|
88
97
|
// listener - it must NOT close the menu the user is scrolling.
|
|
89
98
|
list.dispatchEvent(new Event('scroll'))
|
package/src/a11y/dismissable.ts
CHANGED
|
@@ -101,6 +101,16 @@ export function dismissableDepth(): number {
|
|
|
101
101
|
* window listener gets wrong: inner scroll containers don't bubble, so the
|
|
102
102
|
* capture listener sees them and dismisses the panel the user is scrolling.
|
|
103
103
|
*
|
|
104
|
+
* The listener arms one animation frame after it is asked for, not at once.
|
|
105
|
+
* Scroll events are queued and dispatched in the next rendering step, so a
|
|
106
|
+
* scroll that FINISHED before the click that opened the panel - the browser
|
|
107
|
+
* pulling a half-hidden funnel button into view as it focuses it, a test
|
|
108
|
+
* driver scrolling the header into place - still lands after the panel has
|
|
109
|
+
* mounted and wired this listener, and used to close it on the spot. The
|
|
110
|
+
* panel measured its anchor after that scroll, so the event carries nothing
|
|
111
|
+
* it needs to react to. Rendering runs the scroll steps before the animation
|
|
112
|
+
* frame callbacks, which is why one frame is exactly the right delay.
|
|
113
|
+
*
|
|
104
114
|
* Returns the unsubscribe function.
|
|
105
115
|
*/
|
|
106
116
|
export function onScrollOutside(element: () => DismissTarget, onOutside: () => void): () => void {
|
|
@@ -109,6 +119,16 @@ export function onScrollOutside(element: () => DismissTarget, onOutside: () => v
|
|
|
109
119
|
if (containsTarget(element(), event.target as Node)) return
|
|
110
120
|
onOutside()
|
|
111
121
|
}
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
let armed = false
|
|
123
|
+
let frame: number | null = requestAnimationFrame(() => {
|
|
124
|
+
frame = null
|
|
125
|
+
armed = true
|
|
126
|
+
window.addEventListener('scroll', handler, true)
|
|
127
|
+
})
|
|
128
|
+
return () => {
|
|
129
|
+
if (frame !== null) cancelAnimationFrame(frame)
|
|
130
|
+
frame = null
|
|
131
|
+
if (armed) window.removeEventListener('scroll', handler, true)
|
|
132
|
+
armed = false
|
|
133
|
+
}
|
|
114
134
|
}
|
package/src/build-api.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
columnDefMatchesId,
|
|
27
27
|
} from "./cell-values";
|
|
28
28
|
import { hasAdvancedFilterEngine } from "./advanced-filter.svelte";
|
|
29
|
-
import { undoHistory, redoHistory } from "./history";
|
|
29
|
+
import { undoHistory, redoHistory, peekUndoHistory, peekRedoHistory } from "./history";
|
|
30
30
|
import { buildCommandContext } from "./command-context";
|
|
31
31
|
|
|
32
32
|
type OrientedRange = {
|
|
@@ -35,6 +35,32 @@ type OrientedRange = {
|
|
|
35
35
|
highlight: { row: number; col: number }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Row id -> index, per data array, for `applyTransaction`.
|
|
40
|
+
*
|
|
41
|
+
* An update batch used to walk every row calling `getRowId` to find the ones
|
|
42
|
+
* it replaced - 100k calls per tick on a 100k-row feed, whatever the batch
|
|
43
|
+
* size. The map is built once per array and, for an update-only transaction,
|
|
44
|
+
* carried to the array it produces: same ids at the same indices. An add or
|
|
45
|
+
* remove changes indices, so those transactions leave the next array to
|
|
46
|
+
* build its own map on first use. Keyed weakly so a replaced array takes its
|
|
47
|
+
* map with it.
|
|
48
|
+
*/
|
|
49
|
+
const rowIndexByData = new WeakMap<ReadonlyArray<unknown>, Map<string, number>>()
|
|
50
|
+
|
|
51
|
+
function rowIndexOf<TData>(
|
|
52
|
+
data: ReadonlyArray<TData>,
|
|
53
|
+
getId: (row: TData, index: number) => string,
|
|
54
|
+
): Map<string, number> {
|
|
55
|
+
let byId = rowIndexByData.get(data)
|
|
56
|
+
if (!byId) {
|
|
57
|
+
byId = new Map()
|
|
58
|
+
for (let i = 0; i < data.length; i++) byId.set(getId(data[i]!, i), i)
|
|
59
|
+
rowIndexByData.set(data, byId)
|
|
60
|
+
}
|
|
61
|
+
return byId
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
/**
|
|
39
65
|
* The selection as the user made it: committed ranges first, the active one
|
|
40
66
|
* last, each keeping its anchor as `from` and its focus as `to`. The active
|
|
@@ -111,6 +137,10 @@ export function createGridApi<
|
|
|
111
137
|
};
|
|
112
138
|
|
|
113
139
|
return {
|
|
140
|
+
getElement() {
|
|
141
|
+
const el = ctx.scrollContainer as HTMLElement | null
|
|
142
|
+
return (el?.closest(".sv-grid-root") as HTMLElement | null) ?? null
|
|
143
|
+
},
|
|
114
144
|
getCellValue(rowIndex, columnId) {
|
|
115
145
|
const row = ctx.internalData[rowIndex];
|
|
116
146
|
const column = findColumn(columnId);
|
|
@@ -243,38 +273,41 @@ export function createGridApi<
|
|
|
243
273
|
},
|
|
244
274
|
applyTransaction(tx) {
|
|
245
275
|
const getId = ctx.props.getRowId;
|
|
246
|
-
|
|
276
|
+
const current = ctx.internalData as ReadonlyArray<TData>;
|
|
277
|
+
let next = current.slice() as Array<TData>;
|
|
247
278
|
let added = 0;
|
|
248
279
|
let updated = 0;
|
|
249
280
|
let removed = 0;
|
|
250
281
|
|
|
282
|
+
// Everything resolves against the indices of `current`: the id ->
|
|
283
|
+
// index map answers updates and id removals with one lookup each, a
|
|
284
|
+
// reference removal with one `indexOf`, and no pass over the rows
|
|
285
|
+
// that did not change. A row both updated and removed in the same
|
|
286
|
+
// transaction ends up removed, as it did when removes ran first.
|
|
287
|
+
const removeIdx = new Set<number>();
|
|
251
288
|
if (tx.remove?.length) {
|
|
252
|
-
const
|
|
253
|
-
const removeRefs = new Set<TData>();
|
|
289
|
+
const byId = getId ? rowIndexOf(current, getId) : null;
|
|
254
290
|
for (const r of tx.remove) {
|
|
255
|
-
|
|
256
|
-
|
|
291
|
+
const idx = typeof r === "string" ? byId?.get(r) : current.indexOf(r as TData);
|
|
292
|
+
if (idx !== undefined && idx >= 0) removeIdx.add(idx);
|
|
257
293
|
}
|
|
258
|
-
next = next.filter((row, i) => {
|
|
259
|
-
const hit =
|
|
260
|
-
removeRefs.has(row) ||
|
|
261
|
-
(getId ? removeIds.has(getId(row, i)) : false);
|
|
262
|
-
if (hit) removed += 1;
|
|
263
|
-
return !hit;
|
|
264
|
-
});
|
|
265
294
|
}
|
|
266
295
|
|
|
267
296
|
if (tx.update?.length && getId) {
|
|
268
|
-
const byId =
|
|
269
|
-
for (const u of tx.update)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
297
|
+
const byId = rowIndexOf(current, getId);
|
|
298
|
+
for (const u of tx.update) {
|
|
299
|
+
const idx = byId.get(getId(u, 0));
|
|
300
|
+
if (idx === undefined) continue;
|
|
301
|
+
next[idx] = u;
|
|
302
|
+
updated += 1;
|
|
303
|
+
}
|
|
304
|
+
// Same ids at the same indices: the next array can reuse the map.
|
|
305
|
+
if (!removeIdx.size && !tx.add?.length) rowIndexByData.set(next, byId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (removeIdx.size) {
|
|
309
|
+
next = next.filter((_, i) => !removeIdx.has(i));
|
|
310
|
+
removed = removeIdx.size;
|
|
278
311
|
}
|
|
279
312
|
|
|
280
313
|
if (tx.add?.length) {
|
|
@@ -507,6 +540,28 @@ export function createGridApi<
|
|
|
507
540
|
getData() {
|
|
508
541
|
return ctx.internalData;
|
|
509
542
|
},
|
|
543
|
+
getRowId(row) {
|
|
544
|
+
// The stable id the grid keys a row by: what `getRowId` computed,
|
|
545
|
+
// or the row's position when no `getRowId` prop is set. A number is
|
|
546
|
+
// a DISPLAY index into the rendered rows, the space getActiveCell,
|
|
547
|
+
// scrollToRow and the cell methods work in; a row object is looked
|
|
548
|
+
// up wherever it sits. Null when the index or the object is unknown,
|
|
549
|
+
// so a caller can tell "no such row" from a real id.
|
|
550
|
+
if (typeof row === "number") {
|
|
551
|
+
const found = ctx.allRows[row];
|
|
552
|
+
return found && !isGroupRow(found) ? (found.id as string) : null;
|
|
553
|
+
}
|
|
554
|
+
const inRows = ctx.allRows.find(
|
|
555
|
+
(r: { original?: unknown; id: string }) => r.original === row,
|
|
556
|
+
);
|
|
557
|
+
if (inRows) return inRows.id as string;
|
|
558
|
+
const getId = ctx.props.getRowId as
|
|
559
|
+
| ((r: TData, index: number) => string)
|
|
560
|
+
| undefined;
|
|
561
|
+
const index = ctx.internalData.indexOf(row as TData);
|
|
562
|
+
if (index < 0) return null;
|
|
563
|
+
return getId ? getId(row as TData, index) : String(index);
|
|
564
|
+
},
|
|
510
565
|
getColumns() {
|
|
511
566
|
// Snapshot every column with its human-readable label and
|
|
512
567
|
// visibility flag. Used by external code (exporters, column
|
|
@@ -631,10 +686,10 @@ export function createGridApi<
|
|
|
631
686
|
ctx.autosizeColumn(columnId);
|
|
632
687
|
},
|
|
633
688
|
getRowHeight(rowIndex: number) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
return
|
|
689
|
+
// The height the grid draws the row at, whatever decided it: a drag,
|
|
690
|
+
// a measurement under auto height, a detail row's own height, the
|
|
691
|
+
// declared rowHeight or the default.
|
|
692
|
+
return ctx.rowSizePxOf(rowIndex);
|
|
638
693
|
},
|
|
639
694
|
setRowHeight(rowIndex: number, height: number | null) {
|
|
640
695
|
ctx.setRowResizeHeight(rowIndex, height);
|
|
@@ -705,6 +760,9 @@ export function createGridApi<
|
|
|
705
760
|
canUndo() { void ctx.historyVersion; return ctx.historyPtr >= 0 },
|
|
706
761
|
canRedo() { void ctx.historyVersion; return ctx.historyPtr < ctx.history.length - 1 },
|
|
707
762
|
clearHistory() { ctx.history = []; ctx.historyPtr = -1; ctx.historyVersion += 1 },
|
|
763
|
+
setHistoryTag(tag) { ctx.historyTag = tag },
|
|
764
|
+
peekUndo() { void ctx.historyVersion; return peekUndoHistory(ctx) },
|
|
765
|
+
peekRedo() { void ctx.historyVersion; return peekRedoHistory(ctx) },
|
|
708
766
|
// ---- Find
|
|
709
767
|
openFind() { ctx.findOpen = true },
|
|
710
768
|
closeFind() { ctx.findOpen = false; ctx.findQuery = '' },
|
|
@@ -271,3 +271,20 @@ describe('formatNumericWithConfig - cache reuse', () => {
|
|
|
271
271
|
expect(typeof result).toBe('string')
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
|
+
|
|
275
|
+
describe('formatNumericWithConfig - formatter cache key', () => {
|
|
276
|
+
it('does not hand a signDisplay column the formatter another column cached without it', () => {
|
|
277
|
+
// Same digits, no sign: cached first, as a currency-like column would.
|
|
278
|
+
expect(
|
|
279
|
+
formatNumericWithConfig(3.27, { type: 'number', locales: 'en-US', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } }),
|
|
280
|
+
).toBe('3.27')
|
|
281
|
+
// Same digits, with a sign: must not reuse the one above.
|
|
282
|
+
expect(
|
|
283
|
+
formatNumericWithConfig(3.27, {
|
|
284
|
+
type: 'number',
|
|
285
|
+
locales: 'en-US',
|
|
286
|
+
options: { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: 'always' },
|
|
287
|
+
}),
|
|
288
|
+
).toBe('+3.27')
|
|
289
|
+
})
|
|
290
|
+
})
|
package/src/cell-formatting.ts
CHANGED
|
@@ -101,7 +101,24 @@ export function getNumberFormatter(
|
|
|
101
101
|
'|' +
|
|
102
102
|
(options.currencyDisplay ?? '') +
|
|
103
103
|
'|' +
|
|
104
|
-
(options.notation ?? '')
|
|
104
|
+
(options.notation ?? '') +
|
|
105
|
+
// Every option that changes the output belongs in the key. `signDisplay`
|
|
106
|
+
// was missing, so a percent column asking for "+3.27" got the formatter
|
|
107
|
+
// a currency column with the same digits had already cached, and no sign.
|
|
108
|
+
'|' +
|
|
109
|
+
(options.signDisplay ?? '') +
|
|
110
|
+
'|' +
|
|
111
|
+
(options.minimumIntegerDigits ?? '') +
|
|
112
|
+
'|' +
|
|
113
|
+
(options.compactDisplay ?? '') +
|
|
114
|
+
'|' +
|
|
115
|
+
(options.unit ?? '') +
|
|
116
|
+
'|' +
|
|
117
|
+
(options.unitDisplay ?? '') +
|
|
118
|
+
'|' +
|
|
119
|
+
(options.minimumSignificantDigits ?? '') +
|
|
120
|
+
'|' +
|
|
121
|
+
(options.maximumSignificantDigits ?? '')
|
|
105
122
|
let fmt = numberFormatterCache.get(key)
|
|
106
123
|
if (!fmt) {
|
|
107
124
|
fmt = new Intl.NumberFormat(locales as string | string[] | undefined, options)
|
|
@@ -110,6 +127,37 @@ export function getNumberFormatter(
|
|
|
110
127
|
return fmt
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
/**
|
|
131
|
+
* A cell value as a Date, or null when it is not one. Accepts a Date, epoch ms,
|
|
132
|
+
* or a parseable string.
|
|
133
|
+
*
|
|
134
|
+
* A bare calendar date ('2026-06-27') is read as LOCAL midnight of that day.
|
|
135
|
+
* `new Date('2026-06-27')` reads it as UTC midnight, which every zone west of
|
|
136
|
+
* UTC formats as the 26th. The day in the string is the day the user meant.
|
|
137
|
+
*/
|
|
138
|
+
export function parseDateValue(value: unknown): Date | null {
|
|
139
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
140
|
+
if (typeof value === 'number') {
|
|
141
|
+
if (!Number.isFinite(value)) return null
|
|
142
|
+
const d = new Date(value)
|
|
143
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
144
|
+
}
|
|
145
|
+
if (typeof value !== 'string') return null
|
|
146
|
+
const s = value.trim()
|
|
147
|
+
if (s === '') return null
|
|
148
|
+
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
|
|
149
|
+
if (ymd) {
|
|
150
|
+
const [y, m, day] = [Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3])]
|
|
151
|
+
const d = new Date(0)
|
|
152
|
+
d.setFullYear(y, m, day)
|
|
153
|
+
d.setHours(0, 0, 0, 0)
|
|
154
|
+
// Reject a rollover ('2026-02-30' is not March 2nd), as `new Date` does.
|
|
155
|
+
return d.getMonth() === m && d.getDate() === day ? d : null
|
|
156
|
+
}
|
|
157
|
+
const d = new Date(s)
|
|
158
|
+
return Number.isNaN(d.getTime()) ? null : d
|
|
159
|
+
}
|
|
160
|
+
|
|
113
161
|
/** Cache of `Intl.DateTimeFormat` by `(locale, options)` signature. */
|
|
114
162
|
const dateFormatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
115
163
|
/** A cached `Intl.DateTimeFormat` for the locale and options given. Cached because
|
package/src/cell-render.test.ts
CHANGED
|
@@ -141,6 +141,26 @@ describe('createCellRender / computeCellValidity', () => {
|
|
|
141
141
|
message: 'Salary must be at least $1,000',
|
|
142
142
|
})
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
it('judges the open editor draft, parsed as a commit would parse it', () => {
|
|
146
|
+
// With rejectInvalid a refused value never lands, so the red flag while
|
|
147
|
+
// the value is typed is the only feedback the user gets.
|
|
148
|
+
const col = makeColumn('salary', { validate: validateSalary, editorType: 'number' })
|
|
149
|
+
const typing = createCellRender(makeCtx({
|
|
150
|
+
editedCellValues: {},
|
|
151
|
+
editingCell: { rowId: 'r1', columnId: 'salary', editorType: 'number', value: '5' },
|
|
152
|
+
}))
|
|
153
|
+
expect(typing.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({
|
|
154
|
+
invalid: true,
|
|
155
|
+
message: 'Salary must be at least $1,000',
|
|
156
|
+
})
|
|
157
|
+
// Another cell's editor does not bleed into this one.
|
|
158
|
+
const elsewhere = createCellRender(makeCtx({
|
|
159
|
+
editedCellValues: {},
|
|
160
|
+
editingCell: { rowId: 'r2', columnId: 'salary', editorType: 'number', value: '5' },
|
|
161
|
+
}))
|
|
162
|
+
expect(elsewhere.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({ invalid: false, message: null })
|
|
163
|
+
})
|
|
144
164
|
})
|
|
145
165
|
|
|
146
166
|
describe('createCellRender / computeRowClass', () => {
|
|
@@ -489,9 +509,11 @@ describe('createCellRender / formatPinnedValue', () => {
|
|
|
489
509
|
})
|
|
490
510
|
|
|
491
511
|
describe('createCellRender / computePinnedCellClass', () => {
|
|
492
|
-
it('
|
|
512
|
+
it('applies a static cellClass, a string or a list, the way a body cell does', () => {
|
|
493
513
|
const cr = createCellRender(makeCtx())
|
|
494
|
-
expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: 'x' }))).toBe('')
|
|
514
|
+
expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: 'x' }))).toBe('x')
|
|
515
|
+
expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: ['x', 'y'] }))).toBe('x y')
|
|
516
|
+
expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', {}))).toBe('')
|
|
495
517
|
})
|
|
496
518
|
|
|
497
519
|
it('invokes a function cellClass with a null row and the pinned value', () => {
|
package/src/cell-render.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
getPinnedCellValue,
|
|
32
32
|
} from "./cell-values";
|
|
33
|
+
import { parseEditorValue } from "./editors/cell-editors";
|
|
33
34
|
|
|
34
35
|
export function createCellRender<
|
|
35
36
|
TFeatures extends TableFeatures = TableFeatures,
|
|
@@ -131,11 +132,22 @@ export function createCellRender<
|
|
|
131
132
|
// cache, so `getCellValueByColumnId` would return the stale pre-edit value
|
|
132
133
|
// and validation would never re-run after an edit. Consult the overlay
|
|
133
134
|
// first - same rule the display path (getCellDisplayValue) and summaries
|
|
134
|
-
// use
|
|
135
|
+
// use. While this cell's editor is open the draft is what the rule sees,
|
|
136
|
+
// parsed the way a commit would parse it, so the red highlight and the
|
|
137
|
+
// message show as the user types - which, with `rejectInvalid`, is the
|
|
138
|
+
// only time a refused value is ever visible.
|
|
135
139
|
const edited = ctx.editedCellValues ?? {}
|
|
136
140
|
const key = getCellKey(row.id, column.id)
|
|
141
|
+
const editing = ctx.editingCell
|
|
137
142
|
const value =
|
|
138
|
-
|
|
143
|
+
editing && editing.rowId === row.id && editing.columnId === column.id
|
|
144
|
+
? parseEditorValue(editing.editorType, editing.value, {
|
|
145
|
+
multiple: column.columnDef.editorMultiple === true,
|
|
146
|
+
dateOnly: column.columnDef.cellDataType === "dateString",
|
|
147
|
+
})
|
|
148
|
+
: key in edited
|
|
149
|
+
? edited[key]
|
|
150
|
+
: row.getCellValueByColumnId(column.id)
|
|
139
151
|
const out = rule({
|
|
140
152
|
value,
|
|
141
153
|
row: row.original as TData,
|
|
@@ -470,13 +482,17 @@ export function createCellRender<
|
|
|
470
482
|
rowData: TData,
|
|
471
483
|
column: Column<TData>,
|
|
472
484
|
): string {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
485
|
+
const raw = column.columnDef.cellClass;
|
|
486
|
+
if (raw == null) return "";
|
|
487
|
+
// A static class applies to the pinned cell the way it does to a body
|
|
488
|
+
// cell; a function gets the pinned value and `row: null`, since the
|
|
489
|
+
// pinned row is not a row of the table.
|
|
490
|
+
if (typeof raw === "string" || Array.isArray(raw)) return resolveClassList(raw);
|
|
491
|
+
if (typeof raw !== "function") return "";
|
|
492
|
+
const cellClass = raw as unknown as
|
|
493
|
+
(ctx: { getValue: () => unknown; value: unknown; row: null; column: Column<TData> }) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null;
|
|
477
494
|
const value = getPinnedCellValue(rowData, column);
|
|
478
|
-
|
|
479
|
-
return out ? String(out) : "";
|
|
495
|
+
return resolveClassList(cellClass({ getValue: () => value, value, row: null, column }));
|
|
480
496
|
}
|
|
481
497
|
|
|
482
498
|
return {
|