@lavalogic/scoria 0.40.1 → 0.40.2

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 (137) hide show
  1. package/README.md +58 -58
  2. package/dist/Components/AccordionGroup.svelte +46 -46
  3. package/dist/Components/AccordionGroupButton.svelte +76 -76
  4. package/dist/Components/Action.svelte +136 -136
  5. package/dist/Components/AlertModal.svelte +195 -195
  6. package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +729 -729
  7. package/dist/Components/Base/BaseModal.svelte +125 -125
  8. package/dist/Components/Base/ContextWrapper.svelte +72 -72
  9. package/dist/Components/Base/Pagination.svelte +198 -198
  10. package/dist/Components/Base/Snippets.svelte +36 -36
  11. package/dist/Components/BigRadioSet.svelte +38 -38
  12. package/dist/Components/Bubble.svelte +101 -101
  13. package/dist/Components/Button.svelte +209 -209
  14. package/dist/Components/Checkbox.svelte +86 -86
  15. package/dist/Components/CollapsibleCard.svelte +78 -78
  16. package/dist/Components/Contexts/ContextMenu.svelte +164 -164
  17. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  18. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  19. package/dist/Components/Contexts/Toast.svelte +86 -86
  20. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  21. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  22. package/dist/Components/DataMatrixIcon.svelte +112 -112
  23. package/dist/Components/DateInput.svelte +148 -148
  24. package/dist/Components/DatePicker.svelte +300 -300
  25. package/dist/Components/DesktopModal.svelte +90 -90
  26. package/dist/Components/Dial.svelte +204 -204
  27. package/dist/Components/DimensionInput.svelte +170 -170
  28. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  29. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  30. package/dist/Components/FileCard.svelte +24 -24
  31. package/dist/Components/FileUpload.svelte +236 -236
  32. package/dist/Components/GridInput.svelte +196 -196
  33. package/dist/Components/GridInputSet.svelte +29 -29
  34. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  35. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  36. package/dist/Components/Icon.svelte +128 -128
  37. package/dist/Components/Icons.js +357 -357
  38. package/dist/Components/InfoAlert.svelte +35 -35
  39. package/dist/Components/LoadingAnimation.svelte +29 -29
  40. package/dist/Components/LoadingModal.svelte +45 -45
  41. package/dist/Components/LoadingOverlay.svelte +25 -25
  42. package/dist/Components/Modal.svelte +258 -258
  43. package/dist/Components/MultiSelect.svelte +220 -220
  44. package/dist/Components/Nav/Nav.svelte +110 -110
  45. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  46. package/dist/Components/Nav/NavTab.svelte +62 -62
  47. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  48. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  49. package/dist/Components/NumberInput.svelte +157 -157
  50. package/dist/Components/PageHeading.svelte +25 -25
  51. package/dist/Components/PasswordInput.svelte +88 -88
  52. package/dist/Components/Portal.svelte +36 -36
  53. package/dist/Components/ProgressBubble.svelte +107 -107
  54. package/dist/Components/QuerySelect.svelte +366 -366
  55. package/dist/Components/SingleSelect.svelte +250 -250
  56. package/dist/Components/StepButton.svelte +70 -70
  57. package/dist/Components/StepForm.svelte +140 -140
  58. package/dist/Components/Switch.svelte +69 -69
  59. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  60. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  61. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  62. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  63. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  64. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  65. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  66. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  67. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +131 -120
  68. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  69. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  70. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  71. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  72. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  73. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  74. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  75. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  76. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  77. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  78. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  79. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  80. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  81. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  82. package/dist/Components/Table/ColumnFactory.svelte.js +1 -0
  83. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  84. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  85. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  86. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  87. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  88. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  89. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  90. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  91. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  92. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  93. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  94. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  95. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  96. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  97. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  98. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  99. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  100. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  101. package/dist/Components/Table/NestedTable.svelte +130 -130
  102. package/dist/Components/Table/Table.svelte +692 -692
  103. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.d.ts +5 -0
  104. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.js +3 -0
  105. package/dist/Components/Table/Types/Public/ColumnOptions.d.ts +10 -0
  106. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  107. package/dist/Components/TextArea.svelte +130 -130
  108. package/dist/Components/TextInput.svelte +176 -176
  109. package/dist/Components/ThreeStateRadio.svelte +131 -131
  110. package/dist/Components/Touch/BoolCard.svelte +45 -45
  111. package/dist/Components/Touch/DateModal.svelte +209 -209
  112. package/dist/Components/Touch/EditCard.svelte +59 -59
  113. package/dist/Components/Touch/InfoCard.svelte +31 -31
  114. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  115. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  116. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  117. package/dist/Components/Touch/NumberModal.svelte +79 -79
  118. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  119. package/dist/Components/Touch/SelectModal.svelte +127 -127
  120. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  121. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  122. package/dist/Components/Touch/TextModal.svelte +106 -106
  123. package/dist/Components/Touch/TouchCard.svelte +33 -33
  124. package/dist/Components/Touch/TouchModal.svelte +274 -274
  125. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  126. package/dist/Components/VerticalTabGroup.svelte +79 -79
  127. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  128. package/dist/Helpers/Datamatrix.d.ts +19 -19
  129. package/dist/Helpers/Datamatrix.js +291 -291
  130. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  131. package/dist/scss/_basics.scss +12 -12
  132. package/dist/scss/_colours.scss +134 -134
  133. package/dist/scss/_mixins.scss +3392 -3392
  134. package/dist/scss/_motion.scss +57 -57
  135. package/dist/scss/_sizing.scss +85 -85
  136. package/dist/scss/_transitions.scss +8 -8
  137. package/package.json +1 -1
@@ -1,643 +1,643 @@
1
- <svelte:options runes />
2
-
3
- <script
4
- lang="ts"
5
- generics="T extends object, IdType extends Primitive, FilterValueType"
6
- >
7
- import { dev } from '$app/environment';
8
- import Button from '../../Button.svelte';
9
- import MultiSelect from '../../MultiSelect.svelte';
10
- import NumberInput from '../../NumberInput.svelte';
11
- import QuerySelect from '../../QuerySelect.svelte';
12
- import SingleSelect from '../../SingleSelect.svelte';
13
- import TextInput from '../../TextInput.svelte';
14
- import DateModal from '../../Touch/DateModal.svelte';
15
- import { hasQuery } from '../TableHelpers.svelte.js';
16
- import { devCatch, isDateTuple, isNumericArray } from '../../../Helpers/Helpers.svelte.js';
17
- import type { SelectOption } from '../../../Types/Internal/SelectOption.js';
18
- import { getContext, onMount, untrack } from 'svelte';
19
- import { CheckboxDef } from '../Types/Columns/Definitions/Accessors/CheckboxDef.svelte.js';
20
- import { DateInputDef } from '../Types/Columns/Definitions/Accessors/DateInputDef.svelte.js';
21
- import { NumberInputDef } from '../Types/Columns/Definitions/Accessors/NumberInputDef.svelte.js';
22
- import { SelectDef } from '../Types/Columns/Definitions/Accessors/SelectDef.svelte.js';
23
- import { TableContext } from '../Types/Context/TableContext.svelte.js';
24
- import type { Primitive } from '../Types/Context/TableInitOptions.js';
25
- import { CustomFilterType } from '../Types/Filtering/CustomFilterType.js';
26
- import { NumberDateFilterMode } from '../Types/Filtering/NumberDateFilterMode.js';
27
- import { StringFilterMode } from '../Types/Filtering/StringFilterMode.js';
28
- import type { FilterInputProps } from './FilterInputProps.js';
29
- import { numberDateFilterTypes } from './numberDateFilterTypes.js';
30
- import { stringFilterTypes } from './stringFilterTypes.js';
31
-
32
- const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
33
-
34
- const { def, customFilters }: FilterInputProps<T, FilterValueType> = $props();
35
-
36
- const customFilter = $derived(customFilters?.get(def.id));
37
-
38
- /** Human-readable label used by every inner input (QuerySelect /
39
- * MultiSelect / SingleSelect / etc.). Priority:
40
- * 1. The column's `header` (when it is a plain string, which is the
41
- * usual case in flowms migrations).
42
- * 2. The customFilter's `label` (set on the remoteFilters entry).
43
- * 3. The raw `customFilter.id` (last-ditch fallback).
44
- * Avoids surfacing dotted-path / camelCase IDs like
45
- * `orderLines.allowSplitFulfilment` as the visible label. */
46
- const displayLabel: string = $derived.by(() => {
47
- if (typeof def.header === 'string' && def.header.length > 0) {
48
- return def.header;
49
- }
50
- if (customFilter?.label) {
51
- return customFilter.label;
52
- }
53
- return customFilter?.id ?? def.id;
54
- });
55
-
56
- const isRemoteSelect = $derived(customFilter?.type === CustomFilterType.Select);
57
-
58
- let value: unknown = $state(null);
59
- let valueMin: number | null = $state(null);
60
- let valueMax: number | null = $state(null);
61
-
62
- let selectedOptions: Array<SelectOption<unknown>> = $state([]);
63
- let lastQueryOptions: Array<SelectOption<unknown>> = $state([]);
64
-
65
- const isNumeric = $derived(
66
- def instanceof NumberInputDef || customFilter?.type === CustomFilterType.Number
67
- );
68
-
69
- const hasFilterFn = $derived(!!def.filterFn && typeof def.filterFn === 'function');
70
-
71
- // A column is filterable when it has any of:
72
- // - a local `filterFn` (AccessorDef subclasses, or Display/Bubble/
73
- // Progress columns whose data repo is local),
74
- // - a declared `filterValueType` (server-side filtering — the UI
75
- // renders an input matching the declared type),
76
- // - a `customFilters` entry from `defsWrapper.remoteFilters`.
77
- // The `filterValueType` case is required for remote-pagination tables:
78
- // their data repo never assigns `filterFn`, so without it every typed
79
- // column would silently disappear from the filter UI.
80
- const hasDeclaredFilterType = $derived(def.filterValueType != null);
81
-
82
- const isSearchable = $derived(
83
- def.allowFiltering &&
84
- (hasFilterFn || hasDeclaredFilterType || customFilters?.has(def.id) == true)
85
- );
86
-
87
- const isSelect = $derived(
88
- def instanceof SelectDef || customFilter?.type === CustomFilterType.Select
89
- );
90
-
91
- const options = $derived(isSelect ? (def.getOptions?.() ?? customFilter?.options ?? []) : []);
92
-
93
- let optionsPromise: Array<SelectOption<unknown>> | Promise<Array<SelectOption<unknown>>> = $state(
94
- [] as Array<SelectOption<unknown>> | Promise<Array<SelectOption<unknown>>>
95
- );
96
-
97
- onMount(() => {
98
- retryOptionsPromise();
99
- });
100
-
101
- function retryOptionsPromise() {
102
- optionsPromise = def instanceof SelectDef ? def.getOptions() : [];
103
- }
104
-
105
- const filteringStateValue = $derived(
106
- tableContext.paginationRepo?.filtering.find((it) => it.id === def.id)?.value
107
- );
108
-
109
- $effect(() => {
110
- if (isRemoteSelect && !tableContext.paginationRepo?.filtering) {
111
- return;
112
- }
113
- void filteringStateValue;
114
- if (def.debug) {
115
- console.debug(
116
- `${def.id}: `,
117
- 'FILTER INPUT: filtering state value:',
118
- $state.snapshot(filteringStateValue)
119
- );
120
- }
121
-
122
- if (hasQuery(customFilter)) {
123
- if (def.debug) {
124
- console.debug(`${def.id}: `, 'has query');
125
- }
126
-
127
- untrack(() => {
128
- try {
129
- if (filteringStateValue == undefined) {
130
- // Filter cleared externally (saved view applied with an
131
- // empty filter, or "Clear All" hit). Drop the typeahead
132
- // echo so the next selection isn't masked by a stale
133
- // cache, and clear the local value so the chip clears.
134
- if (def.debug) {
135
- console.debug(
136
- `${def.id}: filtering state value was undefined`,
137
- $state.snapshot(tableContext.paginationRepo?.filtering)
138
- );
139
- }
140
- value = null;
141
- tableContext.queryValues.delete(def);
142
- return;
143
- }
144
-
145
- if (def.debug) {
146
- console.debug(`${def.id}: query values:`, tableContext.queryValues.snapshot());
147
- }
148
- // Prefer the typeahead echo — it carries the real label
149
- // from the option the user clicked. Fall back to the saved
150
- // primitive (the usual case after applying a saved view)
151
- // so the chip is at least visible; QuerySelect's own
152
- // `$effect.pre` adds a placeholder option keyed on the
153
- // primitive so it renders.
154
- const cached = tableContext.queryValues.get(def);
155
- if (def.debug) {
156
- console.debug(`${def.id}: cached value:`, $state.snapshot(cached));
157
- }
158
- if (cached != null) {
159
- value = cached;
160
- } else if (customFilter.valueAsObject === true) {
161
- value = {
162
- value: filteringStateValue,
163
- label: String(filteringStateValue),
164
- };
165
- } else {
166
- value = filteringStateValue;
167
- }
168
- } catch (e) {
169
- console.warn(e);
170
- value = null;
171
- }
172
- });
173
- } else if (isSelect) {
174
- if (def.debug) {
175
- console.debug(`${def.id}: is select (not query)`);
176
- }
177
- untrack(async () => {
178
- try {
179
- const opts = await options;
180
- if (def.debug) {
181
- console.debug(`${def.id}: possible options:`, $state.snapshot(options));
182
- }
183
- const x = opts.filter((it) => {
184
- if (it.value != null && (typeof it.value == 'string' || typeof it.value == 'number')) {
185
- return (filteringStateValue as Array<unknown> | undefined)?.includes(it.value);
186
- }
187
- return false;
188
- });
189
- if (def.debug) {
190
- console.debug(`${def.id}: selected options:`, x);
191
- }
192
- selectedOptions = x;
193
- } catch (e) {
194
- if (dev || def.debug) {
195
- console.debug(`${def.id}: `, e);
196
- }
197
- }
198
- }).catch(devCatch);
199
- } else if (isNumeric) {
200
- if (
201
- selectedNumberFilterType?.value === NumberDateFilterMode.Between &&
202
- tableContext.paginationRepo?.filtering
203
- ) {
204
- if (filteringStateValue != undefined) {
205
- const [min, max] = filteringStateValue as [number, number | undefined];
206
- value = min;
207
- valueMin = min;
208
- valueMax = max ?? null;
209
- } else {
210
- value = valueMin = valueMax = null;
211
- }
212
- } else if (tableContext.paginationRepo?.filtering) {
213
- if (filteringStateValue != undefined) {
214
- const [min] = filteringStateValue as [number, number | undefined];
215
- value = min;
216
- valueMin = min;
217
- valueMax = min;
218
- } else {
219
- value = valueMin = valueMax = null;
220
- }
221
- }
222
- } else if (tableContext.paginationRepo?.filtering) {
223
- if (
224
- filteringStateValue == null ||
225
- typeof filteringStateValue == 'string' ||
226
- typeof filteringStateValue == 'number'
227
- ) {
228
- value = filteringStateValue;
229
- } else if (typeof filteringStateValue !== 'boolean') {
230
- console.warn('unknown value type:', $state.snapshot(filteringStateValue));
231
- value = filteringStateValue as string; // HACK attempt to keep production running
232
- }
233
- }
234
- });
235
-
236
- onMount(() => {
237
- const filterOption =
238
- tableContext.paginationRepo?.filtering &&
239
- tableContext.paginationRepo.filtering.find((it) => it.id === def.id);
240
-
241
- if (filterOption) {
242
- if (isNumericArray(filterOption.value)) {
243
- [valueMin, valueMax] = filterOption.value;
244
- if (valueMax == undefined) {
245
- valueMax = valueMin;
246
- } else if (valueMin == undefined) {
247
- valueMin = valueMax;
248
- }
249
- } else if (!hasQuery(customFilter)) {
250
- const gotValue = filterOption.value;
251
- if (gotValue == null || typeof gotValue == 'string' || typeof gotValue == 'number') {
252
- value = gotValue;
253
- } else if (typeof filteringStateValue !== 'boolean') {
254
- console.warn('unknown value type:', $state.snapshot(gotValue));
255
- value = gotValue as string; // HACK attempt to keep production running
256
- }
257
- }
258
- }
259
- });
260
-
261
- const filterMode = $derived(tableContext.paginationRepo?.filterModes.get(def.id));
262
- const selectedNumberFilterType: SelectOption<NumberDateFilterMode> | null = $derived.by(() => {
263
- const value = (filterMode as NumberDateFilterMode | undefined) ?? NumberDateFilterMode.In;
264
- return numberDateFilterTypes.find((it) => it.value === value) ?? null;
265
- });
266
- const selectedStringFilterType: SelectOption<StringFilterMode> | null = $derived.by(() => {
267
- const value = (filterMode as StringFilterMode | undefined) ?? StringFilterMode.Contains;
268
- return stringFilterTypes.find((it) => it.value === value) ?? null;
269
- });
270
-
271
- const inputId: string = $derived(`${tableContext.tableName}-${def.id}-filter-type`);
272
-
273
- let showFromDateModal = $state(false);
274
- let showToDateModal = $state(false);
275
-
276
- /**
277
- * Debounce window for typed filter inputs (Text / Number / Between).
278
- * Discrete selections (selects, date pickers, filter-mode dropdowns)
279
- * skip this and fire `filterBy` immediately. Combined with the 400 ms
280
- * debounce in `createRemoteRowSource`, server-side filtering lands
281
- * roughly a second after the last keystroke.
282
- */
283
- const FILTER_INPUT_DEBOUNCE_MS = 500;
284
-
285
- let typingDebounceTimer: ReturnType<typeof setTimeout> | undefined;
286
-
287
- /** Fire the table's `filterBy` after the user stops typing for
288
- * `FILTER_INPUT_DEBOUNCE_MS`. Repeated calls coalesce; the most
289
- * recent value wins. */
290
- function debouncedFilterBy(id: string, newValue: unknown): void {
291
- if (typingDebounceTimer !== undefined) {
292
- clearTimeout(typingDebounceTimer);
293
- }
294
- typingDebounceTimer = setTimeout(() => {
295
- typingDebounceTimer = undefined;
296
- tableContext.paginationRepo?.filterBy(id, newValue);
297
- }, FILTER_INPUT_DEBOUNCE_MS);
298
- }
299
-
300
- // Clear any pending debounce when the row unmounts so we do not
301
- // fire `filterBy` against a torn-down `paginationRepo`.
302
- $effect(() => {
303
- return () => {
304
- if (typingDebounceTimer !== undefined) {
305
- clearTimeout(typingDebounceTimer);
306
- typingDebounceTimer = undefined;
307
- }
308
- };
309
- });
310
-
311
- function generateOnChangeObject(_label: string) {
312
- return (newValue: SelectOption<unknown> | null) => {
313
- tableContext.paginationRepo?.filterBy(def.id, newValue?.value ?? null);
314
- tableContext.queryValues.set(def, newValue ?? null);
315
- value = newValue ?? null;
316
- };
317
- }
318
-
319
- function generateOnChangePrimitive(_label: string) {
320
- // AsPrimitive QuerySelect hands us the primitive value directly.
321
- return (newValue: unknown) => {
322
- tableContext.paginationRepo?.filterBy(def.id, newValue ?? null);
323
- tableContext.queryValues.set(def, newValue ?? null);
324
- value = newValue ?? null;
325
- };
326
- }
327
- </script>
328
-
329
- {#if isSearchable}
330
- <!-- TODO remove this check once datePicker is supported -->
331
- {#if def instanceof DateInputDef || def instanceof CheckboxDef}
332
- <label for={inputId}>{displayLabel}</label>
333
- {/if}
334
- <div class="row">
335
- {#if def instanceof DateInputDef}
336
- {@const dateValues =
337
- tableContext.paginationRepo?.filtering.find((it) => it.id === def.id)?.value ?? []}
338
- {#if isDateTuple(dateValues)}
339
- {@const [fromDate, toDate] = dateValues}
340
- <div class="spaced-around">
341
- <Button
342
- onclick={() => {
343
- showFromDateModal = true;
344
- }}>From {fromDate?.toLocaleDateString() ?? 'Any Date'}</Button
345
- >
346
-
347
- <Button
348
- onclick={() => {
349
- showToDateModal = true;
350
- }}>To {toDate?.toLocaleDateString() ?? 'Any Date'}</Button
351
- >
352
- </div>
353
-
354
- {#if showFromDateModal}
355
- <DateModal
356
- header="Edit Date From"
357
- value={fromDate}
358
- cancelEditing={() => {
359
- showFromDateModal = false;
360
- }}
361
- confirm={(e) => {
362
- tableContext.paginationRepo?.filterBy(def.id, [e, toDate]);
363
- showFromDateModal = false;
364
- }}
365
- />
366
- {/if}
367
-
368
- {#if showToDateModal}
369
- <DateModal
370
- inclusive
371
- header="Edit Date To"
372
- value={toDate}
373
- cancelEditing={() => {
374
- showToDateModal = false;
375
- }}
376
- confirm={(e) => {
377
- tableContext.paginationRepo?.filterBy(def.id, [fromDate, e]);
378
- showToDateModal = false;
379
- }}
380
- />
381
- {/if}
382
- {/if}
383
- {:else if def instanceof SelectDef && !isRemoteSelect}
384
- <div class="maxwidth">
385
- {#await optionsPromise then options}
386
- {@const selectedPrimitives = selectedOptions
387
- .map((it) => it.value)
388
- .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')}
389
- <MultiSelect
390
- label={customFilter?.id ??
391
- customFilter?.label ??
392
- customFilter?.objectName ??
393
- customFilter?.queryKey ??
394
- def.id}
395
- labelProp="label"
396
- valueProp="value"
397
- name={customFilter?.objectName ?? 'Item'}
398
- {options}
399
- valueAsObject={false}
400
- onchange={(e: Array<unknown>) => {
401
- // Phase 7.6: `valueAsObject={false}` returns
402
- // primitives directly.
403
- tableContext.paginationRepo?.filterBy(def.id, e);
404
- }}
405
- selectedValues={selectedPrimitives}
406
- collapseSelection
407
- />
408
- {:catch e}
409
- <span>Error retrieving options: {e.message}</span>
410
- <Button
411
- onclick={() => {
412
- retryOptionsPromise();
413
- }}>Retry</Button
414
- >
415
- {/await}
416
- </div>
417
- {:else if customFilter && isRemoteSelect}
418
- <div class="contents">
419
- {#await options then resolvedOptions}
420
- {@const options = resolvedOptions as Array<SelectOption<unknown>>}
421
- {#if hasQuery(customFilter)}
422
- {#if customFilter.valueAsObject !== true}
423
- {@const label = displayLabel}
424
-
425
- <!-- TODO: multiselect -->
426
-
427
- <QuerySelect
428
- valueAsObject={false}
429
- {label}
430
- clearable
431
- name={customFilter.objectName ?? 'Item'}
432
- valueProp={'value' as never}
433
- labelProp={'label' as never}
434
- value={value as never}
435
- query={customFilter.query}
436
- bind:options={lastQueryOptions as never}
437
- onchange={generateOnChangePrimitive(label)}
438
- />
439
- {:else}
440
- {@const label = displayLabel}
441
-
442
- <!-- TODO: multiselect -->
443
-
444
- <QuerySelect
445
- valueAsObject={true}
446
- {label}
447
- clearable
448
- name={customFilter.objectName ?? 'Item'}
449
- valueProp="value"
450
- labelProp="label"
451
- value={value as null}
452
- query={customFilter.query}
453
- bind:options={lastQueryOptions}
454
- onchange={generateOnChangeObject(label)}
455
- />
456
- {/if}
457
- {:else}
458
- {@const selectedPrimitives = selectedOptions
459
- .map((it) => it.value)
460
- .filter(
461
- (v): v is string | number => typeof v === 'string' || typeof v === 'number'
462
- )}
463
- <!--
464
- With `valueAsObject={false}`, MultiSelect compares
465
- its `selectedValues` against options by primitive
466
- value, so we map `selectedOptions` to their
467
- primitive `.value` rather than passing the
468
- SelectOption objects directly.
469
- -->
470
- <MultiSelect
471
- label={displayLabel}
472
- labelProp="label"
473
- valueProp="value"
474
- name={customFilter.objectName ?? 'Item'}
475
- {options}
476
- valueAsObject={false}
477
- onchange={(e: Array<unknown>) => {
478
- // Phase 7.6: `valueAsObject={false}` returns
479
- // primitives directly.
480
- tableContext.paginationRepo?.filterBy(def.id, e);
481
- }}
482
- selectedValues={selectedPrimitives}
483
- collapseSelection
484
- />
485
- {/if}
486
- {/await}
487
- </div>
488
- {:else if def instanceof CheckboxDef}
489
- <div class="inner-row">
490
- <label>
491
- <input
492
- value={false}
493
- type="radio"
494
- bind:group={value}
495
- onchange={() => {
496
- tableContext.paginationRepo?.filterBy(def.id, value);
497
- }}
498
- />
499
- No
500
- </label>
501
- <label>
502
- <input
503
- value={undefined}
504
- type="radio"
505
- bind:group={value}
506
- onchange={() => {
507
- tableContext.paginationRepo?.filterBy(def.id, value);
508
- }}
509
- />
510
- Any
511
- </label>
512
- <label>
513
- <input
514
- value={true}
515
- type="radio"
516
- bind:group={value}
517
- onchange={() => {
518
- tableContext.paginationRepo?.filterBy(def.id, value);
519
- }}
520
- />
521
- Yes
522
- </label>
523
- </div>
524
- {:else if isNumeric}
525
- {@const label = typeof def.header === 'string' ? def.header : undefined}
526
- <div class="left">
527
- <SingleSelect
528
- {label}
529
- name="Filter Type"
530
- options={numberDateFilterTypes}
531
- labelProp="label"
532
- valueProp="value"
533
- valueAsObject
534
- value={selectedNumberFilterType}
535
- onchange={(newValue: SelectOption<NumberDateFilterMode> | null) => {
536
- const oldFilterType = selectedNumberFilterType;
537
-
538
- const newFilterType = newValue?.value ?? NumberDateFilterMode.In;
539
- tableContext.paginationRepo?.setFilterMode(def.id, newFilterType);
540
-
541
- if (selectedNumberFilterType?.value !== oldFilterType?.value) {
542
- if (oldFilterType?.value === NumberDateFilterMode.Between) {
543
- valueMax = null;
544
- tableContext.paginationRepo?.filterBy(def.id, [valueMin, valueMin]);
545
- }
546
-
547
- if (newFilterType === NumberDateFilterMode.In) {
548
- tableContext.paginationRepo?.filterBy(def.id, [value]);
549
- }
550
-
551
- // TODO what about the other modes?
552
- }
553
- }}
554
- onkeydown={(e: KeyboardEvent) => {
555
- e.stopPropagation();
556
- }}
557
- />
558
- </div>
559
- <div class="right">
560
- {#if selectedNumberFilterType?.value === NumberDateFilterMode.Between}
561
- <NumberInput
562
- id={inputId}
563
- bind:value={valueMin}
564
- oninput={() => {
565
- debouncedFilterBy(def.id, [valueMin, valueMax]);
566
- }}
567
- onkeydown={(e: KeyboardEvent) => {
568
- e.stopPropagation();
569
- }}
570
- />
571
- <span>and</span>
572
- <NumberInput
573
- id={inputId}
574
- bind:value={valueMax}
575
- oninput={() => {
576
- debouncedFilterBy(def.id, [valueMin, valueMax]);
577
- }}
578
- onkeydown={(e: KeyboardEvent) => {
579
- e.stopPropagation();
580
- }}
581
- />
582
- {:else if value == null || typeof value == 'string'}
583
- <TextInput
584
- id={inputId}
585
- bind:value
586
- oninput={() => {
587
- debouncedFilterBy(def.id, [value]);
588
- }}
589
- onkeydown={(e: KeyboardEvent) => {
590
- e.stopPropagation();
591
- }}
592
- />
593
- {:else if dev}
594
- error: {value?.toString()}
595
- <!-- eslint-disable-next-line svelte/no-at-debug-tags -->
596
- {@debug value}
597
- {/if}
598
- </div>
599
- {:else}
600
- {@const label = typeof def.header === 'string' ? def.header : undefined}
601
- <div class="left">
602
- <SingleSelect
603
- name="Filter Type"
604
- valueAsObject
605
- {label}
606
- options={stringFilterTypes}
607
- labelProp="label"
608
- valueProp="value"
609
- value={selectedStringFilterType}
610
- onchange={(newValue: SelectOption<StringFilterMode> | null) => {
611
- const filterType = newValue?.value ?? StringFilterMode.Contains;
612
- tableContext.paginationRepo?.setFilterMode(def.id, filterType);
613
- }}
614
- onkeydown={(e: KeyboardEvent) => {
615
- e.stopPropagation();
616
- }}
617
- />
618
- </div>
619
- <div class="right">
620
- {#if value == null || typeof value == 'string'}
621
- <TextInput
622
- id={inputId}
623
- bind:value
624
- oninput={() => {
625
- debouncedFilterBy(def.id, value);
626
- }}
627
- onkeydown={(e: KeyboardEvent) => {
628
- e.stopPropagation();
629
- }}
630
- />
631
- {:else if dev}
632
- error: {value?.toString()}
633
- <!-- eslint-disable-next-line svelte/no-at-debug-tags -->
634
- {@debug value}
635
- {/if}
636
- </div>
637
- {/if}
638
- </div>
639
- {/if}
640
-
1
+ <svelte:options runes />
2
+
3
+ <script
4
+ lang="ts"
5
+ generics="T extends object, IdType extends Primitive, FilterValueType"
6
+ >
7
+ import { dev } from '$app/environment';
8
+ import Button from '../../Button.svelte';
9
+ import MultiSelect from '../../MultiSelect.svelte';
10
+ import NumberInput from '../../NumberInput.svelte';
11
+ import QuerySelect from '../../QuerySelect.svelte';
12
+ import SingleSelect from '../../SingleSelect.svelte';
13
+ import TextInput from '../../TextInput.svelte';
14
+ import DateModal from '../../Touch/DateModal.svelte';
15
+ import { hasQuery } from '../TableHelpers.svelte.js';
16
+ import { devCatch, isDateTuple, isNumericArray } from '../../../Helpers/Helpers.svelte.js';
17
+ import type { SelectOption } from '../../../Types/Internal/SelectOption.js';
18
+ import { getContext, onMount, untrack } from 'svelte';
19
+ import { CheckboxDef } from '../Types/Columns/Definitions/Accessors/CheckboxDef.svelte.js';
20
+ import { DateInputDef } from '../Types/Columns/Definitions/Accessors/DateInputDef.svelte.js';
21
+ import { NumberInputDef } from '../Types/Columns/Definitions/Accessors/NumberInputDef.svelte.js';
22
+ import { SelectDef } from '../Types/Columns/Definitions/Accessors/SelectDef.svelte.js';
23
+ import { TableContext } from '../Types/Context/TableContext.svelte.js';
24
+ import type { Primitive } from '../Types/Context/TableInitOptions.js';
25
+ import { CustomFilterType } from '../Types/Filtering/CustomFilterType.js';
26
+ import { NumberDateFilterMode } from '../Types/Filtering/NumberDateFilterMode.js';
27
+ import { StringFilterMode } from '../Types/Filtering/StringFilterMode.js';
28
+ import type { FilterInputProps } from './FilterInputProps.js';
29
+ import { numberDateFilterTypes } from './numberDateFilterTypes.js';
30
+ import { stringFilterTypes } from './stringFilterTypes.js';
31
+
32
+ const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
33
+
34
+ const { def, customFilters }: FilterInputProps<T, FilterValueType> = $props();
35
+
36
+ const customFilter = $derived(customFilters?.get(def.id));
37
+
38
+ /** Human-readable label used by every inner input (QuerySelect /
39
+ * MultiSelect / SingleSelect / etc.). Priority:
40
+ * 1. The column's `header` (when it is a plain string, which is the
41
+ * usual case in flowms migrations).
42
+ * 2. The customFilter's `label` (set on the remoteFilters entry).
43
+ * 3. The raw `customFilter.id` (last-ditch fallback).
44
+ * Avoids surfacing dotted-path / camelCase IDs like
45
+ * `orderLines.allowSplitFulfilment` as the visible label. */
46
+ const displayLabel: string = $derived.by(() => {
47
+ if (typeof def.header === 'string' && def.header.length > 0) {
48
+ return def.header;
49
+ }
50
+ if (customFilter?.label) {
51
+ return customFilter.label;
52
+ }
53
+ return customFilter?.id ?? def.id;
54
+ });
55
+
56
+ const isRemoteSelect = $derived(customFilter?.type === CustomFilterType.Select);
57
+
58
+ let value: unknown = $state(null);
59
+ let valueMin: number | null = $state(null);
60
+ let valueMax: number | null = $state(null);
61
+
62
+ let selectedOptions: Array<SelectOption<unknown>> = $state([]);
63
+ let lastQueryOptions: Array<SelectOption<unknown>> = $state([]);
64
+
65
+ const isNumeric = $derived(
66
+ def instanceof NumberInputDef || customFilter?.type === CustomFilterType.Number
67
+ );
68
+
69
+ const hasFilterFn = $derived(!!def.filterFn && typeof def.filterFn === 'function');
70
+
71
+ // A column is filterable when it has any of:
72
+ // - a local `filterFn` (AccessorDef subclasses, or Display/Bubble/
73
+ // Progress columns whose data repo is local),
74
+ // - a declared `filterValueType` (server-side filtering — the UI
75
+ // renders an input matching the declared type),
76
+ // - a `customFilters` entry from `defsWrapper.remoteFilters`.
77
+ // The `filterValueType` case is required for remote-pagination tables:
78
+ // their data repo never assigns `filterFn`, so without it every typed
79
+ // column would silently disappear from the filter UI.
80
+ const hasDeclaredFilterType = $derived(def.filterValueType != null);
81
+
82
+ const isSearchable = $derived(
83
+ def.allowFiltering &&
84
+ (hasFilterFn || hasDeclaredFilterType || customFilters?.has(def.id) == true)
85
+ );
86
+
87
+ const isSelect = $derived(
88
+ def instanceof SelectDef || customFilter?.type === CustomFilterType.Select
89
+ );
90
+
91
+ const options = $derived(isSelect ? (def.getOptions?.() ?? customFilter?.options ?? []) : []);
92
+
93
+ let optionsPromise: Array<SelectOption<unknown>> | Promise<Array<SelectOption<unknown>>> = $state(
94
+ [] as Array<SelectOption<unknown>> | Promise<Array<SelectOption<unknown>>>
95
+ );
96
+
97
+ onMount(() => {
98
+ retryOptionsPromise();
99
+ });
100
+
101
+ function retryOptionsPromise() {
102
+ optionsPromise = def instanceof SelectDef ? def.getOptions() : [];
103
+ }
104
+
105
+ const filteringStateValue = $derived(
106
+ tableContext.paginationRepo?.filtering.find((it) => it.id === def.id)?.value
107
+ );
108
+
109
+ $effect(() => {
110
+ if (isRemoteSelect && !tableContext.paginationRepo?.filtering) {
111
+ return;
112
+ }
113
+ void filteringStateValue;
114
+ if (def.debug) {
115
+ console.debug(
116
+ `${def.id}: `,
117
+ 'FILTER INPUT: filtering state value:',
118
+ $state.snapshot(filteringStateValue)
119
+ );
120
+ }
121
+
122
+ if (hasQuery(customFilter)) {
123
+ if (def.debug) {
124
+ console.debug(`${def.id}: `, 'has query');
125
+ }
126
+
127
+ untrack(() => {
128
+ try {
129
+ if (filteringStateValue == undefined) {
130
+ // Filter cleared externally (saved view applied with an
131
+ // empty filter, or "Clear All" hit). Drop the typeahead
132
+ // echo so the next selection isn't masked by a stale
133
+ // cache, and clear the local value so the chip clears.
134
+ if (def.debug) {
135
+ console.debug(
136
+ `${def.id}: filtering state value was undefined`,
137
+ $state.snapshot(tableContext.paginationRepo?.filtering)
138
+ );
139
+ }
140
+ value = null;
141
+ tableContext.queryValues.delete(def);
142
+ return;
143
+ }
144
+
145
+ if (def.debug) {
146
+ console.debug(`${def.id}: query values:`, tableContext.queryValues.snapshot());
147
+ }
148
+ // Prefer the typeahead echo — it carries the real label
149
+ // from the option the user clicked. Fall back to the saved
150
+ // primitive (the usual case after applying a saved view)
151
+ // so the chip is at least visible; QuerySelect's own
152
+ // `$effect.pre` adds a placeholder option keyed on the
153
+ // primitive so it renders.
154
+ const cached = tableContext.queryValues.get(def);
155
+ if (def.debug) {
156
+ console.debug(`${def.id}: cached value:`, $state.snapshot(cached));
157
+ }
158
+ if (cached != null) {
159
+ value = cached;
160
+ } else if (customFilter.valueAsObject === true) {
161
+ value = {
162
+ value: filteringStateValue,
163
+ label: String(filteringStateValue),
164
+ };
165
+ } else {
166
+ value = filteringStateValue;
167
+ }
168
+ } catch (e) {
169
+ console.warn(e);
170
+ value = null;
171
+ }
172
+ });
173
+ } else if (isSelect) {
174
+ if (def.debug) {
175
+ console.debug(`${def.id}: is select (not query)`);
176
+ }
177
+ untrack(async () => {
178
+ try {
179
+ const opts = await options;
180
+ if (def.debug) {
181
+ console.debug(`${def.id}: possible options:`, $state.snapshot(options));
182
+ }
183
+ const x = opts.filter((it) => {
184
+ if (it.value != null && (typeof it.value == 'string' || typeof it.value == 'number')) {
185
+ return (filteringStateValue as Array<unknown> | undefined)?.includes(it.value);
186
+ }
187
+ return false;
188
+ });
189
+ if (def.debug) {
190
+ console.debug(`${def.id}: selected options:`, x);
191
+ }
192
+ selectedOptions = x;
193
+ } catch (e) {
194
+ if (dev || def.debug) {
195
+ console.debug(`${def.id}: `, e);
196
+ }
197
+ }
198
+ }).catch(devCatch);
199
+ } else if (isNumeric) {
200
+ if (
201
+ selectedNumberFilterType?.value === NumberDateFilterMode.Between &&
202
+ tableContext.paginationRepo?.filtering
203
+ ) {
204
+ if (filteringStateValue != undefined) {
205
+ const [min, max] = filteringStateValue as [number, number | undefined];
206
+ value = min;
207
+ valueMin = min;
208
+ valueMax = max ?? null;
209
+ } else {
210
+ value = valueMin = valueMax = null;
211
+ }
212
+ } else if (tableContext.paginationRepo?.filtering) {
213
+ if (filteringStateValue != undefined) {
214
+ const [min] = filteringStateValue as [number, number | undefined];
215
+ value = min;
216
+ valueMin = min;
217
+ valueMax = min;
218
+ } else {
219
+ value = valueMin = valueMax = null;
220
+ }
221
+ }
222
+ } else if (tableContext.paginationRepo?.filtering) {
223
+ if (
224
+ filteringStateValue == null ||
225
+ typeof filteringStateValue == 'string' ||
226
+ typeof filteringStateValue == 'number'
227
+ ) {
228
+ value = filteringStateValue;
229
+ } else if (typeof filteringStateValue !== 'boolean') {
230
+ console.warn('unknown value type:', $state.snapshot(filteringStateValue));
231
+ value = filteringStateValue as string; // HACK attempt to keep production running
232
+ }
233
+ }
234
+ });
235
+
236
+ onMount(() => {
237
+ const filterOption =
238
+ tableContext.paginationRepo?.filtering &&
239
+ tableContext.paginationRepo.filtering.find((it) => it.id === def.id);
240
+
241
+ if (filterOption) {
242
+ if (isNumericArray(filterOption.value)) {
243
+ [valueMin, valueMax] = filterOption.value;
244
+ if (valueMax == undefined) {
245
+ valueMax = valueMin;
246
+ } else if (valueMin == undefined) {
247
+ valueMin = valueMax;
248
+ }
249
+ } else if (!hasQuery(customFilter)) {
250
+ const gotValue = filterOption.value;
251
+ if (gotValue == null || typeof gotValue == 'string' || typeof gotValue == 'number') {
252
+ value = gotValue;
253
+ } else if (typeof filteringStateValue !== 'boolean') {
254
+ console.warn('unknown value type:', $state.snapshot(gotValue));
255
+ value = gotValue as string; // HACK attempt to keep production running
256
+ }
257
+ }
258
+ }
259
+ });
260
+
261
+ const filterMode = $derived(tableContext.paginationRepo?.filterModes.get(def.id));
262
+ const selectedNumberFilterType: SelectOption<NumberDateFilterMode> | null = $derived.by(() => {
263
+ const value = (filterMode as NumberDateFilterMode | undefined) ?? NumberDateFilterMode.In;
264
+ return numberDateFilterTypes.find((it) => it.value === value) ?? null;
265
+ });
266
+ const selectedStringFilterType: SelectOption<StringFilterMode> | null = $derived.by(() => {
267
+ const value = (filterMode as StringFilterMode | undefined) ?? StringFilterMode.Contains;
268
+ return stringFilterTypes.find((it) => it.value === value) ?? null;
269
+ });
270
+
271
+ const inputId: string = $derived(`${tableContext.tableName}-${def.id}-filter-type`);
272
+
273
+ let showFromDateModal = $state(false);
274
+ let showToDateModal = $state(false);
275
+
276
+ /**
277
+ * Debounce window for typed filter inputs (Text / Number / Between).
278
+ * Discrete selections (selects, date pickers, filter-mode dropdowns)
279
+ * skip this and fire `filterBy` immediately. Combined with the 400 ms
280
+ * debounce in `createRemoteRowSource`, server-side filtering lands
281
+ * roughly a second after the last keystroke.
282
+ */
283
+ const FILTER_INPUT_DEBOUNCE_MS = 500;
284
+
285
+ let typingDebounceTimer: ReturnType<typeof setTimeout> | undefined;
286
+
287
+ /** Fire the table's `filterBy` after the user stops typing for
288
+ * `FILTER_INPUT_DEBOUNCE_MS`. Repeated calls coalesce; the most
289
+ * recent value wins. */
290
+ function debouncedFilterBy(id: string, newValue: unknown): void {
291
+ if (typingDebounceTimer !== undefined) {
292
+ clearTimeout(typingDebounceTimer);
293
+ }
294
+ typingDebounceTimer = setTimeout(() => {
295
+ typingDebounceTimer = undefined;
296
+ tableContext.paginationRepo?.filterBy(id, newValue);
297
+ }, FILTER_INPUT_DEBOUNCE_MS);
298
+ }
299
+
300
+ // Clear any pending debounce when the row unmounts so we do not
301
+ // fire `filterBy` against a torn-down `paginationRepo`.
302
+ $effect(() => {
303
+ return () => {
304
+ if (typingDebounceTimer !== undefined) {
305
+ clearTimeout(typingDebounceTimer);
306
+ typingDebounceTimer = undefined;
307
+ }
308
+ };
309
+ });
310
+
311
+ function generateOnChangeObject(_label: string) {
312
+ return (newValue: SelectOption<unknown> | null) => {
313
+ tableContext.paginationRepo?.filterBy(def.id, newValue?.value ?? null);
314
+ tableContext.queryValues.set(def, newValue ?? null);
315
+ value = newValue ?? null;
316
+ };
317
+ }
318
+
319
+ function generateOnChangePrimitive(_label: string) {
320
+ // AsPrimitive QuerySelect hands us the primitive value directly.
321
+ return (newValue: unknown) => {
322
+ tableContext.paginationRepo?.filterBy(def.id, newValue ?? null);
323
+ tableContext.queryValues.set(def, newValue ?? null);
324
+ value = newValue ?? null;
325
+ };
326
+ }
327
+ </script>
328
+
329
+ {#if isSearchable}
330
+ <!-- TODO remove this check once datePicker is supported -->
331
+ {#if def instanceof DateInputDef || def instanceof CheckboxDef}
332
+ <label for={inputId}>{displayLabel}</label>
333
+ {/if}
334
+ <div class="row">
335
+ {#if def instanceof DateInputDef}
336
+ {@const dateValues =
337
+ tableContext.paginationRepo?.filtering.find((it) => it.id === def.id)?.value ?? []}
338
+ {#if isDateTuple(dateValues)}
339
+ {@const [fromDate, toDate] = dateValues}
340
+ <div class="spaced-around">
341
+ <Button
342
+ onclick={() => {
343
+ showFromDateModal = true;
344
+ }}>From {fromDate?.toLocaleDateString() ?? 'Any Date'}</Button
345
+ >
346
+
347
+ <Button
348
+ onclick={() => {
349
+ showToDateModal = true;
350
+ }}>To {toDate?.toLocaleDateString() ?? 'Any Date'}</Button
351
+ >
352
+ </div>
353
+
354
+ {#if showFromDateModal}
355
+ <DateModal
356
+ header="Edit Date From"
357
+ value={fromDate}
358
+ cancelEditing={() => {
359
+ showFromDateModal = false;
360
+ }}
361
+ confirm={(e) => {
362
+ tableContext.paginationRepo?.filterBy(def.id, [e, toDate]);
363
+ showFromDateModal = false;
364
+ }}
365
+ />
366
+ {/if}
367
+
368
+ {#if showToDateModal}
369
+ <DateModal
370
+ inclusive
371
+ header="Edit Date To"
372
+ value={toDate}
373
+ cancelEditing={() => {
374
+ showToDateModal = false;
375
+ }}
376
+ confirm={(e) => {
377
+ tableContext.paginationRepo?.filterBy(def.id, [fromDate, e]);
378
+ showToDateModal = false;
379
+ }}
380
+ />
381
+ {/if}
382
+ {/if}
383
+ {:else if def instanceof SelectDef && !isRemoteSelect}
384
+ <div class="maxwidth">
385
+ {#await optionsPromise then options}
386
+ {@const selectedPrimitives = selectedOptions
387
+ .map((it) => it.value)
388
+ .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')}
389
+ <MultiSelect
390
+ label={customFilter?.id ??
391
+ customFilter?.label ??
392
+ customFilter?.objectName ??
393
+ customFilter?.queryKey ??
394
+ def.id}
395
+ labelProp="label"
396
+ valueProp="value"
397
+ name={customFilter?.objectName ?? 'Item'}
398
+ {options}
399
+ valueAsObject={false}
400
+ onchange={(e: Array<unknown>) => {
401
+ // Phase 7.6: `valueAsObject={false}` returns
402
+ // primitives directly.
403
+ tableContext.paginationRepo?.filterBy(def.id, e);
404
+ }}
405
+ selectedValues={selectedPrimitives}
406
+ collapseSelection
407
+ />
408
+ {:catch e}
409
+ <span>Error retrieving options: {e.message}</span>
410
+ <Button
411
+ onclick={() => {
412
+ retryOptionsPromise();
413
+ }}>Retry</Button
414
+ >
415
+ {/await}
416
+ </div>
417
+ {:else if customFilter && isRemoteSelect}
418
+ <div class="contents">
419
+ {#await options then resolvedOptions}
420
+ {@const options = resolvedOptions as Array<SelectOption<unknown>>}
421
+ {#if hasQuery(customFilter)}
422
+ {#if customFilter.valueAsObject !== true}
423
+ {@const label = displayLabel}
424
+
425
+ <!-- TODO: multiselect -->
426
+
427
+ <QuerySelect
428
+ valueAsObject={false}
429
+ {label}
430
+ clearable
431
+ name={customFilter.objectName ?? 'Item'}
432
+ valueProp={'value' as never}
433
+ labelProp={'label' as never}
434
+ value={value as never}
435
+ query={customFilter.query}
436
+ bind:options={lastQueryOptions as never}
437
+ onchange={generateOnChangePrimitive(label)}
438
+ />
439
+ {:else}
440
+ {@const label = displayLabel}
441
+
442
+ <!-- TODO: multiselect -->
443
+
444
+ <QuerySelect
445
+ valueAsObject={true}
446
+ {label}
447
+ clearable
448
+ name={customFilter.objectName ?? 'Item'}
449
+ valueProp="value"
450
+ labelProp="label"
451
+ value={value as null}
452
+ query={customFilter.query}
453
+ bind:options={lastQueryOptions}
454
+ onchange={generateOnChangeObject(label)}
455
+ />
456
+ {/if}
457
+ {:else}
458
+ {@const selectedPrimitives = selectedOptions
459
+ .map((it) => it.value)
460
+ .filter(
461
+ (v): v is string | number => typeof v === 'string' || typeof v === 'number'
462
+ )}
463
+ <!--
464
+ With `valueAsObject={false}`, MultiSelect compares
465
+ its `selectedValues` against options by primitive
466
+ value, so we map `selectedOptions` to their
467
+ primitive `.value` rather than passing the
468
+ SelectOption objects directly.
469
+ -->
470
+ <MultiSelect
471
+ label={displayLabel}
472
+ labelProp="label"
473
+ valueProp="value"
474
+ name={customFilter.objectName ?? 'Item'}
475
+ {options}
476
+ valueAsObject={false}
477
+ onchange={(e: Array<unknown>) => {
478
+ // Phase 7.6: `valueAsObject={false}` returns
479
+ // primitives directly.
480
+ tableContext.paginationRepo?.filterBy(def.id, e);
481
+ }}
482
+ selectedValues={selectedPrimitives}
483
+ collapseSelection
484
+ />
485
+ {/if}
486
+ {/await}
487
+ </div>
488
+ {:else if def instanceof CheckboxDef}
489
+ <div class="inner-row">
490
+ <label>
491
+ <input
492
+ value={false}
493
+ type="radio"
494
+ bind:group={value}
495
+ onchange={() => {
496
+ tableContext.paginationRepo?.filterBy(def.id, value);
497
+ }}
498
+ />
499
+ No
500
+ </label>
501
+ <label>
502
+ <input
503
+ value={undefined}
504
+ type="radio"
505
+ bind:group={value}
506
+ onchange={() => {
507
+ tableContext.paginationRepo?.filterBy(def.id, value);
508
+ }}
509
+ />
510
+ Any
511
+ </label>
512
+ <label>
513
+ <input
514
+ value={true}
515
+ type="radio"
516
+ bind:group={value}
517
+ onchange={() => {
518
+ tableContext.paginationRepo?.filterBy(def.id, value);
519
+ }}
520
+ />
521
+ Yes
522
+ </label>
523
+ </div>
524
+ {:else if isNumeric}
525
+ {@const label = typeof def.header === 'string' ? def.header : undefined}
526
+ <div class="left">
527
+ <SingleSelect
528
+ {label}
529
+ name="Filter Type"
530
+ options={numberDateFilterTypes}
531
+ labelProp="label"
532
+ valueProp="value"
533
+ valueAsObject
534
+ value={selectedNumberFilterType}
535
+ onchange={(newValue: SelectOption<NumberDateFilterMode> | null) => {
536
+ const oldFilterType = selectedNumberFilterType;
537
+
538
+ const newFilterType = newValue?.value ?? NumberDateFilterMode.In;
539
+ tableContext.paginationRepo?.setFilterMode(def.id, newFilterType);
540
+
541
+ if (selectedNumberFilterType?.value !== oldFilterType?.value) {
542
+ if (oldFilterType?.value === NumberDateFilterMode.Between) {
543
+ valueMax = null;
544
+ tableContext.paginationRepo?.filterBy(def.id, [valueMin, valueMin]);
545
+ }
546
+
547
+ if (newFilterType === NumberDateFilterMode.In) {
548
+ tableContext.paginationRepo?.filterBy(def.id, [value]);
549
+ }
550
+
551
+ // TODO what about the other modes?
552
+ }
553
+ }}
554
+ onkeydown={(e: KeyboardEvent) => {
555
+ e.stopPropagation();
556
+ }}
557
+ />
558
+ </div>
559
+ <div class="right">
560
+ {#if selectedNumberFilterType?.value === NumberDateFilterMode.Between}
561
+ <NumberInput
562
+ id={inputId}
563
+ bind:value={valueMin}
564
+ oninput={() => {
565
+ debouncedFilterBy(def.id, [valueMin, valueMax]);
566
+ }}
567
+ onkeydown={(e: KeyboardEvent) => {
568
+ e.stopPropagation();
569
+ }}
570
+ />
571
+ <span>and</span>
572
+ <NumberInput
573
+ id={inputId}
574
+ bind:value={valueMax}
575
+ oninput={() => {
576
+ debouncedFilterBy(def.id, [valueMin, valueMax]);
577
+ }}
578
+ onkeydown={(e: KeyboardEvent) => {
579
+ e.stopPropagation();
580
+ }}
581
+ />
582
+ {:else if value == null || typeof value == 'string'}
583
+ <TextInput
584
+ id={inputId}
585
+ bind:value
586
+ oninput={() => {
587
+ debouncedFilterBy(def.id, [value]);
588
+ }}
589
+ onkeydown={(e: KeyboardEvent) => {
590
+ e.stopPropagation();
591
+ }}
592
+ />
593
+ {:else if dev}
594
+ error: {value?.toString()}
595
+ <!-- eslint-disable-next-line svelte/no-at-debug-tags -->
596
+ {@debug value}
597
+ {/if}
598
+ </div>
599
+ {:else}
600
+ {@const label = typeof def.header === 'string' ? def.header : undefined}
601
+ <div class="left">
602
+ <SingleSelect
603
+ name="Filter Type"
604
+ valueAsObject
605
+ {label}
606
+ options={stringFilterTypes}
607
+ labelProp="label"
608
+ valueProp="value"
609
+ value={selectedStringFilterType}
610
+ onchange={(newValue: SelectOption<StringFilterMode> | null) => {
611
+ const filterType = newValue?.value ?? StringFilterMode.Contains;
612
+ tableContext.paginationRepo?.setFilterMode(def.id, filterType);
613
+ }}
614
+ onkeydown={(e: KeyboardEvent) => {
615
+ e.stopPropagation();
616
+ }}
617
+ />
618
+ </div>
619
+ <div class="right">
620
+ {#if value == null || typeof value == 'string'}
621
+ <TextInput
622
+ id={inputId}
623
+ bind:value
624
+ oninput={() => {
625
+ debouncedFilterBy(def.id, value);
626
+ }}
627
+ onkeydown={(e: KeyboardEvent) => {
628
+ e.stopPropagation();
629
+ }}
630
+ />
631
+ {:else if dev}
632
+ error: {value?.toString()}
633
+ <!-- eslint-disable-next-line svelte/no-at-debug-tags -->
634
+ {@debug value}
635
+ {/if}
636
+ </div>
637
+ {/if}
638
+ </div>
639
+ {/if}
640
+
641
641
  <style>.contents {
642
642
  display: contents;
643
643
  --input-width: 100%;
@@ -708,4 +708,4 @@
708
708
  .inner-row label input {
709
709
  cursor: pointer;
710
710
  width: 2rem;
711
- }</style>
711
+ }</style>