@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.
- package/CHANGELOG.md +62 -0
- package/README.md +22 -0
- package/dist/GridMenus.svelte +201 -18
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +61 -9
- package/dist/SvGrid.controller.svelte.js +363 -123
- package/dist/SvGrid.css +222 -1
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +255 -56
- package/dist/SvGrid.types.d.ts +146 -1
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/column-resize-DsfNXMom.js +102 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/row-resize-BRcimkUT.js +95 -0
- package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
- package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
- package/dist/cdn/svgrid.js +14 -10
- package/dist/cdn/svgrid.svelte-external.js +14 -10
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/column-groups.js +1 -1
- package/dist/column-resize.d.ts +46 -0
- package/dist/column-resize.js +205 -0
- package/dist/columns.d.ts +0 -3
- package/dist/columns.js +0 -57
- package/dist/core.d.ts +19 -4
- package/dist/core.js +460 -119
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/filtering/excel-filters.js +28 -0
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/group-display.d.ts +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +41 -8
- package/dist/row-resize.d.ts +11 -0
- package/dist/row-resize.js +7 -1
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +133 -8
- package/dist/spreadsheet.d.ts +1 -1
- package/dist/spreadsheet.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +201 -18
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +404 -133
- package/src/SvGrid.css +222 -1
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +255 -56
- package/src/SvGrid.types.ts +153 -1
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/column-groups.ts +1 -1
- package/src/column-resize.test.ts +381 -0
- package/src/column-resize.ts +227 -0
- package/src/columns.test.ts +0 -103
- package/src/columns.ts +0 -58
- package/src/core.aggregate.test.ts +134 -0
- package/src/core.filter.test.ts +156 -0
- package/src/core.grouping.test.ts +146 -0
- package/src/core.row-shape.test.ts +119 -0
- package/src/core.rowmodel-cache.test.ts +121 -0
- package/src/core.sort.test.ts +293 -0
- package/src/core.ts +516 -119
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/filtering/excel-filters.ts +30 -0
- package/src/filtering/normalize-fast-path.test.ts +104 -0
- package/src/grid-messages.ts +34 -0
- package/src/group-display.ts +1 -1
- package/src/index.ts +25 -1
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +38 -8
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/resize-props.test.ts +361 -0
- package/src/row-resize.test.ts +31 -0
- package/src/row-resize.ts +21 -3
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +135 -8
- package/src/spreadsheet.ts +1 -1
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
- package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- 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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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={
|
|
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(
|
|
649
|
-
type={
|
|
720
|
+
class={getEditorClass(editorType)}
|
|
721
|
+
type={getCellEditorInputType(editorType)}
|
|
722
|
+
inputmode={isNumberEditor ? "decimal" : undefined}
|
|
650
723
|
value={getEditableInputValue(
|
|
651
|
-
|
|
724
|
+
editorType,
|
|
652
725
|
ctrl.editingCell?.value,
|
|
653
726
|
)}
|
|
654
|
-
oninput={(event) =>
|
|
655
|
-
|
|
656
|
-
|
|
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
|
/>
|
package/src/SvGridSelect.svelte
CHANGED
|
@@ -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}"
|
|
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>
|
package/src/SvListBox.svelte
CHANGED
|
@@ -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 {
|
package/src/SvMultiSelect.svelte
CHANGED
|
@@ -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}"
|
|
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>
|
package/src/SvNumberInput.svelte
CHANGED
|
@@ -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>
|
package/src/SvTextArea.svelte
CHANGED
|
@@ -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}"
|
|
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>
|
package/src/SvTreeSelect.svelte
CHANGED
|
@@ -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}"
|
|
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>
|
|
@@ -30,9 +30,26 @@ afterEach(() => {
|
|
|
30
30
|
describe('registerBuiltinEditors', () => {
|
|
31
31
|
it('registers the config-free editor types', () => {
|
|
32
32
|
const types = registerBuiltinEditors()
|
|
33
|
-
expect(types).toEqual(['otp', 'duration'])
|
|
33
|
+
expect(types).toEqual(['otp', 'duration', 'richtext'])
|
|
34
34
|
expect(hasCellEditor('otp')).toBe(true)
|
|
35
35
|
expect(hasCellEditor('duration')).toBe(true)
|
|
36
|
+
expect(hasCellEditor('richtext')).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('sanitizes the rich-text value on the way in and on the way out', () => {
|
|
40
|
+
registerBuiltinEditors()
|
|
41
|
+
const onChange = vi.fn()
|
|
42
|
+
// Markup already sitting in the row data is cleaned before the editor sees
|
|
43
|
+
// it, so opening a poisoned cell cannot execute anything either.
|
|
44
|
+
const props = resolveEditorProps(
|
|
45
|
+
getCellEditor('richtext')!,
|
|
46
|
+
ctx({ value: '<p onclick="alert(1)">hi</p><script>alert(2)</script>', onChange }),
|
|
47
|
+
)
|
|
48
|
+
expect(props.value).toBe('<p>hi</p>')
|
|
49
|
+
;(props.onChange as (v: string) => void)('<b>ok</b><img src=x onerror="alert(3)">')
|
|
50
|
+
const stored = onChange.mock.calls[0]![0] as string
|
|
51
|
+
expect(stored).toContain('<b>ok</b>')
|
|
52
|
+
expect(stored).not.toContain('onerror')
|
|
36
53
|
})
|
|
37
54
|
|
|
38
55
|
it("maps the OTP editor's onComplete to commit", () => {
|
package/src/builtin-editors.ts
CHANGED
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
import { registerCellEditor } from './editor-registry'
|
|
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'
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
* Register the config-free UI-kit editors (`otp`, `duration`) as
|
|
31
|
-
* editors. Idempotent. Returns the list of type names it registered.
|
|
32
|
+
* Register the config-free UI-kit editors (`otp`, `duration`, `richtext`) as
|
|
33
|
+
* grid cell editors. Idempotent. Returns the list of type names it registered.
|
|
32
34
|
*/
|
|
33
35
|
export function registerBuiltinEditors(): string[] {
|
|
34
36
|
registerCellEditor('otp', {
|
|
@@ -52,5 +54,19 @@ export function registerBuiltinEditors(): string[] {
|
|
|
52
54
|
onCancel: () => ctx.onCancel(),
|
|
53
55
|
}),
|
|
54
56
|
})
|
|
55
|
-
|
|
57
|
+
// Rich text carries a toolbar, so give the column a taller row (or edit it
|
|
58
|
+
// from a detail panel) - it does not fit a default single-line row.
|
|
59
|
+
//
|
|
60
|
+
// Both the value handed to the editor and the value coming back are
|
|
61
|
+
// sanitized. The stored string is the one SvRichCell later paints, so
|
|
62
|
+
// cleaning it on the way in means a pasted payload never reaches row data.
|
|
63
|
+
registerCellEditor('richtext', {
|
|
64
|
+
component: SvRichText,
|
|
65
|
+
props: (ctx) => ({
|
|
66
|
+
value: sanitizeHtml((ctx.value ?? '') as string),
|
|
67
|
+
autofocus: true,
|
|
68
|
+
onChange: (html: string) => ctx.onChange(sanitizeHtml(html)),
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
return ['otp', 'duration', 'richtext']
|
|
56
72
|
}
|
package/src/cell-values.ts
CHANGED
|
@@ -18,6 +18,36 @@ export function getColumnBaseValue<TData extends RowData>(row: Row<TData>, colum
|
|
|
18
18
|
return row.getCellValueByColumnId(column.id);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Map a visible row back to its slot in the data array.
|
|
23
|
+
*
|
|
24
|
+
* The bulk writers (paste, clear) rebuild `internalData` immutably, so unlike
|
|
25
|
+
* the inline editor - which mutates `row.original` in place and never needs an
|
|
26
|
+
* index - they have to locate the slot first. They used to read it as
|
|
27
|
+
* `Number(row.id)`, which is only the array index under the DEFAULT `getRowId`.
|
|
28
|
+
* A grid with `getRowId={(t) => String(t.id)}` over 1-based ids wrote one row
|
|
29
|
+
* BELOW the selection on every paste, and one with non-numeric ids ('PLAT-412')
|
|
30
|
+
* parsed to NaN and dropped the write silently.
|
|
31
|
+
*
|
|
32
|
+
* Identity is the reliable key: the row model's `original` IS the object in
|
|
33
|
+
* `data`. Returns a lookup rather than a per-row scan so a multi-row paste
|
|
34
|
+
* stays O(n) instead of O(n * rows).
|
|
35
|
+
*/
|
|
36
|
+
export function createDataIndexLookup<TData extends RowData>(
|
|
37
|
+
data: ReadonlyArray<TData>,
|
|
38
|
+
): (row: Row<TData>) => number {
|
|
39
|
+
const byRef = new Map<unknown, number>();
|
|
40
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
41
|
+
// First slot wins: with a duplicated object reference in `data`, writing
|
|
42
|
+
// the earlier one matches what the row model itself resolves to.
|
|
43
|
+
if (!byRef.has(data[i])) byRef.set(data[i], i);
|
|
44
|
+
}
|
|
45
|
+
return (row) => {
|
|
46
|
+
const hit = byRef.get(row?.original);
|
|
47
|
+
return hit === undefined ? -1 : hit;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
export function isGroupRow<TData extends RowData>(row: Row<TData>) {
|
|
22
52
|
// Tree rows are expandable too, but they are real data rows - rendering one as
|
|
23
53
|
// a full-width banner would swallow its cells. Only grouping's synthetic rows
|