@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
package/src/editing.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  toValueArray,
18
18
  } from "./SvGrid.helpers";
19
19
  import {
20
+ createDataIndexLookup,
20
21
  getColumnBaseValue,
21
22
  isGroupRow,
22
23
  } from "./cell-values";
@@ -156,6 +157,8 @@ export function createEditing<
156
157
  ctx.editingCell.value,
157
158
  {
158
159
  multiple: column?.columnDef.editorMultiple === true,
160
+ // A dateString column holds 'YYYY-MM-DD', not a timestamp.
161
+ dateOnly: column?.columnDef.cellDataType === "dateString",
159
162
  },
160
163
  );
161
164
  let oldValue: unknown = undefined;
@@ -416,18 +419,15 @@ export function createEditing<
416
419
  : Math.max(...lines.map((l) => l.split("\t").length));
417
420
 
418
421
  const next = ctx.internalData.slice() as Array<TData>;
422
+ const dataIndexOf = createDataIndexLookup(next);
419
423
  for (let i = 0; i < rowSpan; i += 1) {
420
424
  const targetRowIndex = startRow + i;
421
425
  const row = ctx.allRows[targetRowIndex];
422
426
  if (!row || isGroupRow(row)) continue;
423
- // Map the visible row back to its index in the data array via row.id (default = String(index)).
424
- const dataIndex = Number(row.id);
425
- if (
426
- !Number.isInteger(dataIndex) ||
427
- dataIndex < 0 ||
428
- dataIndex >= next.length
429
- )
430
- continue;
427
+ // Map the visible row back to its slot in the data array by identity -
428
+ // `row.id` is only the index under the default getRowId.
429
+ const dataIndex = dataIndexOf(row);
430
+ if (dataIndex < 0) continue;
431
431
  const originalRow = next[dataIndex];
432
432
  if (!originalRow) continue;
433
433
  const sourceCells = fillRange
@@ -443,7 +443,9 @@ export function createEditing<
443
443
  const editorType = (column.columnDef.editorType ??
444
444
  "text") as CellEditorType;
445
445
  const raw = fillRange ? lines[0]! : sourceCells?.[j] ?? "";
446
- updated[column.columnDef.field] = parseEditorValue(editorType, raw);
446
+ updated[column.columnDef.field] = parseEditorValue(editorType, raw, {
447
+ dateOnly: column.columnDef.cellDataType === "dateString",
448
+ });
447
449
  }
448
450
  next[dataIndex] = updated as TData;
449
451
  }
@@ -530,6 +532,7 @@ export function createEditing<
530
532
  const editorType = (column.columnDef.editorType ?? "text") as CellEditorType;
531
533
  const parsed = parseEditorValue(editorType, fr.draft[column.id], {
532
534
  multiple: column.columnDef.editorMultiple === true,
535
+ dateOnly: column.columnDef.cellDataType === "dateString",
533
536
  });
534
537
  const oldValue = (row.original as Record<string, unknown>)[field];
535
538
  const parser = (column.columnDef as { valueParser?: (p: unknown) => unknown }).valueParser;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `block` is declared once, in `SvEditorProps`, and its doc comment states the
3
+ * contract: "in a form grid a row of inputs each stopping at a different width
4
+ * reads as broken."
5
+ *
6
+ * Nine editors accepted the prop by type and did nothing with it. `<SvForm>`
7
+ * mounts five of them, so the Edit-fields drawer came out with a full-width
8
+ * text box above a 200px select above a 240px date picker - the exact thing the
9
+ * doc comment warns about, shipped.
10
+ *
11
+ * A type that promises something no code honours is worse than no type: it
12
+ * reads as done. This walks the source instead of trusting the declaration, so
13
+ * the next editor added to the kit either honours `block` or is listed here as
14
+ * a deliberate exemption.
15
+ */
16
+ import { describe, expect, it } from 'vitest'
17
+ import { readFileSync, readdirSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ const SRC = join(import.meta.dirname, '.')
21
+
22
+ /**
23
+ * Controls whose width is their content, where stretching to 100% would be
24
+ * wrong rather than merely unusual: a checkbox is a 16px box with a label
25
+ * beside it, a rating is five stars, a switch is a switch. Widening them puts
26
+ * the control at one end of an empty row.
27
+ */
28
+ const INTRINSIC = new Set([
29
+ 'SvCheckBox',
30
+ 'SvSwitchButton',
31
+ 'SvToggleButton',
32
+ 'SvButtonGroup',
33
+ 'SvRadioGroup',
34
+ 'SvRating',
35
+ 'SvOtpInput',
36
+ 'SvFileUpload',
37
+ 'SvSlider',
38
+ 'SvColorInput',
39
+ ])
40
+
41
+ function editorsDeclaringBlock(): string[] {
42
+ return readdirSync(SRC)
43
+ .filter((f) => f.startsWith('Sv') && f.endsWith('.svelte'))
44
+ .filter((f) => {
45
+ const src = readFileSync(join(SRC, f), 'utf8')
46
+ if (!src.includes('SvEditorProps')) return false
47
+ // A `Pick<SvEditorProps, ...>` that leaves 'block' out has opted out at
48
+ // the type level, which is a legitimate (and checkable) choice.
49
+ const pick = /Pick<\s*SvEditorProps,\s*([^>]+)>/.exec(src)
50
+ if (pick) return (pick[1] ?? '').includes("'block'")
51
+ return true
52
+ })
53
+ .map((f) => f.replace('.svelte', ''))
54
+ }
55
+
56
+ describe('the block prop is honoured wherever it is declared', () => {
57
+ const declaring = editorsDeclaringBlock().filter((n) => !INTRINSIC.has(n))
58
+
59
+ it('finds the editor kit (guards against the walker silently matching nothing)', () => {
60
+ expect(declaring.length).toBeGreaterThan(10)
61
+ expect(declaring).toContain('SvDropDownList')
62
+ expect(declaring).toContain('SvDateTimePicker')
63
+ })
64
+
65
+ it.each(declaring)('%s reads the prop and applies it to a width', (name) => {
66
+ const src = readFileSync(join(SRC, `${name}.svelte`), 'utf8')
67
+
68
+ // Declared but never destructured is the failure mode that shipped: the
69
+ // component compiles, the consumer passes `block`, nothing happens.
70
+ expect(src, `${name} never destructures block`).toMatch(/\bblock = false\b|\bblock,/)
71
+
72
+ // And destructured but never used is the same bug one step later.
73
+ const applied =
74
+ /class:is-block=\{block\}/.test(src) ||
75
+ /class:sv-[a-z]+--block=\{block\}/.test(src) ||
76
+ /\{block\}/.test(src)
77
+ expect(applied, `${name} destructures block but never applies it`).toBe(true)
78
+ })
79
+ })
80
+
81
+ /**
82
+ * Every `<SvXxx ... />` in a Svelte source, with its props.
83
+ *
84
+ * Not a regex: props routinely contain `>`, in arrow functions like
85
+ * `onChange={(m) => o.onChange(m)}`. A `[^>]*?` walker stops at the first one
86
+ * and silently skips the tag - which here quietly excused SvComboBox,
87
+ * SvMaskedInput and SvPhoneInput from the check, three of the controls this
88
+ * test exists to cover. So brace depth is tracked instead.
89
+ */
90
+ function selfClosingTags(src: string): Array<{ name: string; props: string }> {
91
+ const out: Array<{ name: string; props: string }> = []
92
+ const open = /<(Sv[A-Za-z]+)(?=[\s/>])/g
93
+ let m: RegExpExecArray | null
94
+ while ((m = open.exec(src))) {
95
+ let i = m.index + m[0].length
96
+ let depth = 0
97
+ for (; i < src.length; i++) {
98
+ const c = src[i]
99
+ if (c === '{') depth++
100
+ else if (c === '}') depth--
101
+ else if (depth === 0 && c === '/' && src[i + 1] === '>') {
102
+ out.push({ name: m[1]!, props: src.slice(m.index + m[0].length, i) })
103
+ break
104
+ } else if (depth === 0 && c === '>') break // an open tag, not self-closing
105
+ }
106
+ }
107
+ return out
108
+ }
109
+
110
+ describe('<SvForm> passes block to every control it mounts', () => {
111
+ // The components above can honour block perfectly and the form still look
112
+ // ragged, because the form is what decides to pass it. That is precisely how
113
+ // this bug survived: SvNumberInput, SvPasswordInput, SvMaskedInput and
114
+ // SvPhoneInput were passed `block` and the rest were not.
115
+ const form = readFileSync(join(SRC, 'SvForm.svelte'), 'utf8')
116
+ const honours = new Set(editorsDeclaringBlock())
117
+ const controls = selfClosingTags(form).filter(
118
+ (c) => honours.has(c.name) && !INTRINSIC.has(c.name),
119
+ )
120
+
121
+ it('finds the control snippet', () => {
122
+ // Every control that CAN fill its row must be seen by this walker. The
123
+ // three `=>`-carrying tags above are the reason this number is asserted.
124
+ expect(controls.length).toBeGreaterThanOrEqual(11)
125
+ for (const expected of ['SvDropDownList', 'SvComboBox', 'SvMaskedInput', 'SvPhoneInput'])
126
+ expect(controls.map((c) => c.name)).toContain(expected)
127
+ })
128
+
129
+ it.each(controls.map((c) => [c.name, c.props]))('%s is mounted block', (name, props) => {
130
+ expect(props, `<${name}> in SvForm is missing block`).toMatch(/(^|\s)block(\s|$|=)/)
131
+ })
132
+ })
@@ -35,6 +35,17 @@ export type CellEditorOption = { value: string | number; label: string; color?:
35
35
  export type ParseEditorValueOptions = {
36
36
  /** When true, list/chips return an array; otherwise a scalar. */
37
37
  multiple?: boolean
38
+ /**
39
+ * Keep a date as the plain `YYYY-MM-DD` string the user picked, instead of a
40
+ * full ISO timestamp. Set for `cellDataType: 'dateString'` columns, which
41
+ * exist precisely to hold ISO date strings.
42
+ *
43
+ * It is not only about shape. `new Date('2026-12-25').toISOString()` sends
44
+ * the value through UTC, so a local midnight becomes the previous or next
45
+ * day either side of the meridian - the stored DATE changes depending on
46
+ * where the user is sitting. Slicing the picked date avoids that entirely.
47
+ */
48
+ dateOnly?: boolean
38
49
  }
39
50
 
40
51
  /** Normalize the loose `editorOptions` ColumnDef prop into a uniform shape.
@@ -83,7 +94,22 @@ export function parseEditorValue(
83
94
  return s || '#000000'
84
95
  }
85
96
  if (type === 'date') {
86
- const date = new Date(String(value))
97
+ const raw = String(value ?? '')
98
+ if (opts?.dateOnly) {
99
+ // Take the calendar date the editor produced as-is. It already arrives
100
+ // as `YYYY-MM-DD` (or that prefix on a datetime), so there is nothing to
101
+ // convert - and converting is exactly what shifts the day across a
102
+ // timezone boundary.
103
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(raw.trim())
104
+ if (match) return match[1]
105
+ const parsed = new Date(raw)
106
+ if (Number.isNaN(parsed.getTime())) return null
107
+ // A non-ISO input (a locale string) still has to become a date string.
108
+ // Use the LOCAL parts, so the day is the one the user saw.
109
+ const pad = (n: number) => String(n).padStart(2, '0')
110
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`
111
+ }
112
+ const date = new Date(raw)
87
113
  return Number.isNaN(date.getTime()) ? null : date.toISOString()
88
114
  }
89
115
  if (type === 'datetime') {
@@ -78,10 +78,27 @@ export type GridMessages = {
78
78
  menuRemoveRow: string
79
79
  menuRemoveColumn: string
80
80
  menuEditComment: string
81
+ // Selection bar
82
+ selectionBarLabel: string
83
+ selectionBarCount: string
84
+ selectionBarClear: string
85
+ selectionBarMore: string
86
+ selectionBarSelectAll: string
87
+ selectionBarEditFields: string
88
+ // Bulk edit dialog
89
+ bulkEditTitle: string
90
+ bulkEditLead: string
91
+ bulkEditMixed: string
92
+ bulkEditApply: string
93
+ bulkEditCancel: string
94
+ bulkEditNoFields: string
81
95
  chartRange: string
82
96
  // Upsell notes (enterprise views not installed)
83
97
  pivotUpsellTitle: string
84
98
  pivotUpsellBody: string
99
+ /** Shared licensing line under every Enterprise upsell note. */
100
+ upsellLicense: string
101
+ upsellLicenseLink: string
85
102
  }
86
103
 
87
104
  /** English defaults - the literal strings the grid shipped before localization. */
@@ -124,6 +141,20 @@ export const defaultGridMessages: GridMessages = {
124
141
  menuRemoveRow: 'Remove row',
125
142
  menuRemoveColumn: 'Remove column',
126
143
  menuEditComment: 'Edit comment',
144
+ selectionBarLabel: 'Selection actions',
145
+ /** Label beside the count CHIP, which carries the number itself. */
146
+ selectionBarCount: 'selected',
147
+ selectionBarClear: 'Clear selection',
148
+ selectionBarMore: 'More actions',
149
+ selectionBarSelectAll: 'Select all',
150
+ selectionBarEditFields: 'Edit fields',
151
+ bulkEditTitle: 'Edit fields',
152
+ bulkEditLead:
153
+ 'Changes apply to all {count} selected items. Fields you leave alone keep their existing values.',
154
+ bulkEditMixed: 'Multiple values',
155
+ bulkEditApply: 'Apply to {count} items',
156
+ bulkEditCancel: 'Cancel',
157
+ bulkEditNoFields: 'No editable fields on this grid.',
127
158
  opContains: 'Contains',
128
159
  opNotContains: 'Not contains',
129
160
  opEquals: 'Equals',
@@ -143,6 +174,9 @@ export const defaultGridMessages: GridMessages = {
143
174
  chartRange: 'Chart selected range',
144
175
  pivotUpsellTitle: 'Pivot mode',
145
176
  pivotUpsellBody: 'Pivot mode is an Enterprise feature. Install @svgrid/enterprise and call enablePivot() to use it.',
177
+ upsellLicense:
178
+ 'A license key is required for production use. Without one the feature still works, but the grid renders an unlicensed watermark.',
179
+ upsellLicenseLink: 'View licensing',
146
180
  }
147
181
 
148
182
  /**
package/src/index.ts CHANGED
@@ -80,6 +80,15 @@ export { default as SvGrid } from './SvGrid.svelte'
80
80
  // through this seam (see board-view.svelte.ts). Mirrors the scheduler-view seam.
81
81
  export type { BoardConfig, BoardLane, BoardCardMoveEvent, BoardCardCommitEvent, BoardLayout, BoardSubtask, BoardComment, BoardMenuContext, BoardLaneMenuContext, BoardDrawerConfig } from './SvGrid.types'
82
82
  export { registerBoardView, getBoardView, hasBoardView } from './board-view.svelte'
83
+ // Bulk-action bar over the row selection (`selectionBar`), and the right-click
84
+ // menu items - both are prop-shaped config a consumer has to be able to name
85
+ // when they lift the array into its own module.
86
+ export type {
87
+ SelectionBarAction,
88
+ SelectionBarTarget,
89
+ ContextMenuItem,
90
+ ContextMenuTarget,
91
+ } from './SvGrid.types'
83
92
  // Built-in AI helpers (free): natural-language filter, smart-fill, summarize,
84
93
  // classify, anomaly detection, chart-this and export planning. They ship no
85
94
  // model client and no-op until you register one with `setAIProvider(...)`, so
@@ -111,6 +120,8 @@ export type {
111
120
  SchedulerOccurrenceChangeEvent,
112
121
  } from './SvGrid.types'
113
122
  export { registerSchedulerView, getSchedulerView, hasSchedulerView } from './scheduler-view.svelte'
123
+ // Bulk-action bar over the row selection. Prop + types free, renderer Pro.
124
+ export { registerSelectionBarView, getSelectionBarView, hasSelectionBarView } from './selection-bar-view.svelte'
114
125
  // Chart view. The `chart` prop + its config live in the free grid AND so does the
115
126
  // renderer (SvGridChartView wraps the free SvChart) - so unlike board/scheduler
116
127
  // it works out of the box. This seam only lets a host OVERRIDE the built-in view.
@@ -254,6 +265,7 @@ export {
254
265
  type CellEditorRegistration,
255
266
  } from './editor-registry'
256
267
  export { registerBuiltinEditors } from './builtin-editors'
268
+ export { sanitizeHtml, renderMarkdown, htmlToText } from './sanitize-html'
257
269
  // UI kit - shared field chrome (label + hint + error + direction wrapper)
258
270
  export { default as SvField } from './SvField.svelte'
259
271
  // UI kit - headless cores (state + prop-getters you render yourself, like createSvGrid)
@@ -441,6 +453,7 @@ export { default as SvAvatarGroup } from './SvAvatarGroup.svelte'
441
453
  export { default as SvCommand, type CommandItem } from './SvCommand.svelte'
442
454
  export { fuzzyScore, fuzzyMatch } from './fuzzy'
443
455
  export { default as SvRichText, type RichTextTool } from './SvRichText.svelte'
456
+ export { default as SvRichCell } from './SvRichCell.svelte'
444
457
  export { default as SvCarousel } from './SvCarousel.svelte'
445
458
  export { default as SvTour, type TourStep } from './SvTour.svelte'
446
459
  export { default as SvPagination } from './SvPagination.svelte'
@@ -7,6 +7,12 @@ export type ListOption = {
7
7
  group?: string
8
8
  /** Optional color swatch (any CSS color) shown before the label. */
9
9
  color?: string
10
+ /**
11
+ * Optional muted text pinned to the trailing edge of the row - a count, a
12
+ * shortcut, a unit. Not part of the label, so it does not affect matching
13
+ * or the accessible name.
14
+ */
15
+ hint?: string
10
16
  }
11
17
 
12
18
  /**
package/src/menus.test.ts CHANGED
@@ -30,6 +30,11 @@ function makeCtx(overrides: any = {}) {
30
30
  commentDraft: '',
31
31
  noteOverrides: {},
32
32
  columnMenuFacetValues: [],
33
+ // The values currently SHOWN, i.e. after the search box narrows the list.
34
+ // Equal to `columnMenuFacetValues` when nothing is typed; the facet
35
+ // select-all reads both to tell "all" from "all results" apart.
36
+ columnMenuVisibleFacets: overrides.columnMenuVisibleFacets ??
37
+ overrides.columnMenuFacetValues ?? [],
33
38
  showColumnFiltersEffective: true,
34
39
  showFilterRowEffective: false,
35
40
  scrollContainer: null,
@@ -429,6 +434,44 @@ describe('createMenus - facet checkboxes', () => {
429
434
  m.toggleAllFacets('team') // was subset -> remove filter (all)
430
435
  expect(ctx.valueFilters.team).toBeUndefined()
431
436
  })
437
+
438
+ describe('with a search query narrowing the list', () => {
439
+ // "Select all" means the RESULTS while a query is typed, the way it does
440
+ // in a spreadsheet. It used to mean the whole column regardless: with
441
+ // everything ticked (the default) it read as "on" and the click cleared
442
+ // the entire selection instead of keeping the matches.
443
+ const searched = (extra: any = {}) =>
444
+ makeCtx({
445
+ columnMenuFacetValues: ['Ada', 'Alan', 'Bram'],
446
+ columnMenuVisibleFacets: ['Ada', 'Alan'],
447
+ ...extra,
448
+ })
449
+
450
+ it('selects only the matches instead of clearing everything', () => {
451
+ const ctx = searched()
452
+ const m = createMenus(ctx)
453
+ // Default state is "all ticked", so the box shows checked for the
454
+ // matches; clicking it unticks THEM and leaves the rest alone.
455
+ expect(m.isAllFacetsChecked('team')).toBe(true)
456
+ m.toggleAllFacets('team')
457
+ expect([...ctx.valueFilters.team].sort()).toEqual(['Bram'])
458
+ })
459
+
460
+ it('ticking it back adds the matches to the existing selection', () => {
461
+ const ctx = searched({ valueFilters: { team: new Set(['Bram']) } })
462
+ const m = createMenus(ctx)
463
+ expect(m.isAllFacetsChecked('team')).toBe(false)
464
+ m.toggleAllFacets('team')
465
+ expect([...ctx.valueFilters.team].sort()).toEqual(['Ada', 'Alan', 'Bram'])
466
+ })
467
+
468
+ it('reports checked only when every match is selected', () => {
469
+ const partial = searched({ valueFilters: { team: new Set(['Ada']) } })
470
+ expect(createMenus(partial).isAllFacetsChecked('team')).toBe(false)
471
+ const both = searched({ valueFilters: { team: new Set(['Ada', 'Alan']) } })
472
+ expect(createMenus(both).isAllFacetsChecked('team')).toBe(true)
473
+ })
474
+ })
432
475
  })
433
476
 
434
477
  describe('createMenus - clearColumnFilter', () => {
package/src/menus.ts CHANGED
@@ -294,19 +294,49 @@ export function createMenus<
294
294
  ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set(next) };
295
295
  }
296
296
 
297
+ /**
298
+ * With a search query typed, "select all" means the RESULTS - the same thing
299
+ * it means in a spreadsheet. It used to mean the whole column regardless, so
300
+ * the standard flow (type a query, tick select-all, get just the matches)
301
+ * cleared the entire selection instead: `isAllFacetsChecked` saw every value
302
+ * ticked, read that as "on", and turned everything off.
303
+ */
297
304
  function isAllFacetsChecked(columnId: string) {
298
305
  const selected = ctx.valueFilters[columnId];
299
- return !selected || selected.size >= ctx.columnMenuFacetValues.length;
306
+ const visible = ctx.columnMenuVisibleFacets as Array<string>;
307
+ const searching = visible.length !== ctx.columnMenuFacetValues.length;
308
+ if (!selected) return true;
309
+ if (searching) return visible.every((value) => selected.has(value));
310
+ return selected.size >= ctx.columnMenuFacetValues.length;
300
311
  }
301
312
 
302
313
  function toggleAllFacets(columnId: string) {
303
- if (isAllFacetsChecked(columnId)) {
304
- ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set<string>() };
305
- } else {
306
- const copy = { ...ctx.valueFilters };
307
- delete copy[columnId];
308
- ctx.valueFilters = copy;
314
+ const visible = ctx.columnMenuVisibleFacets as Array<string>;
315
+ const all = ctx.columnMenuFacetValues as Array<string>;
316
+ const searching = visible.length !== all.length;
317
+ const on = isAllFacetsChecked(columnId);
318
+
319
+ if (!searching) {
320
+ if (on) {
321
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: new Set<string>() };
322
+ } else {
323
+ const copy = { ...ctx.valueFilters };
324
+ delete copy[columnId];
325
+ ctx.valueFilters = copy;
326
+ }
327
+ return;
328
+ }
329
+ // Searching: add or remove just the matches, leaving the rest of the
330
+ // selection alone. An absent entry means "everything", so it has to be
331
+ // materialized before a subtraction can mean anything.
332
+ const current = ctx.valueFilters[columnId]
333
+ ? new Set<string>(ctx.valueFilters[columnId])
334
+ : new Set<string>(all);
335
+ for (const value of visible) {
336
+ if (on) current.delete(value);
337
+ else current.add(value);
309
338
  }
339
+ ctx.valueFilters = { ...ctx.valueFilters, [columnId]: current };
310
340
  }
311
341
 
312
342
  function clearColumnFilter(columnId: string) {