@svgrid/grid 2.6.21 → 2.6.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 (151) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +201 -18
  4. package/dist/SvAutoComplete.svelte +4 -0
  5. package/dist/SvComboBox.svelte +5 -1
  6. package/dist/SvDateRangeInput.svelte +25 -3
  7. package/dist/SvDropDownList.svelte +4 -0
  8. package/dist/SvForm.svelte +8 -8
  9. package/dist/SvGrid.controller.svelte.d.ts +61 -9
  10. package/dist/SvGrid.controller.svelte.js +363 -123
  11. package/dist/SvGrid.css +222 -1
  12. package/dist/SvGrid.helpers.d.ts +55 -0
  13. package/dist/SvGrid.helpers.js +83 -0
  14. package/dist/SvGrid.svelte +255 -56
  15. package/dist/SvGrid.types.d.ts +146 -1
  16. package/dist/SvGridCellEditor.svelte +99 -17
  17. package/dist/SvGridSelect.svelte +6 -2
  18. package/dist/SvGridSelect.svelte.d.ts +1 -1
  19. package/dist/SvListBox.svelte +8 -0
  20. package/dist/SvMultiSelect.svelte +6 -2
  21. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  22. package/dist/SvNumberInput.svelte +4 -0
  23. package/dist/SvRichCell.svelte +125 -0
  24. package/dist/SvRichCell.svelte.d.ts +15 -0
  25. package/dist/SvTextArea.svelte +5 -1
  26. package/dist/SvTreeSelect.svelte +6 -2
  27. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  28. package/dist/builtin-editors.d.ts +2 -2
  29. package/dist/builtin-editors.js +19 -3
  30. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  31. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  32. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  33. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  34. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  35. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  36. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  37. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  38. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  39. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  40. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  41. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  42. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  43. package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
  44. package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
  45. package/dist/cdn/svgrid.js +14 -10
  46. package/dist/cdn/svgrid.svelte-external.js +14 -10
  47. package/dist/cell-values.d.ts +16 -0
  48. package/dist/cell-values.js +28 -0
  49. package/dist/clipboard.d.ts +22 -0
  50. package/dist/clipboard.js +405 -27
  51. package/dist/column-groups.js +1 -1
  52. package/dist/column-resize.d.ts +46 -0
  53. package/dist/column-resize.js +205 -0
  54. package/dist/columns.d.ts +0 -3
  55. package/dist/columns.js +0 -57
  56. package/dist/core.d.ts +19 -4
  57. package/dist/core.js +460 -119
  58. package/dist/editing.js +12 -7
  59. package/dist/editors/cell-editors.d.ts +11 -0
  60. package/dist/editors/cell-editors.js +18 -1
  61. package/dist/filtering/excel-filters.js +28 -0
  62. package/dist/grid-messages.d.ts +15 -0
  63. package/dist/grid-messages.js +15 -0
  64. package/dist/group-display.d.ts +1 -1
  65. package/dist/index.d.ts +6 -1
  66. package/dist/index.js +10 -0
  67. package/dist/list-option.d.ts +6 -0
  68. package/dist/menus.js +41 -8
  69. package/dist/row-resize.d.ts +11 -0
  70. package/dist/row-resize.js +7 -1
  71. package/dist/sanitize-html.d.ts +37 -0
  72. package/dist/sanitize-html.js +234 -0
  73. package/dist/selection-bar-view.svelte.d.ts +25 -0
  74. package/dist/selection-bar-view.svelte.js +13 -0
  75. package/dist/selection.d.ts +2 -1
  76. package/dist/selection.js +133 -8
  77. package/dist/spreadsheet.d.ts +1 -1
  78. package/dist/spreadsheet.js +1 -1
  79. package/package.json +1 -1
  80. package/src/GridMenus.svelte +201 -18
  81. package/src/SvAutoComplete.svelte +4 -0
  82. package/src/SvComboBox.svelte +5 -1
  83. package/src/SvDateRangeInput.svelte +25 -3
  84. package/src/SvDropDownList.svelte +4 -0
  85. package/src/SvForm.svelte +8 -8
  86. package/src/SvGrid.controller.svelte.ts +404 -133
  87. package/src/SvGrid.css +222 -1
  88. package/src/SvGrid.helpers.ts +87 -0
  89. package/src/SvGrid.svelte +255 -56
  90. package/src/SvGrid.types.ts +153 -1
  91. package/src/SvGridCellEditor.svelte +99 -17
  92. package/src/SvGridSelect.svelte +6 -2
  93. package/src/SvListBox.svelte +8 -0
  94. package/src/SvMultiSelect.svelte +6 -2
  95. package/src/SvNumberInput.svelte +4 -0
  96. package/src/SvRichCell.svelte +125 -0
  97. package/src/SvTextArea.svelte +5 -1
  98. package/src/SvTreeSelect.svelte +6 -2
  99. package/src/builtin-editors.test.ts +18 -1
  100. package/src/builtin-editors.ts +19 -3
  101. package/src/cell-values.ts +30 -0
  102. package/src/clipboard.test.ts +93 -20
  103. package/src/clipboard.ts +433 -33
  104. package/src/column-groups.ts +1 -1
  105. package/src/column-resize.test.ts +381 -0
  106. package/src/column-resize.ts +227 -0
  107. package/src/columns.test.ts +0 -103
  108. package/src/columns.ts +0 -58
  109. package/src/core.aggregate.test.ts +134 -0
  110. package/src/core.filter.test.ts +156 -0
  111. package/src/core.grouping.test.ts +146 -0
  112. package/src/core.row-shape.test.ts +119 -0
  113. package/src/core.rowmodel-cache.test.ts +121 -0
  114. package/src/core.sort.test.ts +293 -0
  115. package/src/core.ts +516 -119
  116. package/src/date-string-column.test.ts +87 -0
  117. package/src/editing.test.ts +25 -0
  118. package/src/editing.ts +12 -9
  119. package/src/editor-block-prop.test.ts +132 -0
  120. package/src/editors/cell-editors.ts +27 -1
  121. package/src/filtering/excel-filters.ts +30 -0
  122. package/src/filtering/normalize-fast-path.test.ts +104 -0
  123. package/src/grid-messages.ts +34 -0
  124. package/src/group-display.ts +1 -1
  125. package/src/index.ts +25 -1
  126. package/src/list-option.ts +6 -0
  127. package/src/menus.test.ts +43 -0
  128. package/src/menus.ts +38 -8
  129. package/src/move-cells.test.ts +850 -0
  130. package/src/number-editor-literal.test.ts +84 -0
  131. package/src/number-editor.grid.test.ts +160 -0
  132. package/src/resize-props.test.ts +361 -0
  133. package/src/row-resize.test.ts +31 -0
  134. package/src/row-resize.ts +21 -3
  135. package/src/sanitize-html.test.ts +182 -0
  136. package/src/sanitize-html.ts +235 -0
  137. package/src/selection-bar-view.svelte.ts +36 -0
  138. package/src/selection.test.ts +157 -0
  139. package/src/selection.ts +135 -8
  140. package/src/spreadsheet.ts +1 -1
  141. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  142. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  143. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  144. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  145. package/src/svgrid.upsell-license.test.ts +117 -0
  146. package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
  147. package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
  148. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  149. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  150. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  151. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
3
+
4
+ /**
5
+ * The sanitizer is the one part of the rich-text cell that has to be right:
6
+ * everything else costs a rendering glitch, this costs script execution in the
7
+ * host page. The XSS cases below are the standard vectors a cell renderer gets
8
+ * hit with, so a regression here fails loudly.
9
+ */
10
+ describe('sanitizeHtml - script execution vectors', () => {
11
+ it('drops script tags and their contents', () => {
12
+ const out = sanitizeHtml('<p>hi</p><script>alert(1)</script>')
13
+ expect(out).toContain('hi')
14
+ expect(out).not.toContain('script')
15
+ expect(out).not.toContain('alert')
16
+ })
17
+
18
+ it('strips every inline event handler', () => {
19
+ for (const attr of ['onclick', 'onerror', 'onload', 'onmouseover', 'onfocus']) {
20
+ const out = sanitizeHtml(`<p ${attr}="alert(1)">x</p>`)
21
+ expect(out, attr).not.toContain(attr)
22
+ expect(out, attr).not.toContain('alert')
23
+ }
24
+ })
25
+
26
+ it('strips a javascript: href, including obfuscated forms', () => {
27
+ const vectors = [
28
+ 'javascript:alert(1)',
29
+ 'JaVaScRiPt:alert(1)',
30
+ ' javascript:alert(1)',
31
+ 'java\tscript:alert(1)',
32
+ 'java\nscript:alert(1)',
33
+ 'vbscript:msgbox(1)',
34
+ ]
35
+ for (const href of vectors) {
36
+ const out = sanitizeHtml(`<a href="${href}">x</a>`)
37
+ expect(out.toLowerCase(), href).not.toContain('javascript:')
38
+ expect(out.toLowerCase(), href).not.toContain('vbscript:')
39
+ expect(out, href).not.toContain('href')
40
+ }
41
+ })
42
+
43
+ it('drops an img with an onerror payload but keeps a safe one', () => {
44
+ expect(sanitizeHtml('<img src=x onerror="alert(1)">')).not.toContain('onerror')
45
+ const safe = sanitizeHtml('<img src="/logo.png" alt="Logo">')
46
+ expect(safe).toContain('src="/logo.png"')
47
+ expect(safe).toContain('alt="Logo"')
48
+ })
49
+
50
+ it('allows a base64 image data URL but not data:text/html', () => {
51
+ const png = 'data:image/png;base64,iVBORw0KGgo='
52
+ expect(sanitizeHtml(`<img src="${png}">`)).toContain('src=')
53
+ expect(sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdD4=">')).not.toContain('src=')
54
+ })
55
+
56
+ it('removes style attributes and style/iframe subtrees', () => {
57
+ expect(sanitizeHtml('<p style="position:fixed;top:0">x</p>')).not.toContain('style')
58
+ expect(sanitizeHtml('<style>body{display:none}</style><p>x</p>')).not.toContain('display')
59
+ expect(sanitizeHtml('<iframe src="https://evil.test"></iframe>')).not.toContain('iframe')
60
+ })
61
+
62
+ it('unwraps unknown tags but keeps their text', () => {
63
+ const out = sanitizeHtml('<marquee>keep me</marquee>')
64
+ expect(out).toContain('keep me')
65
+ expect(out).not.toContain('marquee')
66
+ })
67
+
68
+ it('adds rel=noopener to links that open a new tab', () => {
69
+ const out = sanitizeHtml('<a href="https://x.test" target="_blank">x</a>')
70
+ expect(out).toContain('rel="noopener noreferrer"')
71
+ })
72
+ })
73
+
74
+ describe('sanitizeHtml - preserving legitimate content', () => {
75
+ it('keeps the formatting tags SvRichText produces', () => {
76
+ const html = '<p><strong>b</strong> <em>i</em> <u>u</u> <s>s</s> <code>c</code></p><ul><li>one</li></ul>'
77
+ expect(sanitizeHtml(html)).toBe(html)
78
+ })
79
+
80
+ it('is idempotent', () => {
81
+ const once = sanitizeHtml('<p onclick="x()">hi <b>there</b></p>')
82
+ expect(sanitizeHtml(once)).toBe(once)
83
+ })
84
+
85
+ it('passes plain text through untouched and handles empty input', () => {
86
+ expect(sanitizeHtml('just text')).toBe('just text')
87
+ expect(sanitizeHtml('')).toBe('')
88
+ expect(sanitizeHtml(null)).toBe('')
89
+ expect(sanitizeHtml(undefined)).toBe('')
90
+ })
91
+ })
92
+
93
+ describe('renderMarkdown', () => {
94
+ it('renders headings, emphasis, code and lists', () => {
95
+ expect(renderMarkdown('# Title')).toBe('<h1>Title</h1>')
96
+ expect(renderMarkdown('**bold**')).toContain('<strong>bold</strong>')
97
+ expect(renderMarkdown('*it*')).toContain('<em>it</em>')
98
+ expect(renderMarkdown('~~gone~~')).toContain('<del>gone</del>')
99
+ expect(renderMarkdown('`code`')).toContain('<code>code</code>')
100
+ expect(renderMarkdown('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>')
101
+ expect(renderMarkdown('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>')
102
+ })
103
+
104
+ it('does not let emphasis leak into inline code', () => {
105
+ expect(renderMarkdown('`**not bold**`')).toContain('<code>**not bold**</code>')
106
+ })
107
+
108
+ it('escapes raw HTML in the source instead of passing it through', () => {
109
+ const out = renderMarkdown('<script>alert(1)</script>')
110
+ expect(out).not.toContain('<script>')
111
+ expect(out).toContain('&lt;script&gt;')
112
+ })
113
+
114
+ it('will not smuggle javascript: through a Markdown link', () => {
115
+ const out = renderMarkdown('[click](javascript:alert(1))')
116
+ expect(out).toContain('click')
117
+ expect(out).not.toContain('href')
118
+ })
119
+
120
+ it('handles empty input', () => {
121
+ expect(renderMarkdown('')).toBe('')
122
+ expect(renderMarkdown(null)).toBe('')
123
+ })
124
+ })
125
+
126
+ describe('htmlToText', () => {
127
+ it('flattens markup to a single readable line', () => {
128
+ expect(htmlToText('<p>one</p><p>two</p>')).toBe('one two')
129
+ expect(htmlToText('<ul><li>a</li><li>b</li></ul>')).toBe('a b')
130
+ expect(htmlToText('a<br>b')).toBe('a b')
131
+ })
132
+
133
+ it('decodes entities and drops script content', () => {
134
+ expect(htmlToText('<p>a &amp; b</p>')).toBe('a & b')
135
+ expect(htmlToText('<script>alert(1)</script><p>safe</p>')).toBe('safe')
136
+ })
137
+
138
+ it('handles empty input', () => {
139
+ expect(htmlToText('')).toBe('')
140
+ expect(htmlToText(null)).toBe('')
141
+ })
142
+ })
143
+
144
+ describe('sanitizeHtml - SSR path (no DOMParser)', () => {
145
+ // During SSR there is no DOM, so a second, regex-based implementation runs.
146
+ // It is stricter than the DOM path, and it must not be the weak link: the
147
+ // server-rendered HTML reaches the browser before hydration re-sanitizes.
148
+ const withoutDom = (html: string): string => {
149
+ const real = globalThis.DOMParser
150
+ // @ts-expect-error - deleting a global for the duration of the assertion
151
+ delete globalThis.DOMParser
152
+ try {
153
+ return sanitizeHtml(html)
154
+ } finally {
155
+ globalThis.DOMParser = real
156
+ }
157
+ }
158
+
159
+ it('drops scripts, styles and iframes with their contents', () => {
160
+ expect(withoutDom('<p>hi</p><script>alert(1)</script>')).toBe('<p>hi</p>')
161
+ expect(withoutDom('<style>body{x:y}</style><p>hi</p>')).toBe('<p>hi</p>')
162
+ expect(withoutDom('<iframe src="https://evil.test"></iframe>ok')).toBe('ok')
163
+ })
164
+
165
+ it('strips every attribute, so no handler or URL survives', () => {
166
+ expect(withoutDom('<p onclick="alert(1)">x</p>')).toBe('<p>x</p>')
167
+ expect(withoutDom('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>')
168
+ // The tag itself may stay, but with no src and no handler it is inert.
169
+ const img = withoutDom('<img src=x onerror="alert(1)">')
170
+ expect(img).toBe('<img>')
171
+ expect(img).not.toContain('onerror')
172
+ expect(img).not.toContain('src')
173
+ })
174
+
175
+ it('keeps plain allowlisted formatting', () => {
176
+ expect(withoutDom('<p><strong>b</strong> <em>i</em></p>')).toBe('<p><strong>b</strong> <em>i</em></p>')
177
+ })
178
+
179
+ it('removes unknown tags entirely', () => {
180
+ expect(withoutDom('<marquee>text</marquee>')).toBe('text')
181
+ })
182
+ })
@@ -0,0 +1,235 @@
1
+ /**
2
+ * sanitize-html - a dependency-free HTML sanitizer and a small Markdown
3
+ * renderer, sized for what a grid cell needs to display.
4
+ *
5
+ * A rich-text cell has to paint markup that came from a user, which is the one
6
+ * place a data grid can hand an attacker script execution. Everything rendered
7
+ * through `SvRichCell` goes through `sanitizeHtml` first, and the allowlist is
8
+ * deliberately narrower than a general-purpose sanitizer: it covers the tags
9
+ * `SvRichText` can produce, plus links and images.
10
+ *
11
+ * The parse uses the platform parser where there is one (`DOMParser`, which
12
+ * neither executes scripts nor runs loaders on a detached document) and a
13
+ * conservative regex stripper during SSR, where there is no DOM. The SSR path
14
+ * is stricter by design: when in doubt it drops markup rather than passing it
15
+ * through, and the hydrated client re-sanitizes with the real parser anyway.
16
+ *
17
+ * This is not a general-purpose sanitizer for arbitrary third-party HTML. It is
18
+ * a narrow allowlist for cell content. To render something outside the
19
+ * allowlist, extend it explicitly rather than widening the defaults.
20
+ */
21
+
22
+ /** Tags that survive sanitization. Everything else is unwrapped or dropped. */
23
+ const ALLOWED_TAGS = new Set([
24
+ 'a', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em', 'h1', 'h2', 'h3',
25
+ 'h4', 'h5', 'h6', 'i', 'img', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small',
26
+ 'span', 'strong', 'sub', 'sup', 'u', 'ul',
27
+ ])
28
+
29
+ /** Tags whose whole subtree is removed rather than unwrapped. What sits inside
30
+ * a <script> or <style> is code, so keeping its text would be worse than
31
+ * losing it. */
32
+ const DROP_SUBTREE = new Set([
33
+ 'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template',
34
+ 'svg', 'math', 'form', 'input', 'button', 'textarea', 'select', 'link',
35
+ 'meta', 'base', 'title', 'head',
36
+ ])
37
+
38
+ /** Attributes allowed per tag. `*` applies to every allowed tag. A `style`
39
+ * attribute is never allowed: it is the classic sanitizer hole (url(...)
40
+ * payloads, position tricks that let a cell cover the page chrome), and cell
41
+ * styling belongs to the column rather than to the data. */
42
+ const ALLOWED_ATTRS: Record<string, Set<string>> = {
43
+ '*': new Set(['class', 'dir', 'lang', 'title']),
44
+ a: new Set(['href', 'target', 'rel']),
45
+ img: new Set(['src', 'alt', 'width', 'height']),
46
+ ol: new Set(['start']),
47
+ }
48
+
49
+ /** URL schemes a href/src may use. Anything else, `javascript:` and
50
+ * `vbscript:` included, is dropped. `data:` is allowed only on an image src
51
+ * and only for image media types, because `data:text/html` is a script
52
+ * vector. */
53
+ const SAFE_URL = /^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i
54
+ const SAFE_IMG_DATA_URL = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]*$/i
55
+
56
+ function isSafeUrl(value: string, tag: string, attr: string): boolean {
57
+ // Strip control characters and whitespace first: "java\tscript:" and
58
+ // "java\nscript:" are both read as javascript: by browsers.
59
+ // Matching control characters is the whole point here, hence the disable.
60
+ // eslint-disable-next-line no-control-regex
61
+ const url = value.replace(/[\u0000-\u0020\u007f]/g, '')
62
+ if (tag === 'img' && attr === 'src' && SAFE_IMG_DATA_URL.test(url)) return true
63
+ return SAFE_URL.test(url)
64
+ }
65
+
66
+ function attrAllowed(tag: string, attr: string): boolean {
67
+ if (attr.startsWith('on')) return false // every event handler, in one rule
68
+ if (ALLOWED_ATTRS['*']!.has(attr)) return true
69
+ return ALLOWED_ATTRS[tag]?.has(attr) ?? false
70
+ }
71
+
72
+ /** DOM path: walk the parsed tree and strip anything off the allowlist. */
73
+ function sanitizeWithDom(html: string): string {
74
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
75
+ const walk = (node: Element): void => {
76
+ // Snapshot the child list: the loop mutates it as it unwraps and removes.
77
+ for (const child of [...node.children]) {
78
+ const tag = child.tagName.toLowerCase()
79
+ if (DROP_SUBTREE.has(tag)) {
80
+ child.remove()
81
+ continue
82
+ }
83
+ if (!ALLOWED_TAGS.has(tag)) {
84
+ // Unwrap rather than drop, so an unknown wrapper does not cost the user
85
+ // the text inside it.
86
+ walk(child)
87
+ child.replaceWith(...child.childNodes)
88
+ continue
89
+ }
90
+ for (const attr of [...child.attributes]) {
91
+ const name = attr.name.toLowerCase()
92
+ if (!attrAllowed(tag, name)) {
93
+ child.removeAttribute(attr.name)
94
+ continue
95
+ }
96
+ if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value, tag, name)) {
97
+ child.removeAttribute(attr.name)
98
+ }
99
+ }
100
+ // A link that opens a new tab gets rel="noopener", so the opened page
101
+ // cannot reach back through window.opener.
102
+ if (tag === 'a' && child.getAttribute('target')) {
103
+ child.setAttribute('rel', 'noopener noreferrer')
104
+ }
105
+ walk(child)
106
+ }
107
+ }
108
+ walk(doc.body)
109
+ return doc.body.innerHTML
110
+ }
111
+
112
+ /**
113
+ * SSR path: no DOM, so drop dangerous subtrees outright and keep only
114
+ * allowlisted tags in their attribute-free form. Stricter than the DOM path on
115
+ * purpose - the client re-sanitizes on hydration, so the cost is a moment of
116
+ * plainer markup rather than a hole.
117
+ */
118
+ function sanitizeWithoutDom(html: string): string {
119
+ let out = html
120
+ for (const tag of DROP_SUBTREE) {
121
+ out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}\\s*>`, 'gi'), '')
122
+ out = out.replace(new RegExp(`<${tag}\\b[^>]*/?>`, 'gi'), '')
123
+ }
124
+ return out.replace(/<\/?([a-z0-9-]+)([^>]*)>/gi, (match, rawTag: string, attrs: string) => {
125
+ const tag = rawTag.toLowerCase()
126
+ if (!ALLOWED_TAGS.has(tag)) return ''
127
+ if (match.startsWith('</')) return `</${tag}>`
128
+ return attrs.trim() ? `<${tag}>` : match
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Return `html` with everything outside the allowlist removed. Idempotent, so
134
+ * it is safe to call on already-sanitized markup.
135
+ */
136
+ export function sanitizeHtml(html: string | null | undefined): string {
137
+ const input = String(html ?? '')
138
+ if (!input) return ''
139
+ if (!input.includes('<')) return input // no angle bracket, no markup to strip
140
+ return typeof DOMParser === 'undefined' ? sanitizeWithoutDom(input) : sanitizeWithDom(input)
141
+ }
142
+
143
+ /** Escape text so it embeds in HTML as literal characters. */
144
+ function escapeHtml(text: string): string {
145
+ return text
146
+ .replace(/&/g, '&amp;')
147
+ .replace(/</g, '&lt;')
148
+ .replace(/>/g, '&gt;')
149
+ .replace(/"/g, '&quot;')
150
+ }
151
+
152
+ /** A sentinel that cannot occur in escaped input, used to park inline-code
153
+ * spans while the emphasis and link passes run over the rest. */
154
+ const MARK = '\u0000'
155
+
156
+ /** Inline Markdown. Code is extracted first so backticks protect their
157
+ * contents from the emphasis and link passes. */
158
+ function renderInline(text: string): string {
159
+ const code: string[] = []
160
+ let out = escapeHtml(text).replace(/`([^`]+)`/g, (_m, c: string) => {
161
+ code.push(c)
162
+ return `${MARK}${code.length - 1}${MARK}`
163
+ })
164
+ out = out
165
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
166
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>')
167
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
168
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
169
+ .replace(/~~([^~]+)~~/g, '<del>$1</del>')
170
+ return out.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_m, i: string) => `<code>${code[Number(i)]}</code>`)
171
+ }
172
+
173
+ /**
174
+ * Render a common Markdown subset to HTML: headings, bold, italic,
175
+ * strikethrough, inline code, links, images, and bullet / ordered lists.
176
+ *
177
+ * Deliberately not a full CommonMark implementation - a grid cell shows a line
178
+ * or two, and a complete parser is a dependency this package does not take. The
179
+ * output is sanitized, so a Markdown link cannot smuggle `javascript:`.
180
+ */
181
+ export function renderMarkdown(markdown: string | null | undefined): string {
182
+ const src = String(markdown ?? '')
183
+ if (!src) return ''
184
+ const out: string[] = []
185
+ let list: 'ul' | 'ol' | null = null
186
+ const closeList = () => {
187
+ if (list) out.push(`</${list}>`)
188
+ list = null
189
+ }
190
+ for (const line of src.split(/\r?\n/)) {
191
+ const heading = line.match(/^(#{1,6})\s+(.*)$/)
192
+ const bullet = line.match(/^\s*[-*+]\s+(.*)$/)
193
+ const ordered = line.match(/^\s*\d+[.)]\s+(.*)$/)
194
+ if (heading) {
195
+ closeList()
196
+ const level = heading[1]!.length
197
+ out.push(`<h${level}>${renderInline(heading[2]!)}</h${level}>`)
198
+ } else if (bullet || ordered) {
199
+ const want = bullet ? 'ul' : 'ol'
200
+ if (list !== want) {
201
+ closeList()
202
+ out.push(`<${want}>`)
203
+ list = want
204
+ }
205
+ out.push(`<li>${renderInline((bullet ?? ordered)![1]!)}</li>`)
206
+ } else if (!line.trim()) {
207
+ closeList()
208
+ } else {
209
+ closeList()
210
+ out.push(`<p>${renderInline(line)}</p>`)
211
+ }
212
+ }
213
+ closeList()
214
+ return sanitizeHtml(out.join(''))
215
+ }
216
+
217
+ /** Strip markup down to readable text, for exports, aria labels and tooltips
218
+ * where a cell's HTML has to collapse to one plain string. */
219
+ export function htmlToText(html: string | null | undefined): string {
220
+ const clean = sanitizeHtml(html)
221
+ if (!clean) return ''
222
+ const spaced = clean
223
+ .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, ' ')
224
+ .replace(/<br\s*\/?>/gi, ' ')
225
+ return spaced
226
+ .replace(/<[^>]*>/g, '')
227
+ .replace(/&nbsp;/gi, ' ')
228
+ .replace(/&lt;/gi, '<')
229
+ .replace(/&gt;/gi, '>')
230
+ .replace(/&quot;/gi, '"')
231
+ .replace(/&#39;/gi, "'")
232
+ .replace(/&amp;/gi, '&')
233
+ .replace(/\s+/g, ' ')
234
+ .trim()
235
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * selection-bar-view registry - the injection seam for the bulk-action bar that
3
+ * floats over the grid while rows are selected. `<SvGrid selectionBar={...}>`
4
+ * and its config types ship in the free grid, but the BAR itself is a paid
5
+ * feature in `@svgrid/enterprise`. This tiny reactive holder lets enterprise
6
+ * register the renderer at import time; the grid looks it up and mounts it, or
7
+ * shows an upsell placeholder when it is absent.
8
+ *
9
+ * Same shape as `scheduler-view` and `board-view`, deliberately: three seams
10
+ * that behave differently would be three things to learn.
11
+ *
12
+ * ```ts
13
+ * // in @svgrid/enterprise, at module load:
14
+ * import { registerSelectionBarView } from '@svgrid/grid'
15
+ * import SvGridSelectionBar from './SvGridSelectionBar.svelte'
16
+ * registerSelectionBarView(SvGridSelectionBar)
17
+ * ```
18
+ */
19
+ import type { Component } from 'svelte'
20
+
21
+ let renderer = $state<Component<any> | null>(null)
22
+
23
+ /** Register the component that renders the selection bar. Enterprise calls this. */
24
+ export function registerSelectionBarView(component: Component<any>): void {
25
+ renderer = component
26
+ }
27
+
28
+ /** The registered selection-bar renderer, or null when enterprise is not installed. */
29
+ export function getSelectionBarView(): Component<any> | null {
30
+ return renderer
31
+ }
32
+
33
+ /** Whether a selection-bar renderer has been registered. */
34
+ export function hasSelectionBarView(): boolean {
35
+ return renderer != null
36
+ }
@@ -44,6 +44,9 @@ function makeCtx(overrides: any = {}) {
44
44
  selectionRange: { anchor: null, focus: null },
45
45
  isDraggingSelection: false,
46
46
  fillDrag: null,
47
+ moveDrag: null,
48
+ moveGrabHover: false,
49
+ moveCellsEffective: false,
47
50
  activeAtPointerDown: null,
48
51
  editingEnabled: false,
49
52
  editingCell: undefined,
@@ -61,6 +64,17 @@ function makeCtx(overrides: any = {}) {
61
64
  onCellDoubleClick: vi.fn(),
62
65
  onFillPointerUp: vi.fn(),
63
66
  onFillPointerMove: vi.fn(),
67
+ // Range drag-and-drop. `startMoveDrag` returning false is the "the pointer
68
+ // was not on the border" answer, which is what every test below assumes -
69
+ // override it to true to exercise the grab path.
70
+ startMoveDrag: vi.fn(() => false),
71
+ onMovePointerUp: vi.fn(),
72
+ onMovePointerMove: vi.fn(),
73
+ isOnMoveGrabStrip: vi.fn(() => false),
74
+ // Edge auto-scroll: one rAF loop in the clipboard module, driven from here
75
+ // for plain drag-select. Its own behaviour is covered in move-cells.test.ts.
76
+ trackEdgeScroll: vi.fn(),
77
+ stopEdgeScroll: vi.fn(),
64
78
  grid: {
65
79
  setRowSelection,
66
80
  setActiveCell,
@@ -428,6 +442,149 @@ describe('createSelection - endDragSelection / window move', () => {
428
442
  sel.onWindowPointerMove({ buttons: 1 } as any)
429
443
  expect(ctx.isDraggingSelection).toBe(true)
430
444
  })
445
+
446
+ it('endDragSelection cancels the edge-scroll loop', () => {
447
+ // The loop exits on its own once no drag is live, but cancelling the
448
+ // pending frame here stops one more from firing after the gesture ended.
449
+ const ctx = makeCtx({ isDraggingSelection: true })
450
+ const sel = createSelection(ctx)
451
+ sel.endDragSelection()
452
+ expect(ctx.stopEdgeScroll).toHaveBeenCalled()
453
+ })
454
+
455
+ it('feeds the edge-scroll loop while drag-selecting', () => {
456
+ // Drag-select had the same ceiling as the other two drags: a range could
457
+ // only ever cover cells that were on screen when it started.
458
+ const ctx = makeCtx({ isDraggingSelection: true })
459
+ const sel = createSelection(ctx)
460
+ const ev = { buttons: 1, clientX: 10, clientY: 10 } as any
461
+ sel.onWindowPointerMove(ev)
462
+ expect(ctx.trackEdgeScroll).toHaveBeenCalledWith(ev)
463
+ })
464
+
465
+ it('does not feed the loop once the button is released', () => {
466
+ const ctx = makeCtx({ isDraggingSelection: true })
467
+ const sel = createSelection(ctx)
468
+ sel.onWindowPointerMove({ buttons: 0 } as any)
469
+ expect(ctx.trackEdgeScroll).not.toHaveBeenCalled()
470
+ })
471
+
472
+ it('endDragSelection commits a pending range move', () => {
473
+ const ctx = makeCtx({ moveDrag: { x: 1 } })
474
+ const sel = createSelection(ctx)
475
+ sel.endDragSelection()
476
+ expect(ctx.onMovePointerUp).toHaveBeenCalled()
477
+ })
478
+
479
+ it('onWindowPointerMove delegates to the range move when one is in flight', () => {
480
+ // A move outranks a fill: they can never both be active, and checking it
481
+ // first keeps the fill path from swallowing the event.
482
+ const ctx = makeCtx({ moveDrag: { x: 1 }, fillDrag: { x: 1 } })
483
+ const sel = createSelection(ctx)
484
+ const ev = { buttons: 1 } as any
485
+ sel.onWindowPointerMove(ev)
486
+ expect(ctx.onMovePointerMove).toHaveBeenCalledWith(ev)
487
+ expect(ctx.onFillPointerMove).not.toHaveBeenCalled()
488
+ })
489
+ })
490
+
491
+ describe('createSelection - range move grab', () => {
492
+ /** A pointer event over a td that reports itself from `closest()`. */
493
+ function overCell() {
494
+ const cell: any = { dataset: { svgridRow: '1', svgridCol: '1' } }
495
+ cell.closest = () => cell
496
+ return { button: 0, clientX: 10, clientY: 10, target: cell, pointerType: 'mouse' } as any
497
+ }
498
+
499
+ it('hands the pointerdown to the move grab before touching the selection', () => {
500
+ const ctx = makeCtx({
501
+ allRows: [{ id: 'r0' }, { id: 'r1' }],
502
+ allColumns: [{ id: 'a' }, { id: 'b' }],
503
+ startMoveDrag: vi.fn(() => true),
504
+ })
505
+ const sel = createSelection(ctx)
506
+ sel.onCellPointerDown(1, 1, overCell())
507
+ expect(ctx.startMoveDrag).toHaveBeenCalled()
508
+ // The selection the user grabbed is still intact, and no drag-select began.
509
+ expect(ctx.selectionRange).toEqual({ anchor: null, focus: null })
510
+ expect(ctx.isDraggingSelection).toBe(false)
511
+ })
512
+
513
+ it('swallows the click that follows a grab, so the range survives', () => {
514
+ const onCellClick = vi.fn()
515
+ const ctx = makeCtx({
516
+ allRows: [makeDataRow('r0', { id: 0 }), makeDataRow('r1', { id: 1 })],
517
+ allColumns: [makeColumn('c0', { editorType: 'text' }), makeColumn('c1', { editorType: 'text' })],
518
+ gridRootEl: { focus: vi.fn() },
519
+ props: { onCellClick },
520
+ startMoveDrag: vi.fn(() => true),
521
+ })
522
+ const sel = createSelection(ctx)
523
+ sel.onCellPointerDown(1, 1, overCell())
524
+ sel.onCellClick(1, 1)
525
+ expect(onCellClick).not.toHaveBeenCalled()
526
+ // Only the one click is swallowed - the next behaves normally.
527
+ sel.onCellClick(1, 1)
528
+ expect(onCellClick).toHaveBeenCalledTimes(1)
529
+ })
530
+
531
+ it('falls through to normal selection when the pointer misses the border', () => {
532
+ const ctx = makeCtx({
533
+ allRows: [{ id: 'r0' }, { id: 'r1' }],
534
+ allColumns: [{ id: 'a' }, { id: 'b' }],
535
+ })
536
+ const sel = createSelection(ctx)
537
+ sel.onCellPointerDown(1, 1, overCell())
538
+ expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 1, colIndex: 1 })
539
+ expect(ctx.isDraggingSelection).toBe(true)
540
+ })
541
+
542
+ it('updateMoveGrabHover leaves the flag alone while moveCells is off', () => {
543
+ const ctx = makeCtx({ moveCellsEffective: false })
544
+ const sel = createSelection(ctx)
545
+ sel.onWindowPointerMove(overCell())
546
+ expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
547
+ expect(ctx.moveGrabHover).toBe(false)
548
+ })
549
+
550
+ it('raises the hover flag on the border and drops it off it', () => {
551
+ const ctx = makeCtx({
552
+ moveCellsEffective: true,
553
+ // Without an anchored range the hover check bails before touching the
554
+ // DOM - correctly, since there is then no border to grab.
555
+ selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
556
+ isOnMoveGrabStrip: vi.fn(() => true),
557
+ })
558
+ const sel = createSelection(ctx)
559
+ sel.onWindowPointerMove(overCell())
560
+ expect(ctx.moveGrabHover).toBe(true)
561
+ ctx.isOnMoveGrabStrip = vi.fn(() => false)
562
+ sel.onWindowPointerMove(overCell())
563
+ expect(ctx.moveGrabHover).toBe(false)
564
+ })
565
+
566
+ it('never walks the DOM while nothing is selected', () => {
567
+ // This runs on every window pointermove, so the guard order matters:
568
+ // two property reads must come before any closest() call.
569
+ const closest = vi.fn(() => null)
570
+ const ctx = makeCtx({ moveCellsEffective: true, isOnMoveGrabStrip: vi.fn(() => true) })
571
+ const sel = createSelection(ctx)
572
+ sel.onWindowPointerMove({ target: { closest }, buttons: 0 } as any)
573
+ expect(closest).not.toHaveBeenCalled()
574
+ expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
575
+ })
576
+
577
+ it('drops the hover flag when the pointer leaves the grid entirely', () => {
578
+ const ctx = makeCtx({
579
+ moveCellsEffective: true,
580
+ moveGrabHover: true,
581
+ selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
582
+ isOnMoveGrabStrip: vi.fn(() => true),
583
+ })
584
+ const sel = createSelection(ctx)
585
+ sel.onWindowPointerMove({ target: { closest: () => null }, buttons: 0 } as any)
586
+ expect(ctx.moveGrabHover).toBe(false)
587
+ })
431
588
  })
432
589
 
433
590
  describe('createSelection - onCellClick', () => {