@svgrid/grid 2.2.30 → 2.2.32

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 (145) hide show
  1. package/README.md +1 -1
  2. package/dist/SvAvatar.svelte +1 -1
  3. package/dist/SvCheckBox.svelte +2 -1
  4. package/dist/SvCircularProgress.svelte +1 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +20 -0
  6. package/dist/SvGrid.controller.svelte.js +358 -18
  7. package/dist/SvGrid.css +103 -3
  8. package/dist/SvGrid.svelte +162 -10
  9. package/dist/SvGrid.types.d.ts +81 -0
  10. package/dist/SvGridDropdown.svelte +38 -6
  11. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  12. package/dist/SvGridSelect.svelte +12 -3
  13. package/dist/SvGridSelect.svelte.d.ts +6 -0
  14. package/dist/SvGroupCell.svelte +9 -2
  15. package/dist/SvMultiSelect.svelte +12 -3
  16. package/dist/SvMultiSelect.svelte.d.ts +6 -0
  17. package/dist/SvPasswordInput.svelte +1 -1
  18. package/dist/SvProgress.svelte +1 -1
  19. package/dist/SvRating.svelte +1 -1
  20. package/dist/SvResult.svelte +1 -1
  21. package/dist/SvRichText.svelte +31 -16
  22. package/dist/SvRichText.svelte.d.ts +9 -0
  23. package/dist/SvSwitchButton.svelte +2 -1
  24. package/dist/SvToaster.svelte +1 -1
  25. package/dist/SvTree.svelte +9 -2
  26. package/dist/SvTree.svelte.d.ts +5 -0
  27. package/dist/SvTreeSelect.svelte +6 -2
  28. package/dist/build-api.js +16 -0
  29. package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
  30. package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
  31. package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
  32. package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
  33. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
  34. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
  35. package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
  36. package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
  37. package/dist/cdn/svgrid.js +8 -8
  38. package/dist/cdn/svgrid.svelte-external.js +8 -8
  39. package/dist/cell-render.d.ts +1 -0
  40. package/dist/cell-render.js +115 -1
  41. package/dist/cell-values.js +5 -0
  42. package/dist/core.d.ts +80 -9
  43. package/dist/core.js +89 -0
  44. package/dist/editor-contract.d.ts +12 -5
  45. package/dist/editor-registry.d.ts +18 -7
  46. package/dist/editor-registry.js +10 -1
  47. package/dist/group-display.d.ts +39 -0
  48. package/dist/group-display.js +52 -0
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.js +2 -2
  51. package/dist/keyboard-handlers.js +16 -0
  52. package/dist/recurrence.js +3 -2
  53. package/dist/svgrid-wrapper.types.d.ts +8 -2
  54. package/dist/themes/index.d.ts +10 -0
  55. package/dist/themes/index.js +26 -0
  56. package/package.json +1 -1
  57. package/src/SvAccordion.test.ts +5 -5
  58. package/src/SvAvatar.svelte +1 -1
  59. package/src/SvButtonGroup.test.ts +6 -6
  60. package/src/SvCheckBox.svelte +2 -1
  61. package/src/SvCircularProgress.svelte +1 -1
  62. package/src/SvFileUpload.test.ts +1 -1
  63. package/src/SvGrid.controller.svelte.ts +367 -17
  64. package/src/SvGrid.css +103 -3
  65. package/src/SvGrid.svelte +162 -10
  66. package/src/SvGrid.types.ts +81 -0
  67. package/src/SvGridDropdown.svelte +38 -6
  68. package/src/SvGridSelect.svelte +12 -3
  69. package/src/SvGroupCell.svelte +9 -2
  70. package/src/SvMultiSelect.svelte +12 -3
  71. package/src/SvPasswordInput.svelte +1 -1
  72. package/src/SvProgress.svelte +1 -1
  73. package/src/SvRating.svelte +1 -1
  74. package/src/SvResult.svelte +1 -1
  75. package/src/SvRichText.svelte +31 -16
  76. package/src/SvSwitchButton.svelte +2 -1
  77. package/src/SvToaster.svelte +1 -1
  78. package/src/SvTree.svelte +9 -2
  79. package/src/SvTreeSelect.svelte +6 -2
  80. package/src/build-api.ts +15 -0
  81. package/src/builtin-editors.test.ts +3 -1
  82. package/src/cell-render.test.ts +27 -3
  83. package/src/cell-render.ts +122 -1
  84. package/src/cell-values.ts +4 -0
  85. package/src/chart-export.test.ts +2 -2
  86. package/src/clipboard.test.ts +0 -2
  87. package/src/core.ts +157 -8
  88. package/src/editing.test.ts +1 -1
  89. package/src/editor-contract.ts +12 -5
  90. package/src/editor-registry.grid.test.ts +48 -0
  91. package/src/editor-registry.test.ts +3 -1
  92. package/src/editor-registry.ts +23 -7
  93. package/src/facet-buckets.test.ts +33 -33
  94. package/src/group-display.test.ts +91 -1
  95. package/src/group-display.ts +83 -0
  96. package/src/index.ts +9 -0
  97. package/src/keyboard-handlers.ts +17 -0
  98. package/src/list-power.test.ts +2 -2
  99. package/src/menus.test.ts +4 -4
  100. package/src/named-views.coverage.test.ts +3 -3
  101. package/src/recurrence.ts +3 -2
  102. package/src/row-resize.test.ts +40 -40
  103. package/src/scheduler-model.ts +1 -1
  104. package/src/server-data-source.coverage.test.ts +1 -1
  105. package/src/server-group-model.test.ts +6 -6
  106. package/src/spreadsheet.test.ts +0 -1
  107. package/src/svgrid-wrapper.types.ts +11 -9
  108. package/src/svgrid.async-editor-options.test.ts +271 -0
  109. package/src/svgrid.auto-row-height.test.ts +204 -0
  110. package/src/svgrid.charting.test.ts +4 -4
  111. package/src/svgrid.comments-autocomplete.test.ts +8 -2
  112. package/src/svgrid.context-menu.test.ts +25 -9
  113. package/src/svgrid.filter-depth.test.ts +163 -0
  114. package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
  115. package/src/svgrid.filter-menu-scroll.test.ts +10 -2
  116. package/src/svgrid.grand-total.test.ts +188 -0
  117. package/src/svgrid.group-display-mode.test.ts +171 -0
  118. package/src/svgrid.group-footers.test.ts +121 -0
  119. package/src/svgrid.group-pagination.test.ts +153 -0
  120. package/src/svgrid.tree-data.test.ts +186 -0
  121. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
  122. package/src/themes/index.ts +42 -0
  123. package/src/tree-edit.test.ts +4 -4
  124. package/src/tree-row-model.test.ts +169 -0
  125. package/src/ui-localization.test.ts +113 -0
  126. package/themes/ag-alpine.css +22 -0
  127. package/themes/antd.css +22 -0
  128. package/themes/atlassian.css +22 -0
  129. package/themes/bootstrap.css +22 -0
  130. package/themes/carbon.css +22 -0
  131. package/themes/catppuccin.css +22 -0
  132. package/themes/dracula.css +22 -0
  133. package/themes/ember.css +22 -0
  134. package/themes/excel.css +22 -0
  135. package/themes/fluent.css +22 -0
  136. package/themes/github.css +22 -0
  137. package/themes/linear.css +22 -0
  138. package/themes/material.css +22 -0
  139. package/themes/nord.css +22 -0
  140. package/themes/notion.css +22 -0
  141. package/themes/salesforce.css +22 -0
  142. package/themes/sap.css +22 -0
  143. package/themes/shadcn.css +22 -0
  144. package/themes/tailwind.css +22 -0
  145. package/themes/vercel.css +22 -0
@@ -38,6 +38,12 @@
38
38
  loadOptions?: (query: string) => Promise<Row[]>
39
39
  debounceMs?: number
40
40
  loadingText?: string
41
+ /** Shown when the list is empty / a search matches nothing (localization). */
42
+ emptyText?: string
43
+ /** Placeholder in the panel's search box (localization). */
44
+ searchPlaceholder?: string
45
+ /** Accessible name for the panel's search box (localization). */
46
+ searchLabel?: string
41
47
  }
42
48
 
43
49
  let {
@@ -54,6 +60,9 @@
54
60
  loadOptions,
55
61
  debounceMs = 250,
56
62
  loadingText = 'Loading…',
63
+ emptyText = 'No matches',
64
+ searchPlaceholder = 'Search…',
65
+ searchLabel = 'Search options',
57
66
  disabled = false,
58
67
  readonly = false,
59
68
  label,
@@ -168,8 +177,8 @@
168
177
  aria-expanded="true"
169
178
  aria-controls={gs.panelId}
170
179
  aria-activedescendant={gs.activeDescendant()}
171
- aria-label="Search options"
172
- placeholder="Search…"
180
+ aria-label={searchLabel}
181
+ placeholder={searchPlaceholder}
173
182
  value={search}
174
183
  oninput={(e) => { search = e.currentTarget.value; gs.active = 0; if (loadOptions) runLoad(search) }}
175
184
  />
@@ -205,7 +214,7 @@
205
214
  {/each}
206
215
  </div>
207
216
  {:else}
208
- <div class="sv-gs__empty" role="row">{loading ? loadingText : 'No matches'}</div>
217
+ <div class="sv-gs__empty" role="row">{loading ? loadingText : emptyText}</div>
209
218
  {/each}
210
219
  {#if loading && filtered.length}
211
220
  <div class="sv-gs__empty" role="row" aria-live="polite">{loadingText}</div>
@@ -39,7 +39,12 @@
39
39
  aria-label={meta.expanded ? 'Collapse group' : 'Expand group'}
40
40
  onclick={() => onToggle(row)}
41
41
  >
42
- <span class="sv-group-chev" class:open={meta.expanded} aria-hidden="true">▸</span>
42
+ <!-- Same chevron the grid's own expanders and SvTree draw, so every
43
+ expander in the library looks identical. -->
44
+ <span class="sv-group-chev" class:open={meta.expanded} aria-hidden="true">
45
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
46
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
47
+ </span>
43
48
  <span class="sv-group-key">{meta.key}</span>
44
49
  {#if meta.loading}<span class="sv-group-load" aria-hidden="true">…</span>{/if}
45
50
  </button>
@@ -76,7 +81,9 @@
76
81
  cursor: pointer;
77
82
  }
78
83
  .sv-group-chev {
79
- display: inline-block;
84
+ display: inline-flex;
85
+ align-items: center;
86
+ justify-content: center;
80
87
  transition: transform 0.15s;
81
88
  color: var(--sg-muted, #64748b);
82
89
  }
@@ -37,6 +37,12 @@
37
37
  /** Debounce for remote search. Default 250ms. */
38
38
  debounceMs?: number
39
39
  loadingText?: string
40
+ /** Shown when the list is empty / a search matches nothing (localization). */
41
+ emptyText?: string
42
+ /** Placeholder in the panel's search box (localization). */
43
+ searchPlaceholder?: string
44
+ /** Accessible name for the panel's search box (localization). */
45
+ searchLabel?: string
40
46
  }
41
47
 
42
48
  let {
@@ -52,6 +58,9 @@
52
58
  loadOptions,
53
59
  debounceMs = 250,
54
60
  loadingText = 'Loading…',
61
+ emptyText = 'No matches',
62
+ searchPlaceholder = 'Search…',
63
+ searchLabel = 'Search options',
55
64
  disabled = false,
56
65
  readonly = false,
57
66
  label,
@@ -193,8 +202,8 @@
193
202
  aria-expanded="true"
194
203
  aria-controls={ms.panelId}
195
204
  aria-activedescendant={ms.activeDescendant()}
196
- aria-label="Search options"
197
- placeholder="Search…"
205
+ aria-label={searchLabel}
206
+ placeholder={searchPlaceholder}
198
207
  value={search}
199
208
  oninput={(e) => { search = e.currentTarget.value; ms.active = 0; if (loadOptions) runLoad(search) }}
200
209
  />
@@ -224,7 +233,7 @@
224
233
  <span class="sv-ms__label">{opt.label}</span>
225
234
  </li>
226
235
  {:else}
227
- <li class="sv-ms__empty" aria-disabled="true">{loading ? loadingText : 'No matches'}</li>
236
+ <li class="sv-ms__empty" aria-disabled="true">{loading ? loadingText : emptyText}</li>
228
237
  {/each}
229
238
  {#if loading && filtered.length}
230
239
  <li class="sv-ms__loading" aria-live="polite">{loadingText}</li>
@@ -142,7 +142,7 @@
142
142
  .sv-pw__meter { display: flex; gap: 4px; }
143
143
  .sv-pw__bar { flex: 1; height: 4px; border-radius: 2px; background: var(--sg-border, #e2e8f0); transition: background 0.15s; }
144
144
  .sv-pw__bar.on.is-lvl1 { background: var(--sg-danger, #dc2626); }
145
- .sv-pw__bar.on.is-lvl2 { background: var(--sg-warning, #f59e0b); }
145
+ .sv-pw__bar.on.is-lvl2 { background: var(--sg-warning, #d97706); }
146
146
  .sv-pw__bar.on.is-lvl3 { background: #eab308; }
147
147
  .sv-pw__bar.on.is-lvl4 { background: var(--sg-success, #16a34a); }
148
148
  .sv-pw__strength { font-size: 11px; font-weight: 600; color: var(--sg-muted, #64748b); }
@@ -75,7 +75,7 @@
75
75
  display: flex; align-items: center; gap: 10px; width: 100%;
76
76
  }
77
77
  .sv-prog--success { --_c: var(--sg-success, #16a34a); }
78
- .sv-prog--warning { --_c: var(--sg-warning, #f59e0b); }
78
+ .sv-prog--warning { --_c: var(--sg-warning, #d97706); }
79
79
  .sv-prog--danger { --_c: var(--sg-danger, #dc2626); }
80
80
  .sv-prog__track {
81
81
  position: relative; flex: 1; overflow: hidden;
@@ -96,7 +96,7 @@
96
96
  <style>
97
97
  .sv-rating {
98
98
  --_on: var(--sg-rating-on, #f59e0b);
99
- --_off: var(--sg-rating-empty, #d1d5db);
99
+ --_off: var(--sg-rating-empty, #cbd5e1);
100
100
  --_sz: 22px;
101
101
  display: inline-flex; gap: 2px; align-items: center;
102
102
  }
@@ -59,7 +59,7 @@
59
59
  }
60
60
  .sv-result--success { --_c: var(--sg-success, #16a34a); }
61
61
  .sv-result--error { --_c: var(--sg-danger, #dc2626); }
62
- .sv-result--warning { --_c: var(--sg-warning, #f59e0b); }
62
+ .sv-result--warning { --_c: var(--sg-warning, #d97706); }
63
63
  .sv-result--notfound { --_c: var(--sg-muted, #64748b); }
64
64
  .sv-result__icon {
65
65
  display: grid; place-items: center; width: 60px; height: 60px; border-radius: 50%; margin-bottom: 4px;
@@ -19,7 +19,7 @@
19
19
  * <SvRichText bind:value={html} placeholder="Write something…" />
20
20
  * ```
21
21
  */
22
- import { nextEditorId } from './editor-contract'
22
+ import { nextEditorId, resolveMessages } from './editor-contract'
23
23
 
24
24
  const DEFAULT_TOOLS: RichTextTool[] = [
25
25
  'bold', 'italic', 'underline', 'strike', '|',
@@ -29,16 +29,27 @@
29
29
  'link', 'clear', '|', 'undo', 'redo',
30
30
  ]
31
31
 
32
- const META: Record<Exclude<RichTextTool, '|'>, { label: string; glyph: string }> = {
33
- bold: { label: 'Bold', glyph: 'B' }, italic: { label: 'Italic', glyph: 'I' },
34
- underline: { label: 'Underline', glyph: 'U' }, strike: { label: 'Strikethrough', glyph: 'S' },
35
- h1: { label: 'Heading 1', glyph: 'H1' }, h2: { label: 'Heading 2', glyph: 'H2' },
36
- h3: { label: 'Heading 3', glyph: 'H3' }, p: { label: 'Paragraph', glyph: '¶' },
37
- ul: { label: 'Bullet list', glyph: '' }, ol: { label: 'Numbered list', glyph: '1.' },
38
- quote: { label: 'Quote', glyph: '' }, code: { label: 'Code block', glyph: '</>' },
39
- alignLeft: { label: 'Align left', glyph: '' }, alignCenter: { label: 'Align center', glyph: '≣' },
40
- alignRight: { label: 'Align right', glyph: '≡' }, link: { label: 'Link', glyph: '🔗' },
41
- clear: { label: 'Clear formatting', glyph: '⌫' }, undo: { label: 'Undo', glyph: '↶' }, redo: { label: 'Redo', glyph: '↷' },
32
+ /** Toolbar glyphs. Mode-independent and not language-dependent, so they stay
33
+ * fixed; the LABELS beside them are localizable via `messages`. */
34
+ const GLYPH: Record<Exclude<RichTextTool, '|'>, string> = {
35
+ bold: 'B', italic: 'I', underline: 'U', strike: 'S',
36
+ h1: 'H1', h2: 'H2', h3: 'H3', p: '¶',
37
+ ul: '', ol: '1.', quote: '', code: '</>',
38
+ alignLeft: '', alignCenter: '', alignRight: '', link: '🔗',
39
+ clear: '', undo: '', redo: '',
40
+ }
41
+
42
+ /** User-facing strings (localizable via `messages`). Every one is a tooltip +
43
+ * accessible name on a toolbar button, so leaving them hard-coded made the
44
+ * whole toolbar English-only for screen-reader users. */
45
+ type RichTextMessages = Record<Exclude<RichTextTool, '|'>, string> & { linkPrompt: string; toolbar: string }
46
+ const DEFAULT_MESSAGES: RichTextMessages = {
47
+ bold: 'Bold', italic: 'Italic', underline: 'Underline', strike: 'Strikethrough',
48
+ h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', p: 'Paragraph',
49
+ ul: 'Bullet list', ol: 'Numbered list', quote: 'Quote', code: 'Code block',
50
+ alignLeft: 'Align left', alignCenter: 'Align center', alignRight: 'Align right',
51
+ link: 'Link', clear: 'Clear formatting', undo: 'Undo', redo: 'Redo',
52
+ linkPrompt: 'Link URL', toolbar: 'Formatting',
42
53
  }
43
54
 
44
55
  type Props = {
@@ -52,6 +63,8 @@
52
63
  /** Toolbar tools + order; use '|' for a separator. */
53
64
  tools?: RichTextTool[]
54
65
  ariaLabel?: string
66
+ /** Override any toolbar label / prompt (localization). */
67
+ messages?: Partial<RichTextMessages>
55
68
  }
56
69
 
57
70
  let {
@@ -63,8 +76,10 @@
63
76
  minHeight = '160px',
64
77
  tools = DEFAULT_TOOLS,
65
78
  ariaLabel = 'Rich text editor',
79
+ messages,
66
80
  }: Props = $props()
67
81
 
82
+ const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
68
83
  const uid = nextEditorId('sv-rt')
69
84
  let editorEl = $state<HTMLDivElement | null>(null)
70
85
  let focused = $state(false)
@@ -109,14 +124,14 @@
109
124
  ul: () => exec('insertUnorderedList'), ol: () => exec('insertOrderedList'),
110
125
  quote: () => exec('formatBlock', 'BLOCKQUOTE'), code: () => exec('formatBlock', 'PRE'),
111
126
  alignLeft: () => exec('justifyLeft'), alignCenter: () => exec('justifyCenter'), alignRight: () => exec('justifyRight'),
112
- link: () => { const url = typeof prompt === 'function' ? prompt('Link URL', 'https://') : null; if (url) exec('createLink', url) },
127
+ link: () => { const url = typeof prompt === 'function' ? prompt(M.linkPrompt, 'https://') : null; if (url) exec('createLink', url) },
113
128
  clear: () => exec('removeFormat'), undo: () => exec('undo'), redo: () => exec('redo'),
114
129
  }
115
130
  </script>
116
131
 
117
132
  <div class="sv-rt" class:is-disabled={disabled} class:is-readonly={readonly}>
118
133
  {#if !readonly}
119
- <div class="sv-rt__toolbar" role="toolbar" aria-label="Formatting" aria-controls={uid}>
134
+ <div class="sv-rt__toolbar" role="toolbar" aria-label={M.toolbar} aria-controls={uid}>
120
135
  {#each tools as t, i (i)}
121
136
  {#if t === '|'}
122
137
  <span class="sv-rt__sep" aria-hidden="true"></span>
@@ -126,13 +141,13 @@
126
141
  class="sv-rt__btn"
127
142
  class:is-on={activeState[t]}
128
143
  data-tool={t}
129
- title={META[t].label}
130
- aria-label={META[t].label}
144
+ title={M[t]}
145
+ aria-label={M[t]}
131
146
  aria-pressed={activeState[t] ? true : undefined}
132
147
  {disabled}
133
148
  onmousedown={(e) => e.preventDefault()}
134
149
  onclick={run[t]}
135
- >{META[t].glyph}</button>
150
+ >{GLYPH[t]}</button>
136
151
  {/if}
137
152
  {/each}
138
153
  </div>
@@ -53,8 +53,9 @@
53
53
  </script>
54
54
 
55
55
  <SvField id={uid} {label} {hint} {error} {required} {dir}>
56
+ <!-- role="switch" arrives via switchProps(), which the a11y pass cannot see through -->
57
+ <!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
56
58
  <button
57
- role="switch"
58
59
  class="sv-switch sv-switch--{size}"
59
60
  class:is-on={checked}
60
61
  class:is-readonly={readonly}
@@ -135,7 +135,7 @@
135
135
 
136
136
  :global(.sv-toast--success) { --_accent: var(--sg-success, #16a34a); }
137
137
  :global(.sv-toast--error) { --_accent: var(--sg-danger, #dc2626); }
138
- :global(.sv-toast--warning) { --_accent: var(--sg-warning, #f59e0b); }
138
+ :global(.sv-toast--warning) { --_accent: var(--sg-warning, #d97706); }
139
139
  :global(.sv-toast--info) { --_accent: var(--sg-accent, #2563eb); }
140
140
 
141
141
  :global(.sv-toast__icon) { flex: none; width: 18px; height: 18px; display: grid; place-items: center; color: var(--_accent); font-weight: 700; }
package/src/SvTree.svelte CHANGED
@@ -48,6 +48,11 @@
48
48
  /** Render a built-in search box that drives the filter. */
49
49
  searchable?: boolean
50
50
  searchPlaceholder?: string
51
+ /** Placeholder row shown under a lazy node while its children load
52
+ * (localization). */
53
+ loadingText?: string
54
+ /** Accessible name for the search box's clear button (localization). */
55
+ clearSearchLabel?: string
51
56
  /** Sort siblings by label ('asc'/'desc') or a custom comparator. */
52
57
  sort?: 'asc' | 'desc' | ((a: TreeNode, b: TreeNode) => number)
53
58
  /** Allow inline rename (double-click or F2). Fires onRename. */
@@ -77,6 +82,8 @@
77
82
  filter,
78
83
  searchable = false,
79
84
  searchPlaceholder = 'Search...',
85
+ loadingText = 'Loading...',
86
+ clearSearchLabel = 'Clear search',
80
87
  sort,
81
88
  editable = false,
82
89
  onRename,
@@ -173,7 +180,7 @@
173
180
  if (!node?.lazy || loaded.has(id) || loadingIds.has(id)) return
174
181
  loadingIds = new Set(loadingIds).add(id)
175
182
  // A placeholder row shows immediately while the promise resolves.
176
- loaded = new Map(loaded).set(id, [{ id: `${id}__loading`, label: 'Loading...', disabled: true }])
183
+ loaded = new Map(loaded).set(id, [{ id: `${id}__loading`, label: loadingText, disabled: true }])
177
184
  try {
178
185
  const kids = await loadChildren(node)
179
186
  loaded = new Map(loaded).set(id, kids)
@@ -268,7 +275,7 @@
268
275
  <div class="sv-tree__search">
269
276
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
270
277
  <input class="sv-tree__search-input" type="text" placeholder={searchPlaceholder} bind:value={query} aria-label={searchPlaceholder} />
271
- {#if query}<button type="button" class="sv-tree__search-clear" aria-label="Clear search" onclick={() => (query = '')}>&times;</button>{/if}
278
+ {#if query}<button type="button" class="sv-tree__search-clear" aria-label={clearSearchLabel} onclick={() => (query = '')}>&times;</button>{/if}
272
279
  </div>
273
280
  {/if}
274
281
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -186,7 +186,11 @@
186
186
  {...ts.itemProps(i)}
187
187
  >
188
188
  {#if row.hasChildren}
189
- <span class="sv-ts__chev" class:is-open={row.expanded} aria-hidden="true">▸</span>
189
+ <!-- Same chevron as SvTree and the grid's expanders. -->
190
+ <span class="sv-ts__chev" class:is-open={row.expanded} aria-hidden="true">
191
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
192
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
193
+ </span>
190
194
  {:else}
191
195
  <span class="sv-ts__chev sv-ts__chev--leaf" aria-hidden="true"></span>
192
196
  {/if}
@@ -226,7 +230,7 @@
226
230
  :global(.sv-ts__row.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
227
231
  :global(.sv-ts__row.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
228
232
  :global(.sv-ts__row.is-disabled) { opacity: 0.5; cursor: not-allowed; }
229
- :global(.sv-ts__chev) { width: 14px; flex: none; color: var(--sg-muted, #64748b); transition: transform 0.12s; font-size: 10px; }
233
+ :global(.sv-ts__chev) { width: 14px; flex: none; display: inline-flex; align-items: center; justify-content: center; color: var(--sg-muted, #64748b); transition: transform 0.12s; }
230
234
  :global(.sv-ts__chev.is-open) { transform: rotate(90deg); }
231
235
  :global(.sv-ts__chev--leaf) { visibility: hidden; }
232
236
  :global(.sv-ts__label) { flex: 1; white-space: nowrap; }
package/src/build-api.ts CHANGED
@@ -455,6 +455,21 @@ export function createGridApi<
455
455
  clearFilter(columnId) {
456
456
  ctx.clearColumnFilter(columnId);
457
457
  },
458
+ refreshEditorOptions(columnId) {
459
+ // Async `editorOptions` are cached per column (or per column+row for a
460
+ // per-row source) so opening an editor twice does not refetch. This
461
+ // drops those entries, so the next render re-runs the source - use it
462
+ // when the underlying list changed server-side.
463
+ if (columnId == null) {
464
+ ctx.asyncEditorOptions = {};
465
+ return;
466
+ }
467
+ const next = { ...ctx.asyncEditorOptions };
468
+ for (const key of Object.keys(next)) {
469
+ if (key === columnId || key.startsWith(`${columnId}::`)) delete next[key];
470
+ }
471
+ ctx.asyncEditorOptions = next;
472
+ },
458
473
  clearAllFilters() {
459
474
  // See `setFilter` for why the read-modify-write is untracked.
460
475
  untrack(() => {
@@ -9,6 +9,8 @@ import {
9
9
  type CellEditorContext,
10
10
  } from './editor-registry'
11
11
 
12
+ // onCommitAndMove / onRequestClose / inCell are the hooks the grid injects at
13
+ // mount; the registry tests here never exercise them.
12
14
  function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
13
15
  return {
14
16
  value: null,
@@ -18,7 +20,7 @@ function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
18
20
  onCommit: () => {},
19
21
  onCancel: () => {},
20
22
  ...overrides,
21
- }
23
+ } as CellEditorContext
22
24
  }
23
25
 
24
26
  afterEach(() => {
@@ -9,16 +9,35 @@ import { createCellRender } from './cell-render'
9
9
  // shape that mirrors the @svgrid/grid runtime closely enough to exercise
10
10
  // every branch.
11
11
 
12
+ // Only `id` / `original` / `getCellValueByColumnId` are read by the functions
13
+ // under test, but the stub carries the full `Row` shape so it is assignable
14
+ // without a cast at every call site.
12
15
  type AnyRow = {
13
16
  id: string
17
+ index: number
14
18
  original: Record<string, unknown>
19
+ depth: number
20
+ getCanExpand: () => boolean
21
+ getIsExpanded: () => boolean
22
+ toggleExpanded: () => void
23
+ getIsSelected: () => boolean
24
+ toggleSelected: () => void
25
+ getAllCells: () => never[]
15
26
  getCellValueByColumnId: (colId: string) => unknown
16
27
  }
17
28
 
18
29
  function makeRow(id: string, data: Record<string, unknown>): AnyRow {
19
30
  return {
20
31
  id,
32
+ index: 0,
21
33
  original: data,
34
+ depth: 0,
35
+ getCanExpand: () => false,
36
+ getIsExpanded: () => false,
37
+ toggleExpanded: () => {},
38
+ getIsSelected: () => false,
39
+ toggleSelected: () => {},
40
+ getAllCells: () => [],
22
41
  getCellValueByColumnId: (colId: string) => data[colId],
23
42
  }
24
43
  }
@@ -34,6 +53,11 @@ function makeCtx(overrides: Partial<any> = {}): any {
34
53
  conditionalColumnStats: new Map(),
35
54
  noteOverrides: {},
36
55
  editorOptionsCache: {},
56
+ // Async `editorOptions` bookkeeping: resolved lists (reactive in the real
57
+ // controller) plus two plain Sets used as dedupe guards.
58
+ asyncEditorOptions: {} as Record<string, unknown>,
59
+ asyncEditorOptionsPending: new Set<string>(),
60
+ asyncEditorColumns: new Set<string>(),
37
61
  ...overrides,
38
62
  }
39
63
  }
@@ -172,7 +196,7 @@ describe('createCellRender / computeCellClass', () => {
172
196
  const row = makeRow('r', { score: 9 })
173
197
  expect(cr.computeCellClass(row, makeColumn('score', { cellClass }))).toBe('big')
174
198
  expect(cellClass).toHaveBeenCalledTimes(1)
175
- const passed = cellClass.mock.calls[0][0]
199
+ const passed = cellClass.mock.calls[0]![0]
176
200
  expect(passed.row).toBe(row)
177
201
  expect(passed.getValue()).toBe(9)
178
202
  })
@@ -423,7 +447,7 @@ describe('createCellRender / formatPinnedValue', () => {
423
447
  const formatter = vi.fn(({ value, row }: any) => `${value}:${row}`)
424
448
  const col = makeColumn('a', { formatter })
425
449
  expect(cr.formatPinnedValue(col, 5)).toBe('5:null')
426
- expect(formatter.mock.calls[0][0].row).toBeNull()
450
+ expect(formatter.mock.calls[0]![0].row).toBeNull()
427
451
  })
428
452
 
429
453
  it('masks password pinned values', () => {
@@ -478,7 +502,7 @@ describe('createCellRender / computePinnedCellClass', () => {
478
502
  field: 'a',
479
503
  })
480
504
  expect(cr.computePinnedCellClass({ a: 3 }, col)).toBe('pos')
481
- expect(cellClass.mock.calls[0][0].row).toBeNull()
505
+ expect(cellClass.mock.calls[0]![0].row).toBeNull()
482
506
  })
483
507
 
484
508
  it('coerces a falsy cellClass result to empty string', () => {
@@ -263,6 +263,77 @@ export function createCellRender<
263
263
  return v && v.trim() ? v : null
264
264
  }
265
265
 
266
+ const isThenable = (v: unknown): v is Promise<unknown> =>
267
+ typeof (v as { then?: unknown } | null)?.then === "function";
268
+
269
+ /**
270
+ * Resolve a promised option list once per key, then keep it.
271
+ *
272
+ * Render paths call `getColumnEditorOptions` synchronously and often, so an
273
+ * async source cannot block: the first call starts the fetch and returns an
274
+ * empty list, and the resolved value lands in reactive state which re-renders
275
+ * the editor. `asyncEditorOptionsPending` tracks in-flight keys so repeated
276
+ * renders do not fire the request again.
277
+ */
278
+ function resolveAsyncOptions(
279
+ key: string,
280
+ promise: Promise<unknown>,
281
+ /** Drop other cached entries starting with this prefix when the result
282
+ * lands - used to evict a cascade's superseded signatures. */
283
+ prunePrefix?: string,
284
+ ): CellEditorOption[] {
285
+ const done = ctx.asyncEditorOptions[key];
286
+ if (done) return done;
287
+ const pending: Set<string> = ctx.asyncEditorOptionsPending;
288
+ if (!pending.has(key)) {
289
+ // Plain Set, not reactive state: this runs inside the template, and a
290
+ // $state write during render is a Svelte 5 error.
291
+ pending.add(key);
292
+ const store = (value: CellEditorOption[]) => {
293
+ const next: Record<string, CellEditorOption[]> = {};
294
+ for (const [k, v] of Object.entries(ctx.asyncEditorOptions as Record<string, CellEditorOption[]>)) {
295
+ // Keep everything except this cell's stale signatures.
296
+ if (prunePrefix && k !== key && k.startsWith(prunePrefix)) continue;
297
+ next[k] = v;
298
+ }
299
+ next[key] = value;
300
+ ctx.asyncEditorOptions = next;
301
+ };
302
+ promise
303
+ .then((value) => store(normalizeEditorOptions(value as never)))
304
+ // A failed lookup settles on "no options" rather than leaving the
305
+ // editor spinning forever.
306
+ .catch(() => store([]))
307
+ .finally(() => {
308
+ pending.delete(key);
309
+ });
310
+ }
311
+ return [];
312
+ }
313
+
314
+ /**
315
+ * Cache key for a per-row async source.
316
+ *
317
+ * A cascade depends on the row's OTHER cells - City is a function of Country -
318
+ * so keying by row id alone made the first result permanent: changing the
319
+ * country left the old city list cached forever. The row's own values are
320
+ * folded into the key, so editing the row naturally supersedes the entry.
321
+ *
322
+ * Only reached for columns already known to be async, so the stringify cost
323
+ * is bounded by the rows actually rendering such a column.
324
+ */
325
+ function asyncRowKey(column: Column<TData>, row: Row<TData>): string {
326
+ let signature: string;
327
+ try {
328
+ signature = JSON.stringify(row.original);
329
+ } catch {
330
+ // Circular / non-serializable row: fall back to identity, which at least
331
+ // invalidates when the consumer replaces the object immutably.
332
+ signature = String(row.index);
333
+ }
334
+ return `${column.id}::${row.id}::${signature}`;
335
+ }
336
+
266
337
  function getColumnEditorOptions(
267
338
  column: Column<TData>,
268
339
  row?: Row<TData> | null,
@@ -272,8 +343,36 @@ export function createCellRender<
272
343
  // Dynamic per-row: must be re-evaluated because the row's other
273
344
  // cells may have just changed (cascade).
274
345
  if (!row?.original) return [];
275
- return normalizeEditorOptions(def(row.original as TData));
346
+ // Once a column is known to hand back promises, answer from the cache
347
+ // WITHOUT calling the source again. Render reads options constantly, so
348
+ // calling an async source every time fires a real request per render -
349
+ // and any state the source touches is being mutated mid-render, which
350
+ // Svelte rejects outright. The key folds in the row's values, so editing
351
+ // the row supersedes the entry and the cascade refetches.
352
+ const known = (ctx.asyncEditorColumns as Set<string>).has(column.id);
353
+ if (known) {
354
+ const key = asyncRowKey(column, row);
355
+ const done = ctx.asyncEditorOptions[key];
356
+ if (done) return done;
357
+ if ((ctx.asyncEditorOptionsPending as Set<string>).has(key)) return [];
358
+ const produced = def(row.original as TData);
359
+ if (isThenable(produced)) {
360
+ return resolveAsyncOptions(key, produced, `${column.id}::${row.id}::`);
361
+ }
362
+ return normalizeEditorOptions(produced);
363
+ }
364
+ const produced = def(row.original as TData);
365
+ if (isThenable(produced)) {
366
+ (ctx.asyncEditorColumns as Set<string>).add(column.id);
367
+ return resolveAsyncOptions(
368
+ asyncRowKey(column, row),
369
+ produced,
370
+ `${column.id}::${row.id}::`,
371
+ );
372
+ }
373
+ return normalizeEditorOptions(produced);
276
374
  }
375
+ if (isThenable(def)) return resolveAsyncOptions(column.id, def);
277
376
  const id = column.id;
278
377
  if (
279
378
  !ctx.editorOptionsCache[id] ||
@@ -285,6 +384,27 @@ export function createCellRender<
285
384
  return ctx.editorOptionsCache[id];
286
385
  }
287
386
 
387
+ /**
388
+ * True while a column's (or cell's) async option list is still loading.
389
+ * Derived from "the source is async and no result has landed" rather than
390
+ * from the pending Set, so it stays reactive - `asyncEditorOptions` is the
391
+ * reactive half, the Set is only a dedupe guard.
392
+ */
393
+ function areEditorOptionsLoading(
394
+ column: Column<TData>,
395
+ row?: Row<TData> | null,
396
+ ): boolean {
397
+ const def = column.columnDef.editorOptions;
398
+ if (typeof def === "function") {
399
+ if (!row?.original) return false;
400
+ // Never call the source here - that would fire another request just to
401
+ // answer "are we loading?". The resolver records which columns are async.
402
+ if (!(ctx.asyncEditorColumns as Set<string>).has(column.id)) return false;
403
+ return !ctx.asyncEditorOptions[asyncRowKey(column, row)];
404
+ }
405
+ return isThenable(def) && !ctx.asyncEditorOptions[column.id];
406
+ }
407
+
288
408
  /** Joined display string for list/chips cells. */
289
409
  function formatListCellValue(
290
410
  column: Column<TData>,
@@ -461,6 +581,7 @@ export function createCellRender<
461
581
  computeCellValidity,
462
582
  computeCellNote,
463
583
  getColumnEditorOptions,
584
+ areEditorOptionsLoading,
464
585
  formatListCellValue,
465
586
  formatCellValue,
466
587
  formatPinnedValue,
@@ -19,6 +19,10 @@ export function getColumnBaseValue<TData extends RowData>(row: Row<TData>, colum
19
19
  }
20
20
 
21
21
  export function isGroupRow<TData extends RowData>(row: Row<TData>) {
22
+ // Tree rows are expandable too, but they are real data rows - rendering one as
23
+ // a full-width banner would swallow its cells. Only grouping's synthetic rows
24
+ // are banners.
25
+ if ((row as { __treeRow?: boolean }).__treeRow) return false;
22
26
  return typeof row.getCanExpand === "function" && row.getCanExpand();
23
27
  }
24
28
 
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
2
2
  import {
3
3
  chartToSvgString,
4
4
  chartToPngBlob,
@@ -125,7 +125,7 @@ describe('chartToSvgString', () => {
125
125
  describe('downloadChartSvg', () => {
126
126
  let createSpy: ReturnType<typeof vi.spyOn>
127
127
  let revokeSpy: ReturnType<typeof vi.spyOn>
128
- let clickSpy: ReturnType<typeof vi.fn>
128
+ let clickSpy: Mock<() => void>
129
129
 
130
130
  beforeEach(() => {
131
131
  vi.useFakeTimers()
@@ -614,7 +614,6 @@ describe('copySelectionToClipboard', () => {
614
614
  configurable: true,
615
615
  })
616
616
  const execCommand = vi.fn().mockReturnValue(true)
617
- // @ts-expect-error - jsdom lacks execCommand by default
618
617
  document.execCommand = execCommand
619
618
  const ctx = makeCtx()
620
619
  ctx.selectionRange = {
@@ -631,7 +630,6 @@ describe('copySelectionToClipboard', () => {
631
630
  value: undefined,
632
631
  configurable: true,
633
632
  })
634
- // @ts-expect-error - override execCommand to throw
635
633
  document.execCommand = () => {
636
634
  throw new Error('blocked')
637
635
  }