@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
@@ -26,6 +26,8 @@
26
26
  getEditableInputValue,
27
27
  getEditorClass,
28
28
  getEditorInputType,
29
+ getCellEditorInputType,
30
+ isNumericEditorInput,
29
31
  timeStringToDate,
30
32
  toValueArray,
31
33
  } from "./SvGrid.helpers";
@@ -75,6 +77,22 @@
75
77
  const focusOnMount = $derived(ctrl.focusOnMount);
76
78
  const toggleCheckboxWithKeyboard = $derived(ctrl.toggleCheckboxWithKeyboard);
77
79
 
80
+ /**
81
+ * Focus a button-based editor on mount. `focusOnMount` is for text controls -
82
+ * it also places the caret - and the checkbox and rating editors, being
83
+ * <button>s, had no equivalent and so never took focus at all. Their own key
84
+ * handlers therefore never fired: neither could be operated from the
85
+ * keyboard, and Escape never reached them, so the cell stayed stuck in edit
86
+ * mode until the user clicked somewhere else.
87
+ *
88
+ * `enabled` lets a group of buttons (the rating stars) pick which one holds
89
+ * focus, since an action cannot be applied conditionally.
90
+ */
91
+ function focusControlOnMount(node: HTMLElement, enabled: boolean = true) {
92
+ if (!enabled) return;
93
+ requestAnimationFrame(() => node.focus({ preventScroll: true }));
94
+ }
95
+
78
96
  function fullRowKeydown(e: KeyboardEvent) {
79
97
  if (e.key === "Enter") {
80
98
  e.preventDefault();
@@ -337,6 +355,7 @@
337
355
  })}
338
356
  {:else if ctrl.editingCell?.editorType === "checkbox"}
339
357
  <button
358
+ use:focusControlOnMount
340
359
  type="button"
341
360
  class="sv-grid-checkbox"
342
361
  role="checkbox"
@@ -350,15 +369,23 @@
350
369
  : ctrl.editingCell;
351
370
  saveEditingCell();
352
371
  }}
353
- onkeydown={(event) =>
354
- toggleCheckboxWithKeyboard(event, () => {
355
- event.stopPropagation();
356
- const nextValue = !ctrl.editingCell?.value;
357
- ctrl.editingCell = ctrl.editingCell
358
- ? { ...ctrl.editingCell, value: nextValue }
359
- : ctrl.editingCell;
360
- saveEditingCell();
361
- })}
372
+ onkeydown={(event) => {
373
+ // Enter / Space are the checkbox's own activation keys. Everything
374
+ // else - Escape to cancel, Tab to commit and move on - goes to the
375
+ // shared editor handler, which this editor used to bypass entirely.
376
+ if (event.key === "Enter" || event.key === " ") {
377
+ toggleCheckboxWithKeyboard(event, () => {
378
+ event.stopPropagation();
379
+ const nextValue = !ctrl.editingCell?.value;
380
+ ctrl.editingCell = ctrl.editingCell
381
+ ? { ...ctrl.editingCell, value: nextValue }
382
+ : ctrl.editingCell;
383
+ saveEditingCell();
384
+ });
385
+ return;
386
+ }
387
+ onEditorKeyDown(event);
388
+ }}
362
389
  onblur={() => saveEditingCell()}
363
390
  ></button>
364
391
  {:else if ctrl.editingCell?.editorType === "list"}
@@ -395,9 +422,15 @@
395
422
  0,
396
423
  Math.min(5, Math.round(Number(ctrl.editingCell?.value) || 0)),
397
424
  )}
425
+ <!-- Focus the star for the current rating (the first one when unrated),
426
+ the radio a radiogroup is expected to enter on. Without it nothing
427
+ in this editor held focus, so `onEditorKeyDown` below never ran and
428
+ Escape could not close the cell. -->
429
+ {@const focusStar = Math.min(5, Math.max(1, ratingVal))}
398
430
  <span class="sv-grid-rating-editor" role="radiogroup" aria-label="Rating">
399
431
  {#each [1, 2, 3, 4, 5] as n (n)}
400
432
  <button
433
+ use:focusControlOnMount={n === focusStar}
401
434
  type="button"
402
435
  role="radio"
403
436
  aria-checked={ratingVal >= n}
@@ -411,7 +444,43 @@
411
444
  : ctrl.editingCell;
412
445
  saveEditingCell();
413
446
  }}
414
- onkeydown={onEditorKeyDown}>★</button
447
+ onkeydown={(event) => {
448
+ // Arrows move between the stars, the way a radiogroup is
449
+ // expected to work - without them the editor takes focus (see
450
+ // focusControlOnMount) but Space can only re-pick the star that
451
+ // already holds the value. Enter / Escape / Tab still fall
452
+ // through to the shared handler.
453
+ const key = event.key;
454
+ if (
455
+ key !== "ArrowLeft" &&
456
+ key !== "ArrowRight" &&
457
+ key !== "Home" &&
458
+ key !== "End"
459
+ ) {
460
+ onEditorKeyDown(event);
461
+ return;
462
+ }
463
+ event.preventDefault();
464
+ event.stopPropagation();
465
+ const current = Math.min(5, Math.max(1, ratingVal));
466
+ const next =
467
+ key === "Home"
468
+ ? 1
469
+ : key === "End"
470
+ ? 5
471
+ : key === "ArrowLeft"
472
+ ? Math.max(1, current - 1)
473
+ : Math.min(5, current + 1);
474
+ ctrl.editingCell = ctrl.editingCell
475
+ ? { ...ctrl.editingCell, value: next }
476
+ : ctrl.editingCell;
477
+ const stars = (
478
+ event.currentTarget as HTMLElement
479
+ ).parentElement?.querySelectorAll<HTMLElement>(
480
+ ".sv-grid-rating-star",
481
+ );
482
+ stars?.[next - 1]?.focus({ preventScroll: true });
483
+ }}>★</button
415
484
  >
416
485
  {/each}
417
486
  <button
@@ -643,18 +712,31 @@
643
712
  {@const CustomCellEditor = _reg.component}
644
713
  <CustomCellEditor {...resolveEditorProps(_reg, buildRegisteredEditorContext())} />
645
714
  {:else}
715
+ {@const editorType = ctrl.editingCell?.editorType ?? "text"}
716
+ {@const isNumberEditor = getCellEditorInputType(editorType) === "text" &&
717
+ getEditorInputType(editorType) === "number"}
646
718
  <input
647
719
  use:focusOnMount
648
- class={getEditorClass(ctrl.editingCell?.editorType ?? "text")}
649
- type={getEditorInputType(ctrl.editingCell?.editorType ?? "text")}
720
+ class={getEditorClass(editorType)}
721
+ type={getCellEditorInputType(editorType)}
722
+ inputmode={isNumberEditor ? "decimal" : undefined}
650
723
  value={getEditableInputValue(
651
- ctrl.editingCell?.editorType ?? "text",
724
+ editorType,
652
725
  ctrl.editingCell?.value,
653
726
  )}
654
- oninput={(event) =>
655
- updateEditingCellValue(
656
- (event.currentTarget as HTMLInputElement).value,
657
- )}
727
+ oninput={(event) => {
728
+ const input = event.currentTarget as HTMLInputElement;
729
+ // The number editor is a TEXT input so intermediate values survive
730
+ // (see getCellEditorInputType). That means nothing stops a letter
731
+ // being typed, which type="number" used to - so reject it here and
732
+ // put the last good draft back, rather than letting "12x" reach the
733
+ // commit and coerce to null.
734
+ if (isNumberEditor && !isNumericEditorInput(input.value)) {
735
+ input.value = String(ctrl.editingCell?.value ?? "");
736
+ return;
737
+ }
738
+ updateEditingCellValue(input.value);
739
+ }}
658
740
  onblur={() => saveEditingCell()}
659
741
  onkeydown={onEditorKeyDown}
660
742
  />
@@ -21,7 +21,7 @@
21
21
 
22
22
  type Props = Pick<
23
23
  SvEditorProps,
24
- 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
24
+ 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
25
25
  > & {
26
26
  columns: ReadonlyArray<GridSelectColumn>
27
27
  options: ReadonlyArray<Row>
@@ -68,6 +68,7 @@
68
68
  error,
69
69
  required = false,
70
70
  invalid = false,
71
+ block = false,
71
72
  size = 'md',
72
73
  id,
73
74
  name,
@@ -133,7 +134,8 @@
133
134
  </script>
134
135
 
135
136
  <SvField id={uid} {label} {hint} {error} {required} {dir} loading={loadingProp || loading}>
136
- <div class="sv-gs sv-gs--{size}" class:is-disabled={disabled}>
137
+ <div class="sv-gs sv-gs--{size}"
138
+ class:is-block={block} class:is-disabled={disabled}>
137
139
  <button
138
140
  bind:this={triggerEl}
139
141
  type="button"
@@ -263,4 +265,6 @@
263
265
  :global(.sv-gs__row.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
264
266
  :global(.sv-gs__cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
265
267
  :global(.sv-gs__empty) { padding: 12px; text-align: center; color: var(--sg-muted, #94a3b8); }
268
+ /* Fill the container - see `block` in SvEditorProps. */
269
+ .sv-gs.is-block { width: 100%; max-width: 100%; }
266
270
  </style>
@@ -10,7 +10,7 @@ export type { GridSelectColumn } from './ui-app.types.js';
10
10
  import type { GridSelectColumn } from './ui-app.types.js';
11
11
  import { type SvEditorProps } from './editor-contract.js';
12
12
  import { type Row } from './grid-select.js';
13
- type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
13
+ type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
14
14
  columns: ReadonlyArray<GridSelectColumn>;
15
15
  options: ReadonlyArray<Row>;
16
16
  idField?: string;
@@ -230,6 +230,7 @@
230
230
  <span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>
231
231
  {/if}
232
232
  {#if item ?? itemTemplate}{@render (item ?? itemTemplate)!(opt)}{:else}<span class="sv-listbox__label">{opt.label}</span>{/if}
233
+ {#if opt.hint && !(item ?? itemTemplate)}<span class="sv-listbox__hint" aria-hidden="true">{opt.hint}</span>{/if}
233
234
  {/snippet}
234
235
 
235
236
  {#snippet optionRow(opt: IndexedOption, index: number, h?: number, top?: number)}
@@ -302,6 +303,13 @@
302
303
  }
303
304
  .sv-listbox__check { width: 14px; text-align: center; color: var(--_accent); }
304
305
  .sv-listbox__label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
306
+ /* Trailing muted text (a count, a unit). `flex: none` so it never gives up
307
+ width - the label ellipsises instead, which is the half you can guess. */
308
+ .sv-listbox__hint {
309
+ flex: none; margin-inline-start: 8px;
310
+ color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums;
311
+ font-size: 0.9em;
312
+ }
305
313
 
306
314
  /* ---- Checklist variant ---- */
307
315
  .sv-listbox__box {
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Props = Pick<
22
22
  SvEditorProps,
23
- 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
23
+ 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
24
24
  > & {
25
25
  options: ReadonlyArray<MultiSelectOption>
26
26
  value?: ReadonlyArray<string | number>
@@ -67,6 +67,7 @@
67
67
  error,
68
68
  required = false,
69
69
  invalid = false,
70
+ block = false,
70
71
  size = 'md',
71
72
  id,
72
73
  name,
@@ -143,7 +144,8 @@
143
144
  </script>
144
145
 
145
146
  <SvField id={uid} {label} {hint} {error} {required} {dir} loading={loadingProp || loading}>
146
- <div class="sv-ms sv-ms--{size}" class:is-disabled={disabled}>
147
+ <div class="sv-ms sv-ms--{size}"
148
+ class:is-block={block} class:is-disabled={disabled}>
147
149
  <button
148
150
  bind:this={triggerEl}
149
151
  type="button"
@@ -286,4 +288,6 @@
286
288
  :global(.sv-ms__loading) { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 12px; text-align: center; }
287
289
  :global(.sv-ms__footer) { display: flex; justify-content: flex-end; padding: 6px 8px; border-top: 1px solid var(--sg-border, #e2e8f0); }
288
290
  :global(.sv-ms__done) { font: inherit; font-size: 12.5px; padding: 4px 12px; border: 0; border-radius: 6px; background: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff); cursor: pointer; }
291
+ /* Fill the container - see `block` in SvEditorProps. */
292
+ .sv-ms.is-block { width: 100%; max-width: 100%; }
289
293
  </style>
@@ -9,7 +9,7 @@ export type { MultiSelectOption } from './ui-app.types.js';
9
9
  */
10
10
  import type { MultiSelectOption } from './ui-app.types.js';
11
11
  import { type SvEditorProps } from './editor-contract.js';
12
- type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
12
+ type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
13
13
  options: ReadonlyArray<MultiSelectOption>;
14
14
  value?: ReadonlyArray<string | number>;
15
15
  onChange?: (value: Array<string | number>) => void;
@@ -161,6 +161,10 @@
161
161
 
162
162
  <style>
163
163
  .sv-num__input { text-align: end; }
164
+ /* Digits line up on the right; a placeholder is prose, and right-aligning
165
+ it puts one field out of step with every other label-above-control row
166
+ in a form. */
167
+ .sv-num__input:placeholder-shown { text-align: start; }
164
168
  .sv-num__clear { display: grid; place-items: center; width: 20px; align-self: center; flex: none; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 4px; }
165
169
  .sv-num__clear:hover { color: var(--sg-danger, #dc2626); }
166
170
  .sv-num__spin { display: flex; flex-direction: column; margin-inline-start: 2px; }
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvRichCell - render rich text inside a grid cell, safely.
4
+ *
5
+ * Pair it with `editorType: 'richtext'` (or feed it Markdown) to get a column
6
+ * whose values carry formatting. The value is always run through
7
+ * `sanitizeHtml` before it is painted, so markup that arrived from a user
8
+ * cannot execute in the host page - see sanitize-html.ts for the allowlist.
9
+ *
10
+ * ```svelte
11
+ * <script>
12
+ * import { SvRichCell } from '@svgrid/grid'
13
+ * <\/script>
14
+ *
15
+ * {#snippet notesCell({ getValue })}
16
+ * <SvRichCell value={getValue()} />
17
+ * {/snippet}
18
+ * ```
19
+ *
20
+ * A grid row is one line tall by default, so the content is clamped to
21
+ * `clamp` lines with an ellipsis and the full text goes on the `title`
22
+ * attribute. Set `clamp={0}` in a master/detail panel or a tall row where the
23
+ * whole value should show.
24
+ */
25
+ import { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
26
+
27
+ type Props = {
28
+ /** The cell value: an HTML string, or Markdown when `format="markdown"`. */
29
+ value?: unknown
30
+ /** How to read `value`. Markdown is converted first, then sanitized. */
31
+ format?: 'html' | 'markdown'
32
+ /** Lines to clamp to. 0 removes the clamp and lets the content wrap. */
33
+ clamp?: number
34
+ /** Native tooltip carrying the flattened text. Off when the grid already
35
+ * supplies its own cell tooltip. */
36
+ title?: boolean
37
+ class?: string
38
+ }
39
+
40
+ let { value = '', format = 'html', clamp = 1, title = true, class: className = '' }: Props = $props()
41
+
42
+ const source = $derived(value == null ? '' : String(value))
43
+ const html = $derived(format === 'markdown' ? renderMarkdown(source) : sanitizeHtml(source))
44
+ // The flattened text doubles as the tooltip and as the accessible name when
45
+ // the markup collapses to something a screen reader would read as noise.
46
+ const text = $derived(title ? htmlToText(html) : '')
47
+ </script>
48
+
49
+ <div
50
+ class="sv-rich-cell {className}"
51
+ class:is-clamped={clamp > 0}
52
+ style={clamp > 0 ? `--sv-rich-cell-lines: ${clamp}` : undefined}
53
+ title={text || undefined}
54
+ >
55
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
56
+ {@html html}
57
+ </div>
58
+
59
+ <style>
60
+ .sv-rich-cell {
61
+ min-width: 0;
62
+ color: var(--sg-fg, #0f172a);
63
+ font: inherit;
64
+ }
65
+ /* Clamp to N lines. The grid row is a single line tall by default, so
66
+ unclamped rich text would push the row open and break virtualization's
67
+ fixed-height assumption. */
68
+ .sv-rich-cell.is-clamped {
69
+ display: -webkit-box;
70
+ -webkit-box-orient: vertical;
71
+ -webkit-line-clamp: var(--sv-rich-cell-lines, 1);
72
+ line-clamp: var(--sv-rich-cell-lines, 1);
73
+ overflow: hidden;
74
+ }
75
+ /* Block elements inside a cell must not carry their document margins, or a
76
+ single <p> pushes the text out of the row box. */
77
+ .sv-rich-cell :global(p),
78
+ .sv-rich-cell :global(h1),
79
+ .sv-rich-cell :global(h2),
80
+ .sv-rich-cell :global(h3),
81
+ .sv-rich-cell :global(h4),
82
+ .sv-rich-cell :global(h5),
83
+ .sv-rich-cell :global(h6),
84
+ .sv-rich-cell :global(ul),
85
+ .sv-rich-cell :global(ol),
86
+ .sv-rich-cell :global(blockquote),
87
+ .sv-rich-cell :global(pre) {
88
+ margin: 0;
89
+ padding: 0;
90
+ }
91
+ .sv-rich-cell.is-clamped :global(ul),
92
+ .sv-rich-cell.is-clamped :global(ol) {
93
+ list-style-position: inside;
94
+ }
95
+ /* Headings keep their weight but drop to cell size: a real <h1> inside a row
96
+ would dwarf every neighbouring cell. */
97
+ .sv-rich-cell :global(h1),
98
+ .sv-rich-cell :global(h2),
99
+ .sv-rich-cell :global(h3),
100
+ .sv-rich-cell :global(h4),
101
+ .sv-rich-cell :global(h5),
102
+ .sv-rich-cell :global(h6) {
103
+ font-size: inherit;
104
+ font-weight: 650;
105
+ }
106
+ .sv-rich-cell :global(a) {
107
+ color: var(--sg-accent, #2563eb);
108
+ }
109
+ .sv-rich-cell :global(code) {
110
+ font-family: var(--sg-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
111
+ font-size: 0.92em;
112
+ background: var(--sg-code-bg, color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent));
113
+ border-radius: var(--sg-radius-sm, 4px);
114
+ padding: 0 4px;
115
+ }
116
+ .sv-rich-cell :global(blockquote) {
117
+ border-inline-start: 2px solid var(--sg-border, #cbd5e1);
118
+ padding-inline-start: 8px;
119
+ color: var(--sg-muted, #64748b);
120
+ }
121
+ .sv-rich-cell :global(img) {
122
+ max-height: 1.4em;
123
+ vertical-align: text-bottom;
124
+ }
125
+ </style>
@@ -0,0 +1,15 @@
1
+ type Props = {
2
+ /** The cell value: an HTML string, or Markdown when `format="markdown"`. */
3
+ value?: unknown;
4
+ /** How to read `value`. Markdown is converted first, then sanitized. */
5
+ format?: 'html' | 'markdown';
6
+ /** Lines to clamp to. 0 removes the clamp and lets the content wrap. */
7
+ clamp?: number;
8
+ /** Native tooltip carrying the flattened text. Off when the grid already
9
+ * supplies its own cell tooltip. */
10
+ title?: boolean;
11
+ class?: string;
12
+ };
13
+ declare const SvRichCell: import("svelte").Component<Props, {}, "">;
14
+ type SvRichCell = ReturnType<typeof SvRichCell>;
15
+ export default SvRichCell;
@@ -39,6 +39,7 @@
39
39
  size = 'md',
40
40
  ariaLabel,
41
41
  invalid = false,
42
+ block = false,
42
43
  required = false,
43
44
  error,
44
45
  label,
@@ -73,7 +74,8 @@
73
74
  </script>
74
75
 
75
76
  <SvField id={uid} {label} {hint} {error} {required} {dir}>
76
- <div class="sv-ta sv-ta--{size}" class:is-disabled={disabled}>
77
+ <div class="sv-ta sv-ta--{size}"
78
+ class:is-block={block} class:is-disabled={disabled}>
77
79
  <textarea
78
80
  use:grow
79
81
  use:mountFocus
@@ -110,4 +112,6 @@
110
112
  .sv-ta__input:focus { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
111
113
  .sv-ta__input.is-invalid { border-color: var(--sg-danger, #dc2626); }
112
114
  .sv-ta__count { align-self: flex-end; font-size: 11px; color: var(--sg-muted, #64748b); }
115
+ /* Fill the container - see `block` in SvEditorProps. */
116
+ .sv-ta.is-block { width: 100%; max-width: 100%; }
113
117
  </style>
@@ -20,7 +20,7 @@
20
20
 
21
21
  type Props = Pick<
22
22
  SvEditorProps,
23
- 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
23
+ 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'
24
24
  > & {
25
25
  nodes: ReadonlyArray<TreeSelectNode>
26
26
  value?: string | number | null
@@ -46,6 +46,7 @@
46
46
  error,
47
47
  required = false,
48
48
  invalid = false,
49
+ block = false,
49
50
  size = 'md',
50
51
  id,
51
52
  name,
@@ -129,7 +130,8 @@
129
130
  </script>
130
131
 
131
132
  <SvField id={uid} {label} {hint} {error} {required} {dir} {loading}>
132
- <div class="sv-ts sv-ts--{size}" class:is-disabled={disabled}>
133
+ <div class="sv-ts sv-ts--{size}"
134
+ class:is-block={block} class:is-disabled={disabled}>
133
135
  <button
134
136
  bind:this={triggerEl}
135
137
  type="button"
@@ -232,4 +234,6 @@
232
234
  :global(.sv-ts__chev.is-open) { transform: rotate(90deg); }
233
235
  :global(.sv-ts__chev--leaf) { visibility: hidden; }
234
236
  :global(.sv-ts__label) { flex: 1; white-space: nowrap; }
237
+ /* Fill the container - see `block` in SvEditorProps. */
238
+ .sv-ts.is-block { width: 100%; max-width: 100%; }
235
239
  </style>
@@ -9,7 +9,7 @@ export type { TreeSelectNode } from './ui-app.types.js';
9
9
  */
10
10
  import type { TreeSelectNode } from './ui-app.types.js';
11
11
  import { type SvEditorProps } from './editor-contract.js';
12
- type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
12
+ type Props = Pick<SvEditorProps, 'disabled' | 'readonly' | 'label' | 'hint' | 'error' | 'required' | 'invalid' | 'block' | 'size' | 'id' | 'name' | 'dir' | 'ariaLabel' | 'loading'> & {
13
13
  nodes: ReadonlyArray<TreeSelectNode>;
14
14
  value?: string | number | null;
15
15
  onChange?: (value: string | number) => void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Register the config-free UI-kit editors (`otp`, `duration`) as grid cell
3
- * editors. Idempotent. Returns the list of type names it registered.
2
+ * Register the config-free UI-kit editors (`otp`, `duration`, `richtext`) as
3
+ * grid cell editors. Idempotent. Returns the list of type names it registered.
4
4
  */
5
5
  export declare function registerBuiltinEditors(): string[];
@@ -25,9 +25,11 @@
25
25
  import { registerCellEditor } from './editor-registry.js';
26
26
  import SvOtpInput from './SvOtpInput.svelte';
27
27
  import SvDurationInput from './SvDurationInput.svelte';
28
+ import SvRichText from './SvRichText.svelte';
29
+ import { sanitizeHtml } from './sanitize-html.js';
28
30
  /**
29
- * Register the config-free UI-kit editors (`otp`, `duration`) as grid cell
30
- * editors. Idempotent. Returns the list of type names it registered.
31
+ * Register the config-free UI-kit editors (`otp`, `duration`, `richtext`) as
32
+ * grid cell editors. Idempotent. Returns the list of type names it registered.
31
33
  */
32
34
  export function registerBuiltinEditors() {
33
35
  registerCellEditor('otp', {
@@ -51,5 +53,19 @@ export function registerBuiltinEditors() {
51
53
  onCancel: () => ctx.onCancel(),
52
54
  }),
53
55
  });
54
- return ['otp', 'duration'];
56
+ // Rich text carries a toolbar, so give the column a taller row (or edit it
57
+ // from a detail panel) - it does not fit a default single-line row.
58
+ //
59
+ // Both the value handed to the editor and the value coming back are
60
+ // sanitized. The stored string is the one SvRichCell later paints, so
61
+ // cleaning it on the way in means a pasted payload never reaches row data.
62
+ registerCellEditor('richtext', {
63
+ component: SvRichText,
64
+ props: (ctx) => ({
65
+ value: sanitizeHtml((ctx.value ?? '')),
66
+ autofocus: true,
67
+ onChange: (html) => ctx.onChange(sanitizeHtml(html)),
68
+ }),
69
+ });
70
+ return ['otp', 'duration', 'richtext'];
55
71
  }