@svgrid/grid 1.2.22 → 1.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +100 -0
  4. package/dist/SvAutoComplete.svelte.d.ts +16 -0
  5. package/dist/SvButton.svelte +97 -0
  6. package/dist/SvButton.svelte.d.ts +23 -0
  7. package/dist/SvCalendar.svelte +669 -0
  8. package/dist/SvCalendar.svelte.d.ts +70 -0
  9. package/dist/SvCheckBox.svelte +81 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +187 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +130 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +123 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +322 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +37 -0
  19. package/dist/SvDropDownList.svelte +143 -0
  20. package/dist/SvDropDownList.svelte.d.ts +15 -0
  21. package/dist/SvForm.svelte +147 -0
  22. package/dist/SvForm.svelte.d.ts +29 -0
  23. package/dist/SvGauge.svelte +99 -0
  24. package/dist/SvGauge.svelte.d.ts +31 -0
  25. package/dist/SvGrid.controller.svelte.d.ts +16 -2
  26. package/dist/SvGrid.controller.svelte.js +63 -8
  27. package/dist/SvGrid.css +84 -31
  28. package/dist/SvGrid.helpers.d.ts +4 -0
  29. package/dist/SvGrid.helpers.js +40 -12
  30. package/dist/SvGrid.svelte +84 -5
  31. package/dist/SvGrid.types.d.ts +36 -0
  32. package/dist/SvGridDropdown.svelte +16 -30
  33. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  34. package/dist/SvListBox.svelte +122 -0
  35. package/dist/SvListBox.svelte.d.ts +19 -0
  36. package/dist/SvMaskedInput.svelte +0 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +0 -0
  39. package/dist/SvNumberInput.svelte.d.ts +22 -0
  40. package/dist/SvPasswordInput.svelte +110 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +22 -0
  42. package/dist/SvPhoneInput.svelte +0 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +108 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +23 -0
  46. package/dist/SvRating.svelte +111 -0
  47. package/dist/SvRating.svelte.d.ts +20 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +182 -0
  51. package/dist/SvSlider.svelte.d.ts +25 -0
  52. package/dist/SvSwitchButton.svelte +94 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +19 -0
  54. package/dist/SvTabs.svelte +122 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +81 -0
  57. package/dist/SvTagsInput.svelte.d.ts +19 -0
  58. package/dist/SvTimePicker.svelte +359 -0
  59. package/dist/SvTimePicker.svelte.d.ts +27 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +210 -0
  63. package/dist/SvTree.svelte.d.ts +29 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +10022 -5904
  66. package/dist/cdn/svgrid.svelte-external.js +8531 -4429
  67. package/dist/cell-render.d.ts +4 -0
  68. package/dist/cell-render.js +35 -1
  69. package/dist/clipboard.js +19 -1
  70. package/dist/collaboration.d.ts +2 -0
  71. package/dist/collaboration.js +4 -0
  72. package/dist/core.d.ts +22 -1
  73. package/dist/countries.d.ts +15 -0
  74. package/dist/countries.js +61 -0
  75. package/dist/datetime/date-core.d.ts +88 -0
  76. package/dist/datetime/date-core.js +165 -0
  77. package/dist/datetime/date-format.d.ts +39 -0
  78. package/dist/datetime/date-format.js +371 -0
  79. package/dist/datetime/date-restrict.d.ts +23 -0
  80. package/dist/datetime/date-restrict.js +50 -0
  81. package/dist/datetime/date-selection.d.ts +25 -0
  82. package/dist/datetime/date-selection.js +94 -0
  83. package/dist/datetime/mask.d.ts +21 -0
  84. package/dist/datetime/mask.js +90 -0
  85. package/dist/editing.d.ts +1 -1
  86. package/dist/editing.js +7 -0
  87. package/dist/editors/cell-editors.d.ts +2 -2
  88. package/dist/editors/cell-editors.js +3 -1
  89. package/dist/export-format.d.ts +178 -0
  90. package/dist/export-format.js +419 -0
  91. package/dist/hyperformula-adapter.d.ts +4 -0
  92. package/dist/hyperformula-adapter.js +15 -6
  93. package/dist/index.d.ts +36 -0
  94. package/dist/index.js +41 -0
  95. package/dist/list-option.d.ts +10 -0
  96. package/dist/list-option.js +7 -0
  97. package/dist/menus.js +18 -2
  98. package/dist/popover.d.ts +48 -0
  99. package/dist/popover.js +65 -0
  100. package/dist/row-resize.d.ts +4 -4
  101. package/dist/row-resize.js +8 -5
  102. package/dist/selection.d.ts +6 -0
  103. package/dist/selection.js +31 -5
  104. package/dist/server-data-source.d.ts +43 -0
  105. package/dist/server-data-source.js +100 -0
  106. package/dist/sparkline.js +8 -4
  107. package/dist/svgrid-wrapper.types.d.ts +26 -1
  108. package/package.json +6 -1
  109. package/src/GridFooter.svelte +34 -20
  110. package/src/SvAutoComplete.svelte +100 -0
  111. package/src/SvButton.svelte +97 -0
  112. package/src/SvCalendar.svelte +669 -0
  113. package/src/SvCalendar.test.ts +211 -0
  114. package/src/SvCheckBox.svelte +81 -0
  115. package/src/SvColorInput.svelte +187 -0
  116. package/src/SvComboBox.svelte +130 -0
  117. package/src/SvCountryInput.svelte +123 -0
  118. package/src/SvDateTimePicker.svelte +322 -0
  119. package/src/SvDateTimePicker.test.ts +136 -0
  120. package/src/SvDropDownList.svelte +143 -0
  121. package/src/SvForm.svelte +147 -0
  122. package/src/SvGauge.svelte +99 -0
  123. package/src/SvGrid.controller.svelte.ts +74 -8
  124. package/src/SvGrid.css +84 -31
  125. package/src/SvGrid.helpers.ts +41 -12
  126. package/src/SvGrid.svelte +84 -5
  127. package/src/SvGrid.types.ts +33 -0
  128. package/src/SvGridDropdown.svelte +16 -30
  129. package/src/SvListBox.svelte +122 -0
  130. package/src/SvMaskedInput.svelte +0 -0
  131. package/src/SvNumberInput.svelte +0 -0
  132. package/src/SvPasswordInput.svelte +110 -0
  133. package/src/SvPhoneInput.svelte +0 -0
  134. package/src/SvRadioGroup.svelte +108 -0
  135. package/src/SvRating.svelte +111 -0
  136. package/src/SvRepeatButton.svelte +89 -0
  137. package/src/SvSlider.svelte +182 -0
  138. package/src/SvSwitchButton.svelte +94 -0
  139. package/src/SvTabs.svelte +122 -0
  140. package/src/SvTagsInput.svelte +81 -0
  141. package/src/SvTimePicker.svelte +359 -0
  142. package/src/SvTimePicker.test.ts +98 -0
  143. package/src/SvToggleButton.svelte +57 -0
  144. package/src/SvTree.svelte +210 -0
  145. package/src/build-api.ts +115 -0
  146. package/src/cell-render.test.ts +38 -0
  147. package/src/cell-render.ts +43 -0
  148. package/src/clipboard.test.ts +18 -0
  149. package/src/clipboard.ts +24 -1
  150. package/src/collaboration.test.ts +30 -0
  151. package/src/collaboration.ts +6 -0
  152. package/src/core.ts +27 -3
  153. package/src/countries.ts +71 -0
  154. package/src/datetime/date-core.test.ts +217 -0
  155. package/src/datetime/date-core.ts +204 -0
  156. package/src/datetime/date-format.test.ts +121 -0
  157. package/src/datetime/date-format.ts +317 -0
  158. package/src/datetime/date-restrict.ts +60 -0
  159. package/src/datetime/date-selection.test.ts +129 -0
  160. package/src/datetime/date-selection.ts +137 -0
  161. package/src/datetime/mask.test.ts +36 -0
  162. package/src/datetime/mask.ts +84 -0
  163. package/src/editing.test.ts +22 -0
  164. package/src/editing.ts +7 -1
  165. package/src/editors/cell-editors.ts +7 -1
  166. package/src/export-data-api.test.ts +126 -0
  167. package/src/export-format.test.ts +107 -0
  168. package/src/export-format.ts +598 -0
  169. package/src/hyperformula-adapter.test.ts +35 -1
  170. package/src/hyperformula-adapter.ts +18 -6
  171. package/src/index.ts +71 -0
  172. package/src/list-option.ts +15 -0
  173. package/src/menus.ts +21 -2
  174. package/src/popover.ts +79 -0
  175. package/src/row-resize.test.ts +25 -0
  176. package/src/row-resize.ts +8 -5
  177. package/src/selection.test.ts +36 -4
  178. package/src/selection.ts +30 -9
  179. package/src/server-data-source.test.ts +201 -0
  180. package/src/server-data-source.ts +148 -0
  181. package/src/sparkline.test.ts +9 -0
  182. package/src/sparkline.ts +9 -4
  183. package/src/svgrid-wrapper.types.ts +28 -1
  184. package/src/svgrid.behavior.test.ts +27 -0
  185. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  186. package/src/ui-buttons.test.ts +105 -0
  187. package/src/ui-composite.test.ts +89 -0
  188. package/src/ui-inputs.test.ts +92 -0
  189. package/src/ui-range.test.ts +61 -0
  190. package/src/ui-selection.test.ts +106 -0
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvDropDownList - a single-select dropdown (trigger button + portalled list,
4
+ * no typing). Parity: Smart `smart-drop-down-list`. Controlled via `value` +
5
+ * `onChange`. Popover escapes scroll clipping via popover.ts.
6
+ */
7
+ import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
8
+ import type { ListOption } from './list-option'
9
+
10
+ type Props = {
11
+ options: ReadonlyArray<ListOption>
12
+ value?: string | number | null
13
+ onChange?: (value: string | number) => void
14
+ placeholder?: string
15
+ disabled?: boolean
16
+ name?: string
17
+ size?: 'sm' | 'md' | 'lg'
18
+ ariaLabel?: string
19
+ autoOpen?: boolean
20
+ }
21
+
22
+ let { options, value = null, onChange, placeholder = 'Select…', disabled = false, name, size = 'md', ariaLabel, autoOpen = false }: Props = $props()
23
+
24
+ let open = $state(false)
25
+ let active = $state(-1)
26
+ let triggerEl = $state<HTMLButtonElement | null>(null)
27
+ let panelEl = $state<HTMLDivElement | null>(null)
28
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
29
+
30
+ const selected = $derived(options.find((o) => o.value === value) ?? null)
31
+ const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
32
+
33
+ function updatePos() {
34
+ if (!triggerEl) return
35
+ rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: Math.min(options.length, 8) * 34 + 8 })
36
+ }
37
+ function openPanel() {
38
+ if (disabled || open) return
39
+ open = true
40
+ active = Math.max(0, options.findIndex((o) => o.value === value))
41
+ updatePos()
42
+ }
43
+ function close() { open = false }
44
+ function toggle() { open ? close() : openPanel() }
45
+ function pick(i: number) {
46
+ const o = options[i]
47
+ if (!o || o.disabled) return
48
+ onChange?.(o.value); close(); triggerEl?.focus()
49
+ }
50
+ function move(d: number) {
51
+ const pos = enabledIdx.indexOf(active)
52
+ active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active
53
+ queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
54
+ }
55
+ function onKeydown(e: KeyboardEvent) {
56
+ if (disabled) return
57
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openPanel(); return }
58
+ if (!open) return
59
+ if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
60
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
61
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(active) }
62
+ else if (e.key === 'Escape') { e.preventDefault(); close(); triggerEl?.focus() }
63
+ else if (e.key === 'Home') { active = enabledIdx[0] ?? 0 }
64
+ else if (e.key === 'End') { active = enabledIdx.at(-1) ?? 0 }
65
+ }
66
+
67
+ $effect(() => {
68
+ if (!open) return
69
+ const rp = () => updatePos()
70
+ window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
71
+ const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; close() }
72
+ document.addEventListener('pointerdown', od, true)
73
+ return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
74
+ })
75
+ function focusOpen(node: HTMLButtonElement) { if (autoOpen) { node.focus(); openPanel() } }
76
+ </script>
77
+
78
+ <button
79
+ bind:this={triggerEl}
80
+ type="button"
81
+ class="sv-ddl sv-ddl--{size}"
82
+ class:is-open={open}
83
+ class:is-disabled={disabled}
84
+ aria-haspopup="listbox"
85
+ aria-expanded={open}
86
+ aria-label={ariaLabel}
87
+ {disabled}
88
+ onclick={toggle}
89
+ onkeydown={onKeydown}
90
+ use:focusOpen
91
+ >
92
+ <span class="sv-ddl__value" class:is-placeholder={!selected}>{selected?.label ?? placeholder}</span>
93
+ <svg class="sv-ddl__chev" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
94
+ </button>
95
+
96
+ {#if open}
97
+ <div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} role="listbox" tabindex="-1">
98
+ {#each options as opt, i (opt.value)}
99
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
100
+ <div
101
+ class="sv-ddl__opt"
102
+ class:is-active={i === active}
103
+ class:is-selected={opt.value === value}
104
+ class:is-disabled={opt.disabled}
105
+ role="option"
106
+ tabindex="-1"
107
+ aria-selected={opt.value === value}
108
+ data-idx={i}
109
+ onclick={() => pick(i)}
110
+ onpointermove={() => { if (!opt.disabled) active = i }}
111
+ >{opt.label}</div>
112
+ {/each}
113
+ </div>
114
+ {/if}
115
+ {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
116
+
117
+ <style>
118
+ .sv-ddl {
119
+ --_accent: var(--sg-accent, #2563eb);
120
+ display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; width: 200px;
121
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; text-align: left;
122
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
123
+ padding: 0 10px; cursor: pointer;
124
+ }
125
+ .sv-ddl--sm { height: 28px; font-size: 12px; }
126
+ .sv-ddl--md { height: 34px; font-size: 13px; }
127
+ .sv-ddl--lg { height: 40px; font-size: 15px; }
128
+ .sv-ddl.is-open, .sv-ddl:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); outline: none; }
129
+ .sv-ddl.is-disabled { opacity: 0.6; cursor: not-allowed; }
130
+ .sv-ddl__value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
131
+ .sv-ddl__value.is-placeholder { color: var(--sg-muted, #94a3b8); }
132
+ .sv-ddl__chev { color: var(--sg-muted, #64748b); flex: none; }
133
+
134
+ :global(.sv-ddl__panel) {
135
+ z-index: 2147483647; max-height: 288px; overflow-y: auto; padding: 4px;
136
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
137
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
138
+ }
139
+ :global(.sv-ddl__opt) { padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
140
+ :global(.sv-ddl__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
141
+ :global(.sv-ddl__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
142
+ :global(.sv-ddl__opt.is-disabled) { opacity: 0.4; cursor: not-allowed; }
143
+ </style>
@@ -0,0 +1,147 @@
1
+ <script lang="ts" module>
2
+ export type FormFieldType =
3
+ | 'text' | 'email' | 'tel' | 'textarea' | 'number' | 'password'
4
+ | 'select' | 'checkbox' | 'switch' | 'date' | 'color' | 'rating'
5
+
6
+ export type FormField = {
7
+ name: string
8
+ label: string
9
+ type?: FormFieldType
10
+ required?: boolean
11
+ placeholder?: string
12
+ options?: Array<{ value: string | number; label: string }>
13
+ /** Return an error message, or null/undefined when valid. */
14
+ validate?: (value: any, values: Record<string, any>) => string | null | undefined
15
+ /** Span full width in the grid. */
16
+ full?: boolean
17
+ }
18
+ </script>
19
+
20
+ <script lang="ts">
21
+ /**
22
+ * SvForm - a schema-driven form that renders the SvGrid UI-kit controls with
23
+ * labels, required + custom validation, and a submit handler. Parity: Smart
24
+ * `smart-form`. Emits `onSubmit(values)` only when valid; `onChange` on edits.
25
+ */
26
+ import SvNumberInput from './SvNumberInput.svelte'
27
+ import SvPasswordInput from './SvPasswordInput.svelte'
28
+ import SvDropDownList from './SvDropDownList.svelte'
29
+ import SvCheckBox from './SvCheckBox.svelte'
30
+ import SvSwitchButton from './SvSwitchButton.svelte'
31
+ import SvDateTimePicker from './SvDateTimePicker.svelte'
32
+ import SvColorInput from './SvColorInput.svelte'
33
+ import SvRating from './SvRating.svelte'
34
+ import SvButton from './SvButton.svelte'
35
+
36
+ type Props = {
37
+ fields: ReadonlyArray<FormField>
38
+ initial?: Record<string, any>
39
+ onSubmit?: (values: Record<string, any>) => void
40
+ onChange?: (values: Record<string, any>) => void
41
+ submitLabel?: string
42
+ /** Columns in the responsive field grid. */
43
+ columns?: number
44
+ disabled?: boolean
45
+ }
46
+
47
+ let { fields, initial = {}, onSubmit, onChange, submitLabel = 'Submit', columns = 1, disabled = false }: Props = $props()
48
+
49
+ let values = $state<Record<string, any>>({})
50
+ let errors = $state<Record<string, string>>({})
51
+ let touched = $state<Record<string, boolean>>({})
52
+ let seededForm = false
53
+ $effect(() => { if (!seededForm) { seededForm = true; values = { ...initial } } })
54
+
55
+ function setValue(name: string, v: any) {
56
+ values = { ...values, [name]: v }
57
+ onChange?.(values)
58
+ if (touched[name]) validateField(name)
59
+ }
60
+
61
+ function validateField(name: string): boolean {
62
+ const field = fields.find((f) => f.name === name)
63
+ if (!field) return true
64
+ const v = values[name]
65
+ let err: string | null | undefined = null
66
+ if (field.required && (v == null || v === '' || (Array.isArray(v) && !v.length))) err = `${field.label} is required`
67
+ else if (field.validate) err = field.validate(v, values)
68
+ const next = { ...errors }
69
+ if (err) next[name] = err
70
+ else delete next[name]
71
+ errors = next
72
+ return !err
73
+ }
74
+
75
+ function submit(e: Event) {
76
+ e.preventDefault()
77
+ let ok = true
78
+ const t: Record<string, boolean> = {}
79
+ for (const f of fields) { t[f.name] = true; if (!validateField(f.name)) ok = false }
80
+ touched = t
81
+ if (ok) onSubmit?.({ ...values })
82
+ }
83
+
84
+ const dateVal = (v: any): Date | null => (v instanceof Date ? v : v ? new Date(v) : null)
85
+ </script>
86
+
87
+ <form class="sv-form" style:--cols={columns} onsubmit={submit} novalidate>
88
+ <div class="sv-form__grid">
89
+ {#each fields as field (field.name)}
90
+ {@const type = field.type ?? 'text'}
91
+ <div class="sv-form__field" class:is-full={field.full || columns === 1} class:has-error={errors[field.name]}>
92
+ {#if type !== 'checkbox' && type !== 'switch'}
93
+ <label class="sv-form__label" for={`f-${field.name}`}>
94
+ {field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}
95
+ </label>
96
+ {/if}
97
+
98
+ {#if type === 'textarea'}
99
+ <textarea id={`f-${field.name}`} class="sv-form__control sv-form__textarea" rows="3" {disabled} placeholder={field.placeholder} value={values[field.name] ?? ''} oninput={(e) => setValue(field.name, e.currentTarget.value)} onblur={() => { touched[field.name] = true; validateField(field.name) }}></textarea>
100
+ {:else if type === 'number'}
101
+ <SvNumberInput value={values[field.name] ?? null} {disabled} onChange={(v) => setValue(field.name, v)} />
102
+ {:else if type === 'password'}
103
+ <SvPasswordInput value={values[field.name] ?? ''} {disabled} onChange={(v) => setValue(field.name, v)} />
104
+ {:else if type === 'select'}
105
+ <SvDropDownList options={field.options ?? []} value={values[field.name] ?? null} {disabled} placeholder={field.placeholder} onChange={(v) => setValue(field.name, v)} />
106
+ {:else if type === 'checkbox'}
107
+ <SvCheckBox checked={!!values[field.name]} {disabled} onChange={(v) => setValue(field.name, v)}>{field.label}</SvCheckBox>
108
+ {:else if type === 'switch'}
109
+ <div class="sv-form__inline"><SvSwitchButton checked={!!values[field.name]} {disabled} onChange={(v) => setValue(field.name, v)} ariaLabel={field.label} /><span>{field.label}</span></div>
110
+ {:else if type === 'date'}
111
+ <SvDateTimePicker value={dateVal(values[field.name])} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" {disabled} onChange={(v) => setValue(field.name, v)} />
112
+ {:else if type === 'color'}
113
+ <SvColorInput value={values[field.name] ?? '#3b82f6'} {disabled} onChange={(v) => setValue(field.name, v)} />
114
+ {:else if type === 'rating'}
115
+ <SvRating value={values[field.name] ?? 0} {disabled} onChange={(v) => setValue(field.name, v)} />
116
+ {:else}
117
+ <input id={`f-${field.name}`} class="sv-form__control" type={type} {disabled} placeholder={field.placeholder} value={values[field.name] ?? ''} oninput={(e) => setValue(field.name, e.currentTarget.value)} onblur={() => { touched[field.name] = true; validateField(field.name) }} />
118
+ {/if}
119
+
120
+ {#if errors[field.name]}<span class="sv-form__error" role="alert">{errors[field.name]}</span>{/if}
121
+ </div>
122
+ {/each}
123
+ </div>
124
+ <div class="sv-form__actions">
125
+ <SvButton type="submit" {disabled}>{submitLabel}</SvButton>
126
+ </div>
127
+ </form>
128
+
129
+ <style>
130
+ .sv-form { --_accent: var(--sg-accent, #2563eb); width: 100%; }
131
+ .sv-form__grid { display: grid; grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr)); gap: 14px 18px; }
132
+ .sv-form__field { display: flex; flex-direction: column; gap: 5px; }
133
+ .sv-form__field.is-full { grid-column: 1 / -1; }
134
+ .sv-form__label { font-size: 12.5px; font-weight: 600; color: var(--sg-fg, #0f172a); }
135
+ .sv-form__req { color: var(--sg-danger, #dc2626); }
136
+ .sv-form__control {
137
+ width: 100%; box-sizing: border-box; height: 34px; padding: 0 10px; font: inherit; font-size: 13px;
138
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
139
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px); outline: none;
140
+ }
141
+ .sv-form__textarea { height: auto; padding: 8px 10px; resize: vertical; }
142
+ .sv-form__control:focus { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
143
+ .sv-form__field.has-error .sv-form__control { border-color: var(--sg-danger, #dc2626); }
144
+ .sv-form__inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
145
+ .sv-form__error { font-size: 11.5px; color: var(--sg-danger, #dc2626); }
146
+ .sv-form__actions { margin-top: 16px; }
147
+ </style>
@@ -0,0 +1,99 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvGauge - a radial arc gauge (SVG) that renders a value within [min, max]
4
+ * with optional colored threshold bands, a needle, and a center label. Parity:
5
+ * Smart `smart-gauge` (radial). Display control; theme-driven.
6
+ */
7
+ type Band = { from: number; to: number; color: string }
8
+
9
+ type Props = {
10
+ value?: number
11
+ min?: number
12
+ max?: number
13
+ /** Sweep angle in degrees (default 270, a classic dashboard gauge). */
14
+ sweep?: number
15
+ /** Colored threshold bands along the arc. */
16
+ bands?: Band[]
17
+ /** Show the needle. */
18
+ needle?: boolean
19
+ /** Center label (defaults to the formatted value). */
20
+ label?: string
21
+ unit?: string
22
+ size?: number
23
+ thickness?: number
24
+ formatValue?: (v: number) => string
25
+ ariaLabel?: string
26
+ }
27
+
28
+ let {
29
+ value = 0,
30
+ min = 0,
31
+ max = 100,
32
+ sweep = 270,
33
+ bands = [],
34
+ needle = true,
35
+ label,
36
+ unit = '',
37
+ size = 180,
38
+ thickness = 14,
39
+ formatValue,
40
+ ariaLabel,
41
+ }: Props = $props()
42
+
43
+ const clamped = $derived(Math.min(max, Math.max(min, value)))
44
+ const startAngle = $derived(90 + (360 - sweep) / 2) // symmetric around the bottom
45
+ const cx = $derived(size / 2)
46
+ const cy = $derived(size / 2)
47
+ const r = $derived(size / 2 - thickness / 2 - 2)
48
+
49
+ function frac(v: number) { return (v - min) / (max - min) }
50
+ function angleOf(v: number) { return startAngle + frac(v) * sweep }
51
+ function polar(angleDeg: number, radius: number) {
52
+ const a = (angleDeg * Math.PI) / 180
53
+ return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }
54
+ }
55
+ function arcPath(v0: number, v1: number, radius: number): string {
56
+ const a0 = angleOf(v0)
57
+ const a1 = angleOf(v1)
58
+ const p0 = polar(a0, radius)
59
+ const p1 = polar(a1, radius)
60
+ const large = a1 - a0 > 180 ? 1 : 0
61
+ return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${large} 1 ${p1.x} ${p1.y}`
62
+ }
63
+
64
+ const needleEnd = $derived(polar(angleOf(clamped), r - thickness / 2))
65
+ const display = $derived(label ?? `${formatValue ? formatValue(clamped) : Math.round(clamped)}${unit}`)
66
+ </script>
67
+
68
+ <div class="sv-gauge" role="meter" aria-valuenow={clamped} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel ?? 'Gauge'}>
69
+ <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} class="sv-gauge__svg">
70
+ <!-- track -->
71
+ <path d={arcPath(min, max, r)} class="sv-gauge__track" fill="none" stroke-width={thickness} stroke-linecap="round" />
72
+ <!-- bands (or accent fill up to value when no bands) -->
73
+ {#if bands.length}
74
+ {#each bands as band (band.from + '-' + band.to)}
75
+ <path d={arcPath(band.from, band.to, r)} fill="none" stroke={band.color} stroke-width={thickness} stroke-linecap="round" />
76
+ {/each}
77
+ {:else}
78
+ <path d={arcPath(min, clamped, r)} class="sv-gauge__value" fill="none" stroke-width={thickness} stroke-linecap="round" />
79
+ {/if}
80
+ {#if needle}
81
+ <line x1={cx} y1={cy} x2={needleEnd.x} y2={needleEnd.y} class="sv-gauge__needle" stroke-linecap="round" />
82
+ <circle cx={cx} cy={cy} r="5" class="sv-gauge__hub" />
83
+ {/if}
84
+ </svg>
85
+ <div class="sv-gauge__label">{display}</div>
86
+ </div>
87
+
88
+ <style>
89
+ .sv-gauge { position: relative; display: inline-flex; flex-direction: column; align-items: center; color: var(--sg-fg, #0f172a); }
90
+ .sv-gauge__svg { display: block; }
91
+ .sv-gauge__track { stroke: var(--sg-border, #e2e8f0); }
92
+ .sv-gauge__value { stroke: var(--sg-accent, #2563eb); }
93
+ .sv-gauge__needle { stroke: var(--sg-fg, #0f172a); stroke-width: 3; }
94
+ .sv-gauge__hub { fill: var(--sg-fg, #0f172a); }
95
+ .sv-gauge__label {
96
+ position: absolute; left: 0; right: 0; bottom: 22%; text-align: center;
97
+ font-size: 22px; font-weight: 750; font-variant-numeric: tabular-nums;
98
+ }
99
+ </style>
@@ -409,6 +409,10 @@ export function createSvGridController<
409
409
  });
410
410
  let columnVirtualizerVersion = $state(0);
411
411
  let gridStateVersion = $state(0);
412
+ // Bumps only when a row-model-affecting slice changes (see the store
413
+ // subscription below) - the row-model derivation depends on THIS, not the
414
+ // catch-all gridStateVersion, so navigation doesn't rebuild 1M rows.
415
+ let dataStateVersion = $state(0);
412
416
  const selectionColumnWidth = 44;
413
417
  const rowNumberColumnWidth = $derived(props.rowNumberWidth ?? 56);
414
418
  const showRowNumbersEffective = $derived(props.showRowNumbers ?? false);
@@ -582,6 +586,10 @@ export function createSvGridController<
582
586
  // the wrapper records filter state but does not actually filter rows.
583
587
  // svelte-ignore state_referenced_locally
584
588
  const externalFilterEnabled = props.externalFilter === true;
589
+ // Server-side pagination: the footer reads rowCount/pageIndex from props and
590
+ // emits onPaginationChange instead of slicing locally. Controlled by the
591
+ // consumer. Reactive (unlike sort/filter) so pageIndex/rowCount can change.
592
+ const externalPaginationEnabled = $derived(props.externalPagination === true);
585
593
  const passthroughSortedRowModel = ({ rows }: { rows: Array<Row<TData>> }) =>
586
594
  rows;
587
595
 
@@ -629,9 +637,38 @@ export function createSvGridController<
629
637
  },
630
638
  });
631
639
 
640
+ // `gridStateVersion` bumps on EVERY store change (incl. moving the active
641
+ // cell). `dataStateVersion` bumps ONLY when a slice that actually changes the
642
+ // row model changes (sort / filter / pagination / grouping / expansion /
643
+ // selection) - so the O(rows) row-model derivation does NOT re-run on plain
644
+ // keyboard navigation. Without this, arrow-keying a 1,000,000-row grid re-ran
645
+ // the entire core->filter->sort->group pipeline on every keystroke.
646
+ let prevDataSlices:
647
+ | { sorting: unknown; columnFilters: unknown; pagination: unknown; grouping: unknown; expanded: unknown; rowSelection: unknown }
648
+ | null = null;
632
649
  $effect(() => {
633
650
  const unsubscribe = grid.store.subscribe(() => {
634
651
  gridStateVersion += 1;
652
+ const s = grid.getState();
653
+ if (
654
+ !prevDataSlices ||
655
+ prevDataSlices.sorting !== s.sorting ||
656
+ prevDataSlices.columnFilters !== s.columnFilters ||
657
+ prevDataSlices.pagination !== s.pagination ||
658
+ prevDataSlices.grouping !== s.grouping ||
659
+ prevDataSlices.expanded !== s.expanded ||
660
+ prevDataSlices.rowSelection !== s.rowSelection
661
+ ) {
662
+ dataStateVersion += 1;
663
+ prevDataSlices = {
664
+ sorting: s.sorting,
665
+ columnFilters: s.columnFilters,
666
+ pagination: s.pagination,
667
+ grouping: s.grouping,
668
+ expanded: s.expanded,
669
+ rowSelection: s.rowSelection,
670
+ };
671
+ }
635
672
  });
636
673
  return unsubscribe;
637
674
  });
@@ -977,7 +1014,11 @@ export function createSvGridController<
977
1014
  * filters reduce the dataset.
978
1015
  */
979
1016
  const allRowsBeforePagination = $derived.by(() => {
980
- gridStateVersion;
1017
+ // Depend on dataStateVersion (row-model-affecting store changes) NOT
1018
+ // gridStateVersion - so moving the active cell / selection does not force
1019
+ // this O(rows) pipeline to re-run. Filter-input state (globalFilter etc.)
1020
+ // and internalData are read below and tracked as their own dependencies.
1021
+ dataStateVersion;
981
1022
  // Touch internalData + internalColumns so the row model re-derives when
982
1023
  // the consumer replaces the data array (e.g. via a "Reset" button).
983
1024
  void internalData;
@@ -1081,7 +1122,8 @@ export function createSvGridController<
1081
1122
  */
1082
1123
  const allRows = $derived.by(() => {
1083
1124
  const rows = allRowsBeforePagination;
1084
- if (!paginationEnabled) return rows;
1125
+ // External pagination: `data` already IS the current page - never slice.
1126
+ if (!paginationEnabled || externalPaginationEnabled) return rows;
1085
1127
  const { pageIndex, pageSize } = paginationState;
1086
1128
  const start = pageIndex * pageSize;
1087
1129
  return rows.slice(start, start + pageSize);
@@ -1089,14 +1131,27 @@ export function createSvGridController<
1089
1131
 
1090
1132
  // When a filter reduces the dataset, the stored pageIndex can point beyond
1091
1133
  // the last valid page. Reset to page 0 so the grid never shows a blank body.
1134
+ // Skipped for external pagination, where the consumer owns pageIndex.
1092
1135
  $effect(() => {
1093
- if (!paginationEnabled) return;
1136
+ if (!paginationEnabled || externalPaginationEnabled) return;
1094
1137
  const { pageIndex, pageSize } = paginationState;
1095
1138
  const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1096
1139
  if (pageCount > 0 && pageIndex >= pageCount) {
1097
1140
  grid.setPagination({ pageIndex: 0, pageSize });
1098
1141
  }
1099
1142
  });
1143
+
1144
+ // Footer-facing pagination values. In external mode they come from the
1145
+ // consumer-controlled props; otherwise from the local row model + state.
1146
+ const paginationTotalRows = $derived(
1147
+ externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length,
1148
+ );
1149
+ const paginationPageIndex = $derived(
1150
+ externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex,
1151
+ );
1152
+ const paginationPageSize = $derived(
1153
+ externalPaginationEnabled ? (props.pageSize ?? 10) : paginationState.pageSize,
1154
+ );
1100
1155
  const rowSelectionState = $derived.by(() => {
1101
1156
  gridStateVersion;
1102
1157
  return grid.getState().rowSelection ?? {};
@@ -1501,9 +1556,14 @@ export function createSvGridController<
1501
1556
 
1502
1557
  let summaryByColumn = $state<Record<string, string>>({});
1503
1558
  $effect(() => {
1504
- // Re-aggregate whenever the visible data, columns, edits, or any
1505
- // state that reorders/filters rows changes.
1506
- gridStateVersion;
1559
+ // Re-aggregate whenever the data / columns / edits change. We depend on
1560
+ // `allRows` / `allColumns` / `editedCellValues` DIRECTLY - not the
1561
+ // catch-all `gridStateVersion` - because that version bumps on EVERY store
1562
+ // change, including moving the active cell or selection. `allRows` stays
1563
+ // referentially stable across those (sort/filter/paginate produce a new
1564
+ // rows array; navigation does not), so this now skips the
1565
+ // O(rows x cols) aggregation on plain keyboard navigation - which was
1566
+ // making arrow-key movement crawl on huge grids (e.g. 1,000,000 rows).
1507
1567
  void editedCellValues;
1508
1568
  const rows = allRows;
1509
1569
  const columns = allColumns;
@@ -2209,6 +2269,10 @@ export function createSvGridController<
2209
2269
  get sortDirectionByColumn() { return sortDirectionByColumn; },
2210
2270
  get groupingColumns() { return groupingColumns; },
2211
2271
  get paginationState() { return paginationState; },
2272
+ get externalPaginationEnabled() { return externalPaginationEnabled; },
2273
+ get paginationTotalRows() { return paginationTotalRows; },
2274
+ get paginationPageIndex() { return paginationPageIndex; },
2275
+ get paginationPageSize() { return paginationPageSize; },
2212
2276
  get getRowColumnValue() { return getRowColumnValue; },
2213
2277
  get allRowsBeforePagination() { return allRowsBeforePagination; },
2214
2278
  get allRows() { return allRows; },
@@ -2274,6 +2338,7 @@ export function createSvGridController<
2274
2338
  get computeRowClass() { return computeRowClass; },
2275
2339
  get computeCellClass() { return computeCellClass; },
2276
2340
  get computeCellTooltip() { return computeCellTooltip; },
2341
+ get computeCellValidity() { return computeCellValidity; },
2277
2342
  get computeCellNote() { return computeCellNote; },
2278
2343
  get getCellDisplayValue() { return getCellDisplayValue; },
2279
2344
  get getColumnAlign() { return getColumnAlign; },
@@ -2309,6 +2374,7 @@ export function createSvGridController<
2309
2374
  get getSelectionRects() { return getSelectionRects; },
2310
2375
  get fillHandleCell() { return fillHandleCell; },
2311
2376
  get isInFillPreview() { return isInFillPreview; },
2377
+ get fillMarqueeEdges() { return fillMarqueeEdges; },
2312
2378
  get findColumnById() { return findColumnById; },
2313
2379
  get readCellRaw() { return readCellRaw; },
2314
2380
  get writeCellRaw() { return writeCellRaw; },
@@ -2402,9 +2468,9 @@ export function createSvGridController<
2402
2468
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
2403
2469
  const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
2404
2470
  const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
2405
- const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
2471
+ const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
2406
2472
  const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
2407
- const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
2473
+ const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
2408
2474
  const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, startColumnResize, onColumnResizeMove, endColumnResize, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
2409
2475
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd } = createRowDrag<TFeatures, TData>(ctx);
2410
2476
  const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids<TFeatures, TData>(ctx);