@svgrid/grid 1.2.21 → 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.
- package/dist/GridFooter.svelte +34 -20
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/SvAutoComplete.svelte +100 -0
- package/dist/SvAutoComplete.svelte.d.ts +16 -0
- package/dist/SvButton.svelte +97 -0
- package/dist/SvButton.svelte.d.ts +23 -0
- package/dist/SvCalendar.svelte +669 -0
- package/dist/SvCalendar.svelte.d.ts +70 -0
- package/dist/SvCheckBox.svelte +81 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +187 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +130 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +123 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +322 -0
- package/dist/SvDateTimePicker.svelte.d.ts +37 -0
- package/dist/SvDropDownList.svelte +143 -0
- package/dist/SvDropDownList.svelte.d.ts +15 -0
- package/dist/SvForm.svelte +147 -0
- package/dist/SvForm.svelte.d.ts +29 -0
- package/dist/SvGauge.svelte +99 -0
- package/dist/SvGauge.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +16 -2
- package/dist/SvGrid.controller.svelte.js +63 -8
- package/dist/SvGrid.css +84 -31
- package/dist/SvGrid.helpers.d.ts +4 -0
- package/dist/SvGrid.helpers.js +40 -12
- package/dist/SvGrid.svelte +84 -5
- package/dist/SvGrid.types.d.ts +36 -0
- package/dist/SvGridDropdown.svelte +16 -30
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvListBox.svelte +122 -0
- package/dist/SvListBox.svelte.d.ts +19 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +22 -0
- package/dist/SvPasswordInput.svelte +110 -0
- package/dist/SvPasswordInput.svelte.d.ts +22 -0
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +108 -0
- package/dist/SvRadioGroup.svelte.d.ts +23 -0
- package/dist/SvRating.svelte +111 -0
- package/dist/SvRating.svelte.d.ts +20 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +182 -0
- package/dist/SvSlider.svelte.d.ts +25 -0
- package/dist/SvSwitchButton.svelte +94 -0
- package/dist/SvSwitchButton.svelte.d.ts +19 -0
- package/dist/SvTabs.svelte +122 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +81 -0
- package/dist/SvTagsInput.svelte.d.ts +19 -0
- package/dist/SvTimePicker.svelte +359 -0
- package/dist/SvTimePicker.svelte.d.ts +27 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +210 -0
- package/dist/SvTree.svelte.d.ts +29 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +10022 -5904
- package/dist/cdn/svgrid.svelte-external.js +8531 -4429
- package/dist/cell-render.d.ts +4 -0
- package/dist/cell-render.js +35 -1
- package/dist/clipboard.js +19 -1
- package/dist/collaboration.d.ts +2 -0
- package/dist/collaboration.js +4 -0
- package/dist/core.d.ts +22 -1
- package/dist/countries.d.ts +15 -0
- package/dist/countries.js +61 -0
- package/dist/datetime/date-core.d.ts +88 -0
- package/dist/datetime/date-core.js +165 -0
- package/dist/datetime/date-format.d.ts +39 -0
- package/dist/datetime/date-format.js +371 -0
- package/dist/datetime/date-restrict.d.ts +23 -0
- package/dist/datetime/date-restrict.js +50 -0
- package/dist/datetime/date-selection.d.ts +25 -0
- package/dist/datetime/date-selection.js +94 -0
- package/dist/datetime/mask.d.ts +21 -0
- package/dist/datetime/mask.js +90 -0
- package/dist/editing.d.ts +1 -1
- package/dist/editing.js +7 -0
- package/dist/editors/cell-editors.d.ts +2 -2
- package/dist/editors/cell-editors.js +3 -1
- package/dist/export-format.d.ts +178 -0
- package/dist/export-format.js +419 -0
- package/dist/hyperformula-adapter.d.ts +4 -0
- package/dist/hyperformula-adapter.js +15 -6
- package/dist/index.d.ts +36 -0
- package/dist/index.js +41 -0
- package/dist/list-option.d.ts +10 -0
- package/dist/list-option.js +7 -0
- package/dist/menus.js +18 -2
- package/dist/popover.d.ts +48 -0
- package/dist/popover.js +65 -0
- package/dist/row-resize.d.ts +4 -4
- package/dist/row-resize.js +8 -5
- package/dist/selection.d.ts +6 -0
- package/dist/selection.js +31 -5
- package/dist/server-data-source.d.ts +43 -0
- package/dist/server-data-source.js +100 -0
- package/dist/sparkline.js +8 -4
- package/dist/svgrid-wrapper.types.d.ts +26 -1
- package/package.json +6 -1
- package/src/GridFooter.svelte +34 -20
- package/src/SvAutoComplete.svelte +100 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +669 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +81 -0
- package/src/SvColorInput.svelte +187 -0
- package/src/SvComboBox.svelte +130 -0
- package/src/SvCountryInput.svelte +123 -0
- package/src/SvDateTimePicker.svelte +322 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +143 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +99 -0
- package/src/SvGrid.controller.svelte.ts +74 -8
- package/src/SvGrid.css +84 -31
- package/src/SvGrid.helpers.ts +41 -12
- package/src/SvGrid.svelte +84 -5
- package/src/SvGrid.types.ts +33 -0
- package/src/SvGridDropdown.svelte +16 -30
- package/src/SvListBox.svelte +122 -0
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +110 -0
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +108 -0
- package/src/SvRating.svelte +111 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +182 -0
- package/src/SvSwitchButton.svelte +94 -0
- package/src/SvTabs.svelte +122 -0
- package/src/SvTagsInput.svelte +81 -0
- package/src/SvTimePicker.svelte +359 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +210 -0
- package/src/build-api.ts +115 -0
- package/src/cell-render.test.ts +38 -0
- package/src/cell-render.ts +43 -0
- package/src/clipboard.test.ts +18 -0
- package/src/clipboard.ts +24 -1
- package/src/collaboration.test.ts +30 -0
- package/src/collaboration.ts +6 -0
- package/src/core.ts +27 -3
- package/src/countries.ts +71 -0
- package/src/datetime/date-core.test.ts +217 -0
- package/src/datetime/date-core.ts +204 -0
- package/src/datetime/date-format.test.ts +121 -0
- package/src/datetime/date-format.ts +317 -0
- package/src/datetime/date-restrict.ts +60 -0
- package/src/datetime/date-selection.test.ts +129 -0
- package/src/datetime/date-selection.ts +137 -0
- package/src/datetime/mask.test.ts +36 -0
- package/src/datetime/mask.ts +84 -0
- package/src/editing.test.ts +22 -0
- package/src/editing.ts +7 -1
- package/src/editors/cell-editors.ts +7 -1
- package/src/export-data-api.test.ts +126 -0
- package/src/export-format.test.ts +107 -0
- package/src/export-format.ts +598 -0
- package/src/hyperformula-adapter.test.ts +35 -1
- package/src/hyperformula-adapter.ts +18 -6
- package/src/index.ts +71 -0
- package/src/list-option.ts +15 -0
- package/src/menus.ts +21 -2
- package/src/popover.ts +79 -0
- package/src/row-resize.test.ts +25 -0
- package/src/row-resize.ts +8 -5
- package/src/selection.test.ts +36 -4
- package/src/selection.ts +30 -9
- package/src/server-data-source.test.ts +201 -0
- package/src/server-data-source.ts +148 -0
- package/src/sparkline.test.ts +9 -0
- package/src/sparkline.ts +9 -4
- package/src/svgrid-wrapper.types.ts +28 -1
- package/src/svgrid.behavior.test.ts +27 -0
- package/src/svgrid.new-features.wrapper.test.ts +27 -1
- package/src/ui-buttons.test.ts +105 -0
- package/src/ui-composite.test.ts +89 -0
- package/src/ui-inputs.test.ts +92 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +106 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type TreeNode = {
|
|
3
|
+
id: string
|
|
4
|
+
label: string
|
|
5
|
+
children?: TreeNode[]
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
/**
|
|
12
|
+
* SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight, and
|
|
13
|
+
* optional cascading tri-state checkboxes. Parity: Smart `smart-tree`. Keyboard:
|
|
14
|
+
* up/down move, left collapse/parent, right expand/child, Enter/Space select.
|
|
15
|
+
* The visible tree is flattened so roving-focus keyboard nav stays simple.
|
|
16
|
+
*/
|
|
17
|
+
type Props = {
|
|
18
|
+
nodes: ReadonlyArray<TreeNode>
|
|
19
|
+
/** Selected node id (single-select highlight). */
|
|
20
|
+
selected?: string | null
|
|
21
|
+
onSelect?: (id: string) => void
|
|
22
|
+
/** Initially/controlled expanded ids. */
|
|
23
|
+
expandedIds?: string[]
|
|
24
|
+
onToggle?: (id: string, expanded: boolean) => void
|
|
25
|
+
/** Show cascading checkboxes; `checked` is the set of checked ids. */
|
|
26
|
+
checkable?: boolean
|
|
27
|
+
checked?: string[]
|
|
28
|
+
onCheck?: (ids: string[]) => void
|
|
29
|
+
ariaLabel?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
nodes,
|
|
34
|
+
selected = null,
|
|
35
|
+
onSelect,
|
|
36
|
+
expandedIds,
|
|
37
|
+
onToggle,
|
|
38
|
+
checkable = false,
|
|
39
|
+
checked = [],
|
|
40
|
+
onCheck,
|
|
41
|
+
ariaLabel,
|
|
42
|
+
}: Props = $props()
|
|
43
|
+
|
|
44
|
+
// Internal expanded set, seeded from the prop once (then self-managed unless
|
|
45
|
+
// the prop identity changes).
|
|
46
|
+
let expanded = $state<Set<string>>(new Set())
|
|
47
|
+
let seededExp = false
|
|
48
|
+
$effect(() => {
|
|
49
|
+
if (!seededExp) { seededExp = true; if (expandedIds) expanded = new Set(expandedIds) }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
type Flat = { node: TreeNode; depth: number; hasChildren: boolean; open: boolean; parentId: string | null }
|
|
53
|
+
|
|
54
|
+
const flat = $derived.by<Flat[]>(() => {
|
|
55
|
+
const out: Flat[] = []
|
|
56
|
+
const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
|
|
57
|
+
for (const node of list) {
|
|
58
|
+
const hasChildren = !!node.children?.length
|
|
59
|
+
const open = expanded.has(node.id)
|
|
60
|
+
out.push({ node, depth, hasChildren, open, parentId })
|
|
61
|
+
if (hasChildren && open) walk(node.children!, depth + 1, node.id)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
walk(nodes, 0, null)
|
|
65
|
+
return out
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
let active = $state(0)
|
|
69
|
+
$effect(() => { if (active >= flat.length) active = Math.max(0, flat.length - 1) })
|
|
70
|
+
|
|
71
|
+
const checkedSet = $derived(new Set(checked))
|
|
72
|
+
|
|
73
|
+
function descendantIds(node: TreeNode): string[] {
|
|
74
|
+
const ids: string[] = []
|
|
75
|
+
const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
|
|
76
|
+
walk(node)
|
|
77
|
+
return ids
|
|
78
|
+
}
|
|
79
|
+
function checkState(node: TreeNode): 'checked' | 'indeterminate' | 'unchecked' {
|
|
80
|
+
if (!node.children?.length) return checkedSet.has(node.id) ? 'checked' : 'unchecked'
|
|
81
|
+
const desc = descendantIds(node)
|
|
82
|
+
const on = desc.filter((id) => checkedSet.has(id)).length
|
|
83
|
+
if (on === 0 && !checkedSet.has(node.id)) return 'unchecked'
|
|
84
|
+
if (on === desc.length) return 'checked'
|
|
85
|
+
return 'indeterminate'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleExpand(node: TreeNode) {
|
|
89
|
+
if (!node.children?.length) return
|
|
90
|
+
const next = new Set(expanded)
|
|
91
|
+
const willOpen = !next.has(node.id)
|
|
92
|
+
willOpen ? next.add(node.id) : next.delete(node.id)
|
|
93
|
+
expanded = next
|
|
94
|
+
onToggle?.(node.id, willOpen)
|
|
95
|
+
}
|
|
96
|
+
function select(node: TreeNode) {
|
|
97
|
+
if (node.disabled) return
|
|
98
|
+
onSelect?.(node.id)
|
|
99
|
+
}
|
|
100
|
+
function toggleCheck(node: TreeNode) {
|
|
101
|
+
if (node.disabled) return
|
|
102
|
+
const next = new Set(checkedSet)
|
|
103
|
+
const ids = [node.id, ...descendantIds(node)]
|
|
104
|
+
const currentlyOn = checkState(node) === 'checked'
|
|
105
|
+
for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
|
|
106
|
+
onCheck?.([...next])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function onKeydown(e: KeyboardEvent) {
|
|
110
|
+
const item = flat[active]
|
|
111
|
+
if (!item) return
|
|
112
|
+
switch (e.key) {
|
|
113
|
+
case 'ArrowDown': e.preventDefault(); active = Math.min(active + 1, flat.length - 1); focusActive(); break
|
|
114
|
+
case 'ArrowUp': e.preventDefault(); active = Math.max(active - 1, 0); focusActive(); break
|
|
115
|
+
case 'ArrowRight':
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
if (item.hasChildren && !item.open) toggleExpand(item.node)
|
|
118
|
+
else if (item.hasChildren && item.open) { active = Math.min(active + 1, flat.length - 1); focusActive() }
|
|
119
|
+
break
|
|
120
|
+
case 'ArrowLeft':
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
if (item.hasChildren && item.open) toggleExpand(item.node)
|
|
123
|
+
else if (item.parentId) { const pi = flat.findIndex((f) => f.node.id === item.parentId); if (pi >= 0) { active = pi; focusActive() } }
|
|
124
|
+
break
|
|
125
|
+
case 'Enter': e.preventDefault(); select(item.node); break
|
|
126
|
+
case ' ': e.preventDefault(); checkable ? toggleCheck(item.node) : select(item.node); break
|
|
127
|
+
case 'Home': e.preventDefault(); active = 0; focusActive(); break
|
|
128
|
+
case 'End': e.preventDefault(); active = flat.length - 1; focusActive(); break
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let treeEl: HTMLDivElement | null = null
|
|
133
|
+
function focusActive() {
|
|
134
|
+
queueMicrotask(() => treeEl?.querySelector<HTMLElement>(`[data-row="${active}"]`)?.focus())
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<div bind:this={treeEl} class="sv-tree" role="tree" aria-label={ariaLabel} aria-multiselectable={checkable}>
|
|
139
|
+
{#each flat as item, i (item.node.id)}
|
|
140
|
+
{@const cs = checkable ? checkState(item.node) : 'unchecked'}
|
|
141
|
+
<div
|
|
142
|
+
class="sv-tree__row"
|
|
143
|
+
class:is-selected={item.node.id === selected}
|
|
144
|
+
class:is-disabled={item.node.disabled}
|
|
145
|
+
role="treeitem"
|
|
146
|
+
aria-level={item.depth + 1}
|
|
147
|
+
aria-selected={item.node.id === selected}
|
|
148
|
+
aria-expanded={item.hasChildren ? item.open : undefined}
|
|
149
|
+
aria-checked={checkable ? cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false' : undefined}
|
|
150
|
+
data-row={i}
|
|
151
|
+
tabindex={i === active ? 0 : -1}
|
|
152
|
+
style:padding-left={`${item.depth * 18 + 6}px`}
|
|
153
|
+
onclick={() => { active = i; select(item.node) }}
|
|
154
|
+
onkeydown={onKeydown}
|
|
155
|
+
>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
class="sv-tree__twist"
|
|
159
|
+
class:is-open={item.open}
|
|
160
|
+
class:is-leaf={!item.hasChildren}
|
|
161
|
+
tabindex="-1"
|
|
162
|
+
aria-hidden={!item.hasChildren}
|
|
163
|
+
onclick={(e) => { e.stopPropagation(); toggleExpand(item.node) }}
|
|
164
|
+
>
|
|
165
|
+
{#if item.hasChildren}
|
|
166
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
|
|
167
|
+
{/if}
|
|
168
|
+
</button>
|
|
169
|
+
{#if checkable}
|
|
170
|
+
<button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggleCheck(item.node) }}>
|
|
171
|
+
{#if cs === 'checked'}<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
172
|
+
{:else if cs === 'indeterminate'}<svg viewBox="0 0 16 16"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>{/if}
|
|
173
|
+
</button>
|
|
174
|
+
{/if}
|
|
175
|
+
<span class="sv-tree__label">{item.node.label}</span>
|
|
176
|
+
</div>
|
|
177
|
+
{/each}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<style>
|
|
181
|
+
.sv-tree {
|
|
182
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
183
|
+
width: 260px; padding: 4px; user-select: none;
|
|
184
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
185
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
|
|
186
|
+
font-size: 13px;
|
|
187
|
+
}
|
|
188
|
+
.sv-tree__row {
|
|
189
|
+
display: flex; align-items: center; gap: 4px; height: 30px; padding-right: 8px;
|
|
190
|
+
border-radius: 6px; cursor: pointer; outline: none;
|
|
191
|
+
}
|
|
192
|
+
.sv-tree__row:hover:not(.is-disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
193
|
+
.sv-tree__row.is-selected { background: color-mix(in srgb, var(--_accent) 15%, transparent); color: var(--_accent); font-weight: 600; }
|
|
194
|
+
.sv-tree__row.is-disabled { opacity: 0.45; cursor: not-allowed; }
|
|
195
|
+
.sv-tree__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-focus-ring, var(--_accent)); }
|
|
196
|
+
.sv-tree__twist {
|
|
197
|
+
display: grid; place-items: center; width: 18px; height: 18px; flex: none;
|
|
198
|
+
background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; transition: transform 0.12s;
|
|
199
|
+
}
|
|
200
|
+
.sv-tree__twist.is-open { transform: rotate(90deg); }
|
|
201
|
+
.sv-tree__twist.is-leaf { visibility: hidden; }
|
|
202
|
+
.sv-tree__check {
|
|
203
|
+
display: grid; place-items: center; width: 16px; height: 16px; flex: none; padding: 0;
|
|
204
|
+
border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 4px; background: var(--sg-input-bg, #fff);
|
|
205
|
+
color: var(--sg-on-accent, #fff); cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
.sv-tree__check.is-checked, .sv-tree__check.is-indeterminate { background: var(--_accent); border-color: var(--_accent); }
|
|
208
|
+
.sv-tree__check svg { width: 100%; height: 100%; }
|
|
209
|
+
.sv-tree__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
210
|
+
</style>
|
package/src/build-api.ts
CHANGED
|
@@ -47,6 +47,18 @@ import {
|
|
|
47
47
|
getDateFormatter,
|
|
48
48
|
resolveDatePattern,
|
|
49
49
|
} from "./cell-formatting";
|
|
50
|
+
import {
|
|
51
|
+
projectGridRows,
|
|
52
|
+
serializeDelimited,
|
|
53
|
+
serializeJson,
|
|
54
|
+
serializeMarkdown,
|
|
55
|
+
downloadTextFile,
|
|
56
|
+
copyTextToClipboard,
|
|
57
|
+
type GridExportColumn,
|
|
58
|
+
type GridExportScope,
|
|
59
|
+
type GridExportOptions,
|
|
60
|
+
type GridClipboardOptions,
|
|
61
|
+
} from "./export-format";
|
|
50
62
|
import {
|
|
51
63
|
RenderSnippetConfig,
|
|
52
64
|
RenderComponentConfig,
|
|
@@ -128,6 +140,42 @@ export function createGridApi<
|
|
|
128
140
|
function buildApi(): SvGridApi<TFeatures, TData> {
|
|
129
141
|
const findColumn = (columnId: string) =>
|
|
130
142
|
ctx.grid.getAllColumns().find((column: any) => column.id === columnId);
|
|
143
|
+
|
|
144
|
+
// --- Free data export (CSV / TSV / JSON + clipboard) helpers ----------
|
|
145
|
+
// Snapshot the VISIBLE columns (field + label + format + align) so the
|
|
146
|
+
// exporter reproduces on-screen values. Mirrors getColumns()'s visible set.
|
|
147
|
+
const exportColumns = (): GridExportColumn[] =>
|
|
148
|
+
ctx.allColumns
|
|
149
|
+
.map((c: any) => ({
|
|
150
|
+
field: (c.columnDef as { field?: string }).field as string,
|
|
151
|
+
header:
|
|
152
|
+
typeof c.columnDef.header === "string" ? c.columnDef.header : c.id,
|
|
153
|
+
format: c.columnDef.format,
|
|
154
|
+
align: getColumnAlign(c),
|
|
155
|
+
}))
|
|
156
|
+
.filter((c: GridExportColumn) => !!c.field);
|
|
157
|
+
// Rows for the requested scope: current view / checked / full dataset.
|
|
158
|
+
const exportScopeRows = (scope: GridExportScope): TData[] => {
|
|
159
|
+
if (scope === "all") return ctx.internalData as TData[];
|
|
160
|
+
const out: TData[] = [];
|
|
161
|
+
for (const row of ctx.allRows) {
|
|
162
|
+
if (isGroupRow(row)) continue;
|
|
163
|
+
if (scope === "selected" && !ctx.rowSelectionState[row.id]) continue;
|
|
164
|
+
out.push(row.original as TData);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
const projectForExport = (
|
|
169
|
+
scope: GridExportScope,
|
|
170
|
+
columns: ReadonlyArray<string> | undefined,
|
|
171
|
+
rawValues: boolean | undefined,
|
|
172
|
+
) =>
|
|
173
|
+
projectGridRows(
|
|
174
|
+
exportScopeRows(scope) as ReadonlyArray<Record<string, unknown>>,
|
|
175
|
+
exportColumns(),
|
|
176
|
+
{ columns, rawValues },
|
|
177
|
+
);
|
|
178
|
+
|
|
131
179
|
return {
|
|
132
180
|
getCellValue(rowIndex, columnId) {
|
|
133
181
|
const row = ctx.internalData[rowIndex];
|
|
@@ -409,6 +457,12 @@ export function createGridApi<
|
|
|
409
457
|
? c.columnDef.header
|
|
410
458
|
: c.id,
|
|
411
459
|
visible,
|
|
460
|
+
// Surface the format + alignment so exporters can reproduce the
|
|
461
|
+
// on-screen display value (currency symbol, date pattern, etc.)
|
|
462
|
+
// instead of dumping raw values. `getColumnAlign` applies the same
|
|
463
|
+
// editorType-based default the body uses.
|
|
464
|
+
format: c.columnDef.format,
|
|
465
|
+
align: getColumnAlign(c),
|
|
412
466
|
});
|
|
413
467
|
// Visible columns first, in their current visual order...
|
|
414
468
|
const out = ctx.allColumns.map((c: any) => describe(c, true));
|
|
@@ -421,6 +475,67 @@ export function createGridApi<
|
|
|
421
475
|
}
|
|
422
476
|
return out;
|
|
423
477
|
},
|
|
478
|
+
// ---- Free data export (CSV / TSV / JSON + clipboard) -----------------
|
|
479
|
+
async exportCsv(options: GridExportOptions = {}) {
|
|
480
|
+
const { records, fields } = projectForExport(
|
|
481
|
+
options.rows ?? "displayed",
|
|
482
|
+
options.columns,
|
|
483
|
+
options.rawValues,
|
|
484
|
+
);
|
|
485
|
+
const text = await serializeDelimited(records, fields, {
|
|
486
|
+
csv: { delimiter: ",", bom: options.bom ?? true },
|
|
487
|
+
signal: options.signal,
|
|
488
|
+
onProgress: options.onProgress,
|
|
489
|
+
});
|
|
490
|
+
if (options.download !== false)
|
|
491
|
+
downloadTextFile(text, `${options.filename ?? "grid"}.csv`, "text/csv;charset=utf-8");
|
|
492
|
+
return text;
|
|
493
|
+
},
|
|
494
|
+
async exportTsv(options: GridExportOptions = {}) {
|
|
495
|
+
const { records, fields } = projectForExport(
|
|
496
|
+
options.rows ?? "displayed",
|
|
497
|
+
options.columns,
|
|
498
|
+
options.rawValues,
|
|
499
|
+
);
|
|
500
|
+
const text = await serializeDelimited(records, fields, {
|
|
501
|
+
csv: { delimiter: "\t", bom: options.bom ?? true },
|
|
502
|
+
signal: options.signal,
|
|
503
|
+
onProgress: options.onProgress,
|
|
504
|
+
});
|
|
505
|
+
if (options.download !== false)
|
|
506
|
+
downloadTextFile(text, `${options.filename ?? "grid"}.tsv`, "text/tab-separated-values;charset=utf-8");
|
|
507
|
+
return text;
|
|
508
|
+
},
|
|
509
|
+
async exportJson(options: GridExportOptions = {}) {
|
|
510
|
+
const { records, fields } = projectForExport(
|
|
511
|
+
options.rows ?? "displayed",
|
|
512
|
+
options.columns,
|
|
513
|
+
options.rawValues,
|
|
514
|
+
);
|
|
515
|
+
const text = await serializeJson(records, fields, {
|
|
516
|
+
signal: options.signal,
|
|
517
|
+
onProgress: options.onProgress,
|
|
518
|
+
});
|
|
519
|
+
if (options.download !== false)
|
|
520
|
+
downloadTextFile(text, `${options.filename ?? "grid"}.json`, "application/json");
|
|
521
|
+
return text;
|
|
522
|
+
},
|
|
523
|
+
async copyToClipboard(options: GridClipboardOptions = {}) {
|
|
524
|
+
const { records, fields, align } = projectForExport(
|
|
525
|
+
options.rows ?? "displayed",
|
|
526
|
+
options.columns,
|
|
527
|
+
options.rawValues,
|
|
528
|
+
);
|
|
529
|
+
const format = options.format ?? "tsv";
|
|
530
|
+
const text =
|
|
531
|
+
format === "markdown"
|
|
532
|
+
? await serializeMarkdown(records, fields, { align })
|
|
533
|
+
: await serializeDelimited(records, fields, {
|
|
534
|
+
csv: { delimiter: format === "csv" ? "," : "\t", bom: false },
|
|
535
|
+
});
|
|
536
|
+
await copyTextToClipboard(text);
|
|
537
|
+
return text;
|
|
538
|
+
},
|
|
424
539
|
clearRowSelection() {
|
|
425
540
|
// Wipe the internal selection map AND emit the change so any
|
|
426
541
|
// consumer holding a derived `selectedRows` resets too.
|
package/src/cell-render.test.ts
CHANGED
|
@@ -81,6 +81,44 @@ describe('createCellRender / cellConditionalFormat', () => {
|
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
describe('createCellRender / computeCellValidity', () => {
|
|
85
|
+
const validateSalary = ({ value }: { value: unknown }) =>
|
|
86
|
+
Number(value) >= 1000 ? true : 'Salary must be at least $1,000'
|
|
87
|
+
|
|
88
|
+
it('returns valid when no validate hook is configured', () => {
|
|
89
|
+
const cr = createCellRender(makeCtx({ editedCellValues: {} }))
|
|
90
|
+
const out = cr.computeCellValidity(makeRow('r', { salary: 5 }), makeColumn('salary'))
|
|
91
|
+
expect(out).toEqual({ invalid: false, message: null })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('flags bad base data on load (message becomes the tooltip)', () => {
|
|
95
|
+
const cr = createCellRender(makeCtx({ editedCellValues: {} }))
|
|
96
|
+
const col = makeColumn('salary', { validate: validateSalary })
|
|
97
|
+
const out = cr.computeCellValidity(makeRow('r3', { salary: -5000 }), col)
|
|
98
|
+
expect(out).toEqual({ invalid: true, message: 'Salary must be at least $1,000' })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('re-validates against the edit overlay, not the stale cached base value', () => {
|
|
102
|
+
// The row still reports the old (bad) value via getCellValueByColumnId -
|
|
103
|
+
// inline edits are not written into the row's memoized value cache. The
|
|
104
|
+
// edited value lives in editedCellValues keyed rowId:colId; validity must
|
|
105
|
+
// read THAT so a cell clears/lights up live as the user types.
|
|
106
|
+
const col = makeColumn('salary', { validate: validateSalary })
|
|
107
|
+
|
|
108
|
+
const fixed = createCellRender(makeCtx({ editedCellValues: { 'r3:salary': 85000 } }))
|
|
109
|
+
expect(fixed.computeCellValidity(makeRow('r3', { salary: -5000 }), col)).toEqual({
|
|
110
|
+
invalid: false,
|
|
111
|
+
message: null,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const broken = createCellRender(makeCtx({ editedCellValues: { 'r1:salary': 5 } }))
|
|
115
|
+
expect(broken.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({
|
|
116
|
+
invalid: true,
|
|
117
|
+
message: 'Salary must be at least $1,000',
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
84
122
|
describe('createCellRender / computeRowClass', () => {
|
|
85
123
|
it('returns empty string when no rowClass configured', () => {
|
|
86
124
|
const cr = createCellRender(makeCtx())
|
package/src/cell-render.ts
CHANGED
|
@@ -201,6 +201,48 @@ export function createCellRender<
|
|
|
201
201
|
return null
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Resolve per-cell validity via the column's declarative `validate` hook.
|
|
206
|
+
* Runs for every rendered cell so pre-existing bad data is flagged on load.
|
|
207
|
+
* Returns `{ invalid, message }` - `invalid` drives the red highlight class,
|
|
208
|
+
* `message` (when present) becomes the cell's tooltip.
|
|
209
|
+
*/
|
|
210
|
+
function computeCellValidity(
|
|
211
|
+
row: Row<TData>,
|
|
212
|
+
column: Column<TData>,
|
|
213
|
+
): { invalid: boolean; message: string | null } {
|
|
214
|
+
const rule = column.columnDef.validate as
|
|
215
|
+
| ((params: {
|
|
216
|
+
value: unknown
|
|
217
|
+
row: TData
|
|
218
|
+
rowIndex: number
|
|
219
|
+
column: Column<TData>
|
|
220
|
+
}) => string | boolean | null | undefined)
|
|
221
|
+
| undefined
|
|
222
|
+
if (typeof rule !== "function") return { invalid: false, message: null }
|
|
223
|
+
// Read the edit-aware value: inline edits land in `editedCellValues`
|
|
224
|
+
// (keyed rowId:colId) and are NOT written into the row's memoized value
|
|
225
|
+
// cache, so `getCellValueByColumnId` would return the stale pre-edit value
|
|
226
|
+
// and validation would never re-run after an edit. Consult the overlay
|
|
227
|
+
// first - same rule the display path (getCellDisplayValue) and summaries
|
|
228
|
+
// use - so a cell re-validates live as the user types.
|
|
229
|
+
const edited = ctx.editedCellValues ?? {}
|
|
230
|
+
const key = getCellKey(row.id, column.id)
|
|
231
|
+
const value =
|
|
232
|
+
key in edited ? edited[key] : row.getCellValueByColumnId(column.id)
|
|
233
|
+
const out = rule({
|
|
234
|
+
value,
|
|
235
|
+
row: row.original as TData,
|
|
236
|
+
rowIndex: row.index,
|
|
237
|
+
column,
|
|
238
|
+
})
|
|
239
|
+
// Valid: null / undefined / true. Invalid: false or a message string.
|
|
240
|
+
if (out == null || out === true) return { invalid: false, message: null }
|
|
241
|
+
if (out === false) return { invalid: true, message: null }
|
|
242
|
+
const msg = String(out)
|
|
243
|
+
return { invalid: true, message: msg.trim() ? msg : null }
|
|
244
|
+
}
|
|
245
|
+
|
|
204
246
|
/**
|
|
205
247
|
* Resolve a per-cell note (a longer comment / annotation). Notes
|
|
206
248
|
* come from the grid's `notes` prop - a `{ [rowId]: { [columnId]: string } }`
|
|
@@ -416,6 +458,7 @@ export function createCellRender<
|
|
|
416
458
|
computeRowClass,
|
|
417
459
|
computeCellClass,
|
|
418
460
|
computeCellTooltip,
|
|
461
|
+
computeCellValidity,
|
|
419
462
|
computeCellNote,
|
|
420
463
|
getColumnEditorOptions,
|
|
421
464
|
formatListCellValue,
|
package/src/clipboard.test.ts
CHANGED
|
@@ -668,6 +668,24 @@ describe('clearSelectedCells', () => {
|
|
|
668
668
|
expect(ctx.grid.store.setState).toHaveBeenCalled()
|
|
669
669
|
})
|
|
670
670
|
|
|
671
|
+
it('fires onCellValueChange per cleared cell (so formula engines recompute)', () => {
|
|
672
|
+
const onCellValueChange = vi.fn()
|
|
673
|
+
const ctx = makeCtx({
|
|
674
|
+
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
|
|
675
|
+
data: [{ a: 'hello' }],
|
|
676
|
+
})
|
|
677
|
+
ctx.props.onCellValueChange = onCellValueChange
|
|
678
|
+
ctx.selectionRange = {
|
|
679
|
+
anchor: { rowIndex: 0, colIndex: 0 },
|
|
680
|
+
focus: { rowIndex: 0, colIndex: 0 },
|
|
681
|
+
}
|
|
682
|
+
const cb = createClipboard(ctx)
|
|
683
|
+
cb.clearSelectedCells()
|
|
684
|
+
expect(onCellValueChange).toHaveBeenCalledWith(
|
|
685
|
+
expect.objectContaining({ rowIndex: 0, columnId: 'a', oldValue: 'hello', newValue: '' }),
|
|
686
|
+
)
|
|
687
|
+
})
|
|
688
|
+
|
|
671
689
|
it('clears a number cell via parseEditorValue coercion', () => {
|
|
672
690
|
const ctx = makeCtx({
|
|
673
691
|
columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
|
package/src/clipboard.ts
CHANGED
|
@@ -488,6 +488,13 @@ export function createClipboard<
|
|
|
488
488
|
|
|
489
489
|
const next = ctx.internalData.slice() as Array<TData>;
|
|
490
490
|
let mutated = false;
|
|
491
|
+
const changes: Array<{
|
|
492
|
+
rowIndex: number
|
|
493
|
+
columnId: string
|
|
494
|
+
oldValue: unknown
|
|
495
|
+
newValue: unknown
|
|
496
|
+
row: TData
|
|
497
|
+
}> = [];
|
|
491
498
|
for (let r = startRow; r <= endRow; r += 1) {
|
|
492
499
|
const row = ctx.allRows[r];
|
|
493
500
|
if (!row || isGroupRow(row)) continue;
|
|
@@ -511,8 +518,18 @@ export function createClipboard<
|
|
|
511
518
|
// else. parseEditorValue handles the per-type coercion.
|
|
512
519
|
const editorType = (column.columnDef.editorType ??
|
|
513
520
|
"text") as CellEditorType;
|
|
514
|
-
|
|
521
|
+
const field = column.columnDef.field;
|
|
522
|
+
const oldValue = updated[field];
|
|
523
|
+
const cleared = parseEditorValue(editorType, "");
|
|
524
|
+
updated[field] = cleared;
|
|
515
525
|
rowChanged = true;
|
|
526
|
+
changes.push({
|
|
527
|
+
rowIndex: dataIndex,
|
|
528
|
+
columnId: column.id,
|
|
529
|
+
oldValue,
|
|
530
|
+
newValue: cleared,
|
|
531
|
+
row: updated as TData,
|
|
532
|
+
});
|
|
516
533
|
}
|
|
517
534
|
if (rowChanged) {
|
|
518
535
|
next[dataIndex] = updated as TData;
|
|
@@ -522,6 +539,12 @@ export function createClipboard<
|
|
|
522
539
|
if (mutated) {
|
|
523
540
|
ctx.internalData = next;
|
|
524
541
|
ctx.grid.store.setState((prev: any) => ({ ...prev }));
|
|
542
|
+
// Fire onCellValueChange per cleared cell - same as paste's writeCellRaw -
|
|
543
|
+
// so consumers (formula engines, autosave) recompute. Clear IS a value
|
|
544
|
+
// change; without this, a HyperFormula-backed grid wouldn't re-evaluate.
|
|
545
|
+
if (ctx.props.onCellValueChange) {
|
|
546
|
+
for (const ch of changes) ctx.props.onCellValueChange(ch);
|
|
547
|
+
}
|
|
525
548
|
}
|
|
526
549
|
return mutated;
|
|
527
550
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import {
|
|
3
|
+
broadcastChannelTransport,
|
|
3
4
|
createCollaboration,
|
|
4
5
|
type CollabMessage,
|
|
5
6
|
type CollabTransport,
|
|
@@ -37,6 +38,35 @@ describe('createCollaboration', () => {
|
|
|
37
38
|
b.dispose()
|
|
38
39
|
})
|
|
39
40
|
|
|
41
|
+
it('closes the transport channel on dispose (#82)', () => {
|
|
42
|
+
// The controller now forwards dispose to the transport...
|
|
43
|
+
const disposeSpy = vi.fn()
|
|
44
|
+
const collab = createCollaboration({
|
|
45
|
+
user: userA,
|
|
46
|
+
transport: { post: vi.fn(), subscribe: () => () => {}, dispose: disposeSpy },
|
|
47
|
+
})
|
|
48
|
+
collab.dispose()
|
|
49
|
+
expect(disposeSpy).toHaveBeenCalledTimes(1)
|
|
50
|
+
|
|
51
|
+
// ...and broadcastChannelTransport.dispose() closes the BroadcastChannel.
|
|
52
|
+
const close = vi.fn()
|
|
53
|
+
class FakeBroadcastChannel {
|
|
54
|
+
postMessage = vi.fn()
|
|
55
|
+
addEventListener = vi.fn()
|
|
56
|
+
removeEventListener = vi.fn()
|
|
57
|
+
close = close
|
|
58
|
+
constructor(public name: string) {}
|
|
59
|
+
}
|
|
60
|
+
const Orig = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel
|
|
61
|
+
;(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel
|
|
62
|
+
try {
|
|
63
|
+
broadcastChannelTransport('room').dispose?.()
|
|
64
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
65
|
+
} finally {
|
|
66
|
+
;(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = Orig
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
40
70
|
it('setCell propagates a remote cursor', () => {
|
|
41
71
|
const bus = makeBus()
|
|
42
72
|
const onPeers = vi.fn()
|
package/src/collaboration.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type CollabMessage =
|
|
|
32
32
|
export type CollabTransport = {
|
|
33
33
|
post(msg: CollabMessage): void
|
|
34
34
|
subscribe(handler: (msg: CollabMessage) => void): () => void
|
|
35
|
+
/** Release any underlying resource (e.g. a BroadcastChannel). Optional. */
|
|
36
|
+
dispose?(): void
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export type Collaboration = {
|
|
@@ -75,6 +77,9 @@ export function broadcastChannelTransport(name: string): CollabTransport {
|
|
|
75
77
|
channel.addEventListener('message', listener)
|
|
76
78
|
return () => channel.removeEventListener('message', listener)
|
|
77
79
|
},
|
|
80
|
+
dispose() {
|
|
81
|
+
channel?.close()
|
|
82
|
+
},
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -156,6 +161,7 @@ export function createCollaboration(options: CollaborationOptions): Collaboratio
|
|
|
156
161
|
transport.post({ kind: 'bye', userId: user.id })
|
|
157
162
|
unsubscribe()
|
|
158
163
|
if (pruneTimer) clearInterval(pruneTimer)
|
|
164
|
+
transport.dispose?.() // release the channel (BroadcastChannel etc.)
|
|
159
165
|
},
|
|
160
166
|
}
|
|
161
167
|
}
|
package/src/core.ts
CHANGED
|
@@ -221,9 +221,12 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
|
|
|
221
221
|
editorType?:
|
|
222
222
|
| 'text'
|
|
223
223
|
| 'number'
|
|
224
|
-
| 'date'
|
|
225
|
-
| 'datetime'
|
|
226
|
-
| 'time' //
|
|
224
|
+
| 'date' // rich SvCalendar popover (opt out with 'date-native')
|
|
225
|
+
| 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
|
|
226
|
+
| 'time' // rich SvTimePicker dial (opt out with 'time-native')
|
|
227
|
+
| 'date-native' // plain <input type="date">
|
|
228
|
+
| 'datetime-native' // plain <input type="datetime-local">
|
|
229
|
+
| 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
|
|
227
230
|
| 'password' // native <input type="password"> with masked rendering
|
|
228
231
|
| 'checkbox'
|
|
229
232
|
| 'list'
|
|
@@ -251,6 +254,27 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
|
|
|
251
254
|
* string skips the tooltip.
|
|
252
255
|
*/
|
|
253
256
|
tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
|
|
257
|
+
/**
|
|
258
|
+
* Declarative per-cell validation (Handsontable-style). Runs for EVERY
|
|
259
|
+
* rendered cell - including values already present in `data` on load, not
|
|
260
|
+
* just on edit - so bad data is flagged immediately. Invalid cells get the
|
|
261
|
+
* `sv-grid-cell-invalid` class (red highlight) and the returned message as
|
|
262
|
+
* their tooltip.
|
|
263
|
+
*
|
|
264
|
+
* Return value:
|
|
265
|
+
* - `null` / `undefined` / `true` → valid (no highlight)
|
|
266
|
+
* - `false` → invalid, no message
|
|
267
|
+
* - a non-empty `string` → invalid, string is the tooltip
|
|
268
|
+
*
|
|
269
|
+
* The value keeps rendering as-is (the grid does NOT roll it back); pair
|
|
270
|
+
* with `onCellValueChange` if you also want to reject the commit.
|
|
271
|
+
*/
|
|
272
|
+
validate?: (params: {
|
|
273
|
+
value: unknown
|
|
274
|
+
row: TData
|
|
275
|
+
rowIndex: number
|
|
276
|
+
column: Column<TData>
|
|
277
|
+
}) => string | boolean | null | undefined
|
|
254
278
|
/**
|
|
255
279
|
* Gate editing per column or per cell.
|
|
256
280
|
*
|