@svgrid/grid 2.6.22 → 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 (115) hide show
  1. package/dist/GridMenus.svelte +184 -6
  2. package/dist/SvAutoComplete.svelte +4 -0
  3. package/dist/SvComboBox.svelte +5 -1
  4. package/dist/SvDateRangeInput.svelte +25 -3
  5. package/dist/SvDropDownList.svelte +4 -0
  6. package/dist/SvForm.svelte +8 -8
  7. package/dist/SvGrid.controller.svelte.d.ts +48 -1
  8. package/dist/SvGrid.controller.svelte.js +214 -52
  9. package/dist/SvGrid.css +221 -0
  10. package/dist/SvGrid.helpers.d.ts +55 -0
  11. package/dist/SvGrid.helpers.js +83 -0
  12. package/dist/SvGrid.svelte +145 -0
  13. package/dist/SvGrid.types.d.ts +105 -0
  14. package/dist/SvGridCellEditor.svelte +99 -17
  15. package/dist/SvGridSelect.svelte +6 -2
  16. package/dist/SvGridSelect.svelte.d.ts +1 -1
  17. package/dist/SvListBox.svelte +8 -0
  18. package/dist/SvMultiSelect.svelte +6 -2
  19. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  20. package/dist/SvNumberInput.svelte +4 -0
  21. package/dist/SvRichCell.svelte +125 -0
  22. package/dist/SvRichCell.svelte.d.ts +15 -0
  23. package/dist/SvTextArea.svelte +5 -1
  24. package/dist/SvTreeSelect.svelte +6 -2
  25. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  26. package/dist/builtin-editors.d.ts +2 -2
  27. package/dist/builtin-editors.js +19 -3
  28. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  29. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  30. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  31. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  32. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  33. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  34. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  35. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  36. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  37. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  38. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  39. package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
  40. package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
  41. package/dist/cdn/svgrid.js +14 -12
  42. package/dist/cdn/svgrid.svelte-external.js +14 -12
  43. package/dist/cell-values.d.ts +16 -0
  44. package/dist/cell-values.js +28 -0
  45. package/dist/clipboard.d.ts +22 -0
  46. package/dist/clipboard.js +405 -27
  47. package/dist/editing.js +12 -7
  48. package/dist/editors/cell-editors.d.ts +11 -0
  49. package/dist/editors/cell-editors.js +18 -1
  50. package/dist/grid-messages.d.ts +15 -0
  51. package/dist/grid-messages.js +15 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +4 -0
  54. package/dist/list-option.d.ts +6 -0
  55. package/dist/menus.js +40 -7
  56. package/dist/sanitize-html.d.ts +37 -0
  57. package/dist/sanitize-html.js +234 -0
  58. package/dist/selection-bar-view.svelte.d.ts +25 -0
  59. package/dist/selection-bar-view.svelte.js +13 -0
  60. package/dist/selection.d.ts +2 -1
  61. package/dist/selection.js +129 -13
  62. package/package.json +1 -1
  63. package/src/GridMenus.svelte +184 -6
  64. package/src/SvAutoComplete.svelte +4 -0
  65. package/src/SvComboBox.svelte +5 -1
  66. package/src/SvDateRangeInput.svelte +25 -3
  67. package/src/SvDropDownList.svelte +4 -0
  68. package/src/SvForm.svelte +8 -8
  69. package/src/SvGrid.controller.svelte.ts +251 -61
  70. package/src/SvGrid.css +221 -0
  71. package/src/SvGrid.helpers.ts +87 -0
  72. package/src/SvGrid.svelte +145 -0
  73. package/src/SvGrid.types.ts +112 -0
  74. package/src/SvGridCellEditor.svelte +99 -17
  75. package/src/SvGridSelect.svelte +6 -2
  76. package/src/SvListBox.svelte +8 -0
  77. package/src/SvMultiSelect.svelte +6 -2
  78. package/src/SvNumberInput.svelte +4 -0
  79. package/src/SvRichCell.svelte +125 -0
  80. package/src/SvTextArea.svelte +5 -1
  81. package/src/SvTreeSelect.svelte +6 -2
  82. package/src/builtin-editors.test.ts +18 -1
  83. package/src/builtin-editors.ts +19 -3
  84. package/src/cell-values.ts +30 -0
  85. package/src/clipboard.test.ts +93 -20
  86. package/src/clipboard.ts +433 -33
  87. package/src/date-string-column.test.ts +87 -0
  88. package/src/editing.test.ts +25 -0
  89. package/src/editing.ts +12 -9
  90. package/src/editor-block-prop.test.ts +132 -0
  91. package/src/editors/cell-editors.ts +27 -1
  92. package/src/grid-messages.ts +34 -0
  93. package/src/index.ts +13 -0
  94. package/src/list-option.ts +6 -0
  95. package/src/menus.test.ts +43 -0
  96. package/src/menus.ts +37 -7
  97. package/src/move-cells.test.ts +850 -0
  98. package/src/number-editor-literal.test.ts +84 -0
  99. package/src/number-editor.grid.test.ts +160 -0
  100. package/src/sanitize-html.test.ts +182 -0
  101. package/src/sanitize-html.ts +235 -0
  102. package/src/selection-bar-view.svelte.ts +36 -0
  103. package/src/selection.test.ts +157 -0
  104. package/src/selection.ts +131 -13
  105. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  106. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  107. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  108. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  109. package/src/svgrid.upsell-license.test.ts +117 -0
  110. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  111. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  112. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  113. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  114. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  115. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
@@ -191,6 +191,74 @@ export type ContextMenuItem<TData extends RowData = RowData> = string | {
191
191
  /** Invoked on click. The menu closes afterwards. */
192
192
  action: (target: ContextMenuTarget<TData>) => void;
193
193
  };
194
+ /** What a {@link SelectionBarAction} is handed when it runs. */
195
+ export type SelectionBarTarget<TData extends RowData = RowData> = {
196
+ /** The selected rows, in the grid's current display order. */
197
+ rows: TData[];
198
+ /** Their row ids, same order as `rows`. */
199
+ ids: string[];
200
+ };
201
+ /**
202
+ * One button on the selection bar. Same shape as {@link ContextMenuItem}'s
203
+ * object form - key, label, optional hidden/disabled predicates, and an action -
204
+ * except everything here is handed the whole SELECTION rather than one cell.
205
+ */
206
+ export type SelectionBarAction<TData extends RowData = RowData> = {
207
+ key: string;
208
+ label: string;
209
+ /**
210
+ * Inline SVG path data (the `d` of a single 24x24 path) drawn to the left of
211
+ * the label. A string rather than a component so an action array stays plain
212
+ * data that can live in a `.ts` module next to the columns.
213
+ */
214
+ icon?: string;
215
+ /** Render in the destructive style (red). Does not add a confirmation. */
216
+ danger?: boolean;
217
+ /** Hide the button entirely for this selection. */
218
+ hidden?: (target: SelectionBarTarget<TData>) => boolean;
219
+ /** Render greyed-out and non-clickable for this selection. */
220
+ disabled?: (target: SelectionBarTarget<TData>) => boolean;
221
+ /** Invoked on click. The bar stays open; clear the selection yourself if the
222
+ * action should dismiss it. */
223
+ action: (target: SelectionBarTarget<TData>) => void;
224
+ };
225
+ /**
226
+ * A built-in bar button, named by key - the same convention
227
+ * {@link ContextMenuItem} uses for its string form.
228
+ *
229
+ * - `'selectAll'` selects every row in the current view.
230
+ * - `'editFields'` opens the bulk-edit dialog: pick a field, set one value,
231
+ * apply it to every selected row in a single undo step.
232
+ * - `'separator'` draws a divider.
233
+ */
234
+ export type SelectionBarBuiltin = "selectAll" | "editFields" | "separator";
235
+ /**
236
+ * Full form of the {@link SvGridProps.selectionBar} prop, for when the array
237
+ * shorthand is not enough.
238
+ */
239
+ export type SelectionBarConfig<TData extends RowData = RowData> = {
240
+ /**
241
+ * The buttons, left to right. Strings are {@link SelectionBarBuiltin} keys,
242
+ * objects are your own actions - so a Jira-shaped bar is
243
+ * `['selectAll', 'editFields', 'separator', myAction]`.
244
+ */
245
+ actions?: ReadonlyArray<SelectionBarAction<TData> | SelectionBarBuiltin>;
246
+ /**
247
+ * Which edge of the grid the bar floats against. Default `'bottom'`, where
248
+ * the eye is already going after ticking a checkbox and where it covers the
249
+ * rows a user is least likely to be reading.
250
+ */
251
+ position?: "bottom" | "top";
252
+ /**
253
+ * How many actions stay on the bar before the rest collapse into an overflow
254
+ * menu. Default 6, which fits a full issue-tracker set (select all, edit,
255
+ * status, watch, delete) without collapsing. A bar wide enough to need
256
+ * horizontal scrolling is a bar whose last button nobody finds.
257
+ */
258
+ maxVisible?: number;
259
+ /** Hide the trailing clear (x) button. Default false. */
260
+ hideClear?: boolean;
261
+ };
194
262
  /** A single Kanban lane (board column). {@link BoardConfig}. */
195
263
  export type BoardLane = {
196
264
  /** Lane id - equals the `groupBy` field value of the cards it holds. */
@@ -967,6 +1035,26 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
967
1035
  * browser menu shows instead).
968
1036
  */
969
1037
  contextMenu?: boolean | ReadonlyArray<ContextMenuItem<TData>>;
1038
+ /**
1039
+ * Floating bar that appears over the grid while rows are selected, showing
1040
+ * the count and the actions that apply to the whole selection - the pattern
1041
+ * an issue tracker uses for bulk edit.
1042
+ *
1043
+ * `true` gives the count and a Clear button. An array is the shorthand for
1044
+ * `{ actions }`. Pass {@link SelectionBarConfig} to also set `position`
1045
+ * (`'bottom'` default, or `'top'`), `maxVisible` and `hideClear`. Omitted /
1046
+ * `false` is off.
1047
+ *
1048
+ * Default **off**. This is chrome that floats over your content, not a
1049
+ * gesture: it overlaps a row, and what belongs on it is specific to the app.
1050
+ *
1051
+ * **The prop and its types are free; the bar itself is an Enterprise
1052
+ * feature.** Install `@svgrid/enterprise` and call `enableSelectionBar()`
1053
+ * (or `installEnterprise`, which does it for you) to render it - without
1054
+ * that the grid shows a short upsell note in its place, the same way the
1055
+ * scheduler and board views do.
1056
+ */
1057
+ selectionBar?: boolean | ReadonlyArray<SelectionBarAction<TData> | SelectionBarBuiltin> | SelectionBarConfig<TData>;
970
1058
  /**
971
1059
  * The feature set built with `tableFeatures({ ... })`. Optional - the
972
1060
  * `sortable` / `filterable` / `groupable` shortcuts below inject the
@@ -1424,6 +1512,23 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1424
1512
  * is the shortcut alias and wins over it.
1425
1513
  */
1426
1514
  enableCellSelection?: boolean;
1515
+ /**
1516
+ * Excel-style drag-and-drop of a selected cell range: grab the range's
1517
+ * border and drag it somewhere else to MOVE the values there, or hold
1518
+ * Ctrl (Cmd on macOS) to COPY them instead. The modifier is read at drop
1519
+ * time, so it can be pressed or released mid-drag.
1520
+ *
1521
+ * On by default whenever cell selection is on, matching the fill handle.
1522
+ * Set `false` to keep a border pointerdown starting a fresh selection, which
1523
+ * is what it did before this existed.
1524
+ *
1525
+ * The grab strip is the outer 4px of the range border, so a pointerdown
1526
+ * anywhere further inside still starts a new selection. A drop is refused
1527
+ * outright - nothing changes - when the destination would fall outside the
1528
+ * grid, or when any source or destination cell is read-only. It does not
1529
+ * move the part that fits and silently drop the rest.
1530
+ */
1531
+ moveCells?: boolean;
1427
1532
  /**
1428
1533
  * Highlight the row under the pointer. Default **false** - the hover tint can
1429
1534
  * compete with the cell selection / fill marquee. Set `true` to opt in; when
@@ -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[];