@ibobbyts/svelte-ui-utils 0.4.6 → 0.5.0

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 (37) hide show
  1. package/README.md +37 -125
  2. package/dist/data-table/DateRangeFilter.svelte +119 -47
  3. package/dist/data-table/DateRangeFilter.svelte.d.ts +4 -3
  4. package/dist/data-table/DateRangeFilter.svelte.d.ts.map +1 -1
  5. package/dist/data-table/FilterControl.svelte +136 -27
  6. package/dist/data-table/FilterControl.svelte.d.ts.map +1 -1
  7. package/dist/data-table/date-range.d.ts +1 -0
  8. package/dist/data-table/date-range.d.ts.map +1 -1
  9. package/dist/data-table/date-range.js +12 -0
  10. package/dist/data-table/index.d.ts +2 -2
  11. package/dist/data-table/index.d.ts.map +1 -1
  12. package/dist/data-table/index.js +1 -1
  13. package/dist/data-table/types.d.ts +76 -4
  14. package/dist/data-table/types.d.ts.map +1 -1
  15. package/dist/dropdown/Dropdown.svelte +27 -11
  16. package/dist/dropdown/Dropdown.svelte.d.ts +1 -1
  17. package/dist/dropdown/Dropdown.svelte.d.ts.map +1 -1
  18. package/dist/index.d.ts +0 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +0 -1
  21. package/dist/style.css +12 -282
  22. package/package.json +4 -12
  23. package/dist/dropdown-search/DropdownSearch.svelte +0 -725
  24. package/dist/dropdown-search/DropdownSearch.svelte.d.ts +0 -62
  25. package/dist/dropdown-search/DropdownSearch.svelte.d.ts.map +0 -1
  26. package/dist/dropdown-search/DropdownSearchMultiSelect.svelte +0 -94
  27. package/dist/dropdown-search/DropdownSearchMultiSelect.svelte.d.ts +0 -54
  28. package/dist/dropdown-search/DropdownSearchMultiSelect.svelte.d.ts.map +0 -1
  29. package/dist/dropdown-search/index.d.ts +0 -7
  30. package/dist/dropdown-search/index.d.ts.map +0 -1
  31. package/dist/dropdown-search/index.js +0 -4
  32. package/dist/dropdown-search/state.d.ts +0 -17
  33. package/dist/dropdown-search/state.d.ts.map +0 -1
  34. package/dist/dropdown-search/state.js +0 -51
  35. package/dist/dropdown-search/types.d.ts +0 -39
  36. package/dist/dropdown-search/types.d.ts.map +0 -1
  37. package/dist/dropdown-search/types.js +0 -1
package/README.md CHANGED
@@ -40,7 +40,6 @@ import '@ibobbyts/svelte-ui-utils/style.css';
40
40
  <script lang="ts">
41
41
  import { ToastManager, toast } from '@ibobbyts/svelte-ui-utils/toast';
42
42
  import { Dropdown } from '@ibobbyts/svelte-ui-utils/dropdown';
43
- import { DropdownSearch, DropdownSearchMultiSelect } from '@ibobbyts/svelte-ui-utils/dropdown-search';
44
43
  import { Dialog, ConfirmDialog, InputDialog, CsvUploadDialog, ImagePreviewDialog, PasswordCopyDialog } from '@ibobbyts/svelte-ui-utils/dialog';
45
44
  import { DataTable, DateRangeFilter, FilterTable, NumberRangeFilter } from '@ibobbyts/svelte-ui-utils/table';
46
45
  </script>
@@ -49,7 +48,7 @@ import '@ibobbyts/svelte-ui-utils/style.css';
49
48
  The package root also re-exports the public modules:
50
49
 
51
50
  ```ts
52
- import { ToastManager, Dropdown, DropdownSearch, Dialog, DataTable } from '@ibobbyts/svelte-ui-utils';
51
+ import { ToastManager, Dropdown, Dialog, DataTable } from '@ibobbyts/svelte-ui-utils';
53
52
  ```
54
53
 
55
54
  ## Toast
@@ -85,103 +84,6 @@ moves. Set `placement="up"` or `placement="down"` to force a direction. Use
85
84
  `fitViewport={false}` to disable the default panel-height constraint for the
86
85
  chosen side.
87
86
 
88
- ## DropdownSearch
89
-
90
- `DropdownSearch` is the free-text input entry with server-driven validation
91
- (`exactMatch`), input statuses, focus options, footer text, a clear button, and
92
- multiselect chips. For a trigger-style dropdown that combines single or multi
93
- select with async search and dynamic result groups, use the unified `Dropdown`
94
- search mode instead.
95
-
96
- ```svelte
97
- <script lang="ts">
98
- import { DropdownSearch } from '@ibobbyts/svelte-ui-utils/dropdown-search';
99
-
100
- async function loadOptions(query, { limit, signal }) {
101
- const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${limit}`, { signal });
102
- return response.json();
103
- }
104
- </script>
105
-
106
- <DropdownSearch
107
- language="en_us"
108
- placeholder="Search"
109
- debounceMs={500}
110
- clearLabel="Clear search"
111
- width="24rem"
112
- maxWidth="100%"
113
- {loadOptions}
114
- searchOnExternalValueChange={true}
115
- closeOnValid={true}
116
- />
117
- ```
118
-
119
- `loadOptions` returns `{ options, exactMatch }`. An item uses this shape:
120
-
121
- ```ts
122
- {
123
- value: '123',
124
- label: 'Jane Doe',
125
- param_dict: { ID: 'M-123' }
126
- }
127
- ```
128
-
129
- `label` is the display text and `value` is the submitted string identifier;
130
- extra fields such as `param_dict` are preserved as business metadata. The
131
- free-text `value` prop and `onChange` detail stay the raw input text, so they
132
- never collide with the submitted `item.value`.
133
-
134
- The input is valid when the server returns one unique `exactMatch`, or when the
135
- user selects an item. Non-empty text without a unique match is invalid.
136
- Set `validate={false}` when the field should only show selectable options and
137
- stay visually neutral instead of turning green or red. In that mode the
138
- component ignores `exactMatch` for status and auto-selection.
139
- Use `searchOnExternalValueChange` for scanner or programmatic input workflows.
140
- Set `closeOnValid={true}` to hide the result list after validation finds a
141
- usable exact match. The default is `false`, so existing validated searches keep
142
- showing their current result list until the field closes or the user selects an
143
- option.
144
- Use `showOptionsOnFocus={true}` with `focusOptions` to show a controlled set of
145
- options as soon as the field receives focus, including when the current value is
146
- empty or already valid. Pass `footerText` to render a non-selectable note below
147
- the options, separated by a divider.
148
- When the input has text, `DropdownSearch` shows an internal clear button on the
149
- right side of the field. Use `clearLabel` to localize that button's accessible
150
- label, or use `language` to select the package default.
151
- Use `width`, `minWidth`, and `maxWidth` to size the control directly when a
152
- wrapper is not convenient.
153
- Server-side code and Node tests that only need pure helpers should import from
154
- `@ibobbyts/svelte-ui-utils/dropdown-search/state` so they do not load Svelte
155
- component files.
156
-
157
- Set `multiselect={true}` when the search box should collect multiple selected
158
- items as chips. `DropdownSearchMultiSelect` is a convenience wrapper with the
159
- same controlled contract:
160
-
161
- ```svelte
162
- <script lang="ts">
163
- import { DropdownSearchMultiSelect, type DropdownSearchItem } from '@ibobbyts/svelte-ui-utils/dropdown-search';
164
-
165
- let selectedMembers: DropdownSearchItem[] = [];
166
- </script>
167
-
168
- <DropdownSearchMultiSelect
169
- language="en_us"
170
- placeholder="Search members"
171
- selectedItems={selectedMembers}
172
- selectedItemsLabel="Selected members"
173
- removeSelectedItemLabel={(item) => `Remove ${item.label}`}
174
- {loadOptions}
175
- onSelectedItemsChange={(items) => {
176
- selectedMembers = items;
177
- }}
178
- />
179
- ```
180
-
181
- In multiselect mode, the text input remains a search query. Selecting an item
182
- adds or removes it from `selectedItems`, clears the query, and emits both
183
- `onChange` and `onSelectedItemsChange`.
184
-
185
87
  ## SortableList
186
88
 
187
89
  `SortableList` provides controlled vertical HTML5 drag-and-drop ordering. Each
@@ -417,8 +319,8 @@ the menu to its longest option while keeping the selected edge aligned.
417
319
  `DataTable` uses this same component for its page-size picker.
418
320
 
419
321
  Set `search={true}` with `loadOptions` to load remote options as the user
420
- types. The default `input_style="dropdown"` keeps the search field inside the
421
- expanded menu. Set `input_style="input"` to render a directly editable input;
322
+ types. The default `inputStyle="dropdown"` keeps the search field inside the
323
+ expanded menu. Set `inputStyle="input"` to render a directly editable input;
422
324
  results appear while it is focused and hide on blur. In input style, the
423
325
  optional `getItemLabel` callback controls the displayed label while `value`
424
326
  and `onChange` continue to represent submitted option values. The input style
@@ -766,10 +668,8 @@ for the left column and a controlled filter created with the `filter` helper:
766
668
  filter.dropdownSearch({
767
669
  value: searchValue,
768
670
  selectedItem,
769
- status: searchStatus,
770
671
  width: '24rem',
771
672
  maxWidth: '100%',
772
- clearLabel: 'Clear search',
773
673
  loadOptions,
774
674
  onChange: (detail) => updateSearch(detail)
775
675
  }),
@@ -811,13 +711,6 @@ same menu, keyboard, and visual behavior as standalone dropdowns.
811
711
  }
812
712
  ```
813
713
 
814
- If a dropdown-style filter appears clipped, check the parent containers first.
815
- `DropdownSearch` renders its result list as an absolutely positioned child, so
816
- any ancestor with `overflow: hidden`, `overflow: auto`, or `overflow: scroll`
817
- can clip the menu even when the menu has a high `z-index`. Keep the nearest
818
- filter container at `overflow: visible`, or move the clipping/scrolling behavior
819
- to a parent that does not wrap the dropdown menu directly.
820
-
821
714
  `dateRange` renders two browser date inputs, preset buttons, and quick month/year
822
715
  selects. The presets are `last 24 hours`, `last 7 days`, `last 30 days`,
823
716
  `today`, `this week`, `this month`, and `this year`. Manual changes and quick
@@ -832,6 +725,28 @@ period, including days after today. The `last24Hours` preset also emits
832
725
  a consuming app can run an exact timestamp query while still showing the covered
833
726
  dates in the inputs.
834
727
 
728
+ Pass `presets` to fully control the preset row. Each entry is one of:
729
+
730
+ - a built-in preset key: `'last24Hours'`, `'last7Days'`, `'last30Days'`,
731
+ `'today'`, `'thisWeek'`, `'thisMonth'`, `'thisYear'`;
732
+ - a custom preset object `{ key, label?, resolve }`, where `resolve` receives
733
+ `{ now, weekStartsOn }` and returns `{ startDate, endDate }` dates that the
734
+ component formats into the value;
735
+ - a consumer-controlled select object `{ type: 'select', key, value, options,
736
+ ariaLabel?, onChange }`, rendered as a dropdown inside the preset row (for
737
+ example a year picker whose selection feeds custom preset resolvers); the
738
+ component only reports selection changes, the consumer owns the value;
739
+ - `'quickMonth'` or `'quickYear'` for the quick month/year selects;
740
+ - `'divider'` for a visual separator between entries.
741
+
742
+ When `presets` is omitted the default row is used: the seven built-in buttons
743
+ with dividers after `last 30 days` and `this year`, then both quick selects.
744
+ Custom preset keys are stored in `value.preset` as plain strings, so
745
+ `defaultPreset` and the active-button highlight work with them too. Custom
746
+ labels resolve from `label`, then `presetLabels[key]`, then the key itself.
747
+ Preset and select `key`s must be unique within one `presets` list; duplicate
748
+ keys make the active-state highlight and the `defaultPreset` lookup ambiguous.
749
+
835
750
  `numberRange` renders min/max number inputs and supports `prefixLabel`, for
836
751
  example `$` for currency filters.
837
752
 
@@ -928,21 +843,18 @@ calculations.
928
843
  ## Release
929
844
 
930
845
  Do not publish this package as part of consumer-app local integration work.
931
- Publish only when a release is explicitly requested. Releases are published
932
- directly from the maintainer's machine to npm; do not create a GitHub Actions
933
- secret or a GitHub Release for package publishing.
934
-
935
- The npm token is exported as `NPM_TOKEN` from `~/.zshrc`. After updating the
936
- package version, run the validation and publish commands locally:
937
-
938
- ```bash
939
- npm run check
940
- npm test
941
- npm run package
942
- npm publish --access public
943
- ```
944
-
945
- Never commit the npm token or copy it into repository settings.
846
+ Publish only when a release is explicitly requested.
847
+
848
+ Releases publish from CI via npm Trusted Publishing (OIDC): bump the version
849
+ in `package.json`, commit, then push a `vX.Y.Z` tag. That runs
850
+ [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml), which
851
+ installs from the committed lockfile, runs `svelte-check` and `vitest`, and
852
+ publishes with no npm token in the environment — the runner's OIDC identity is
853
+ the credential (`id-token: write`). The tag must match `package.json`'s
854
+ version. The trusted publisher is linked on npmjs.com (user `iBobbyTS`, repo
855
+ `svelte-ui-utils`, workflow `npm-publish.yml`); the workflow filename is part
856
+ of that link and must not be renamed. `prepublishOnly` builds `dist/` before
857
+ the tarball is packed, so no publish path can ship stale output.
946
858
 
947
859
  ## Bun install verification
948
860
 
@@ -4,8 +4,14 @@
4
4
  import { onMount } from 'svelte';
5
5
  import Dropdown from '../dropdown/Dropdown.svelte';
6
6
  import { getUiMessages, type UiLanguage } from '../i18n.js';
7
- import { endOfMonth, formatDate, resolveDateRangePreset, startOfDay } from './date-range.js';
8
- import type { DateRangeFilterValue, DateRangePreset } from './types.js';
7
+ import { endOfMonth, formatDate, isDateRangePreset, resolveDateRangePreset, startOfDay } from './date-range.js';
8
+ import type {
9
+ CustomDateRangePreset,
10
+ DateRangeFilterValue,
11
+ DateRangePreset,
12
+ DateRangePresetEntry,
13
+ DateRangePresetSelect
14
+ } from './types.js';
9
15
 
10
16
  export let value: DateRangeFilterValue = {
11
17
  startDate: '',
@@ -15,23 +21,29 @@
15
21
  export let language: UiLanguage = 'en_us';
16
22
  export let startLabel: string | undefined = undefined;
17
23
  export let endLabel: string | undefined = undefined;
18
- export let presetLabels: Partial<Record<DateRangePreset, string>> = {};
19
- export let defaultPreset: DateRangePreset | undefined = undefined;
24
+ export let presets: DateRangePresetEntry[] | undefined = undefined;
25
+ export let presetLabels: Partial<Record<string, string>> = {};
26
+ export let defaultPreset: string | undefined = undefined;
20
27
  export let quickYears: number[] | undefined = undefined;
21
28
  export let now: () => Date = () => new Date();
22
29
  export let weekStartsOn: 0 | 1 = 1;
23
30
  export let onChange: ((value: DateRangeFilterValue) => void) | undefined = undefined;
24
31
 
25
- const presets: DateRangePreset[] = [
32
+ const defaultPresetEntries: DateRangePresetEntry[] = [
26
33
  'last24Hours',
27
34
  'last7Days',
28
35
  'last30Days',
36
+ 'divider',
29
37
  'today',
30
38
  'thisWeek',
31
39
  'thisMonth',
32
- 'thisYear'
40
+ 'thisYear',
41
+ 'divider',
42
+ 'quickMonth',
43
+ 'quickYear'
33
44
  ];
34
45
  const monthNumbers = Array.from({ length: 12 }, (_, index) => index + 1);
46
+ const structuralEntryKeys = new Set(['quickMonth', 'quickYear', 'divider']);
35
47
 
36
48
  let quickYear = '';
37
49
  let quickMonth = '';
@@ -39,6 +51,7 @@
39
51
  $: messages = getUiMessages(language);
40
52
  $: resolvedStartLabel = startLabel ?? messages.dateRange.startLabel;
41
53
  $: resolvedEndLabel = endLabel ?? messages.dateRange.endLabel;
54
+ $: presetEntries = presets ?? defaultPresetEntries;
42
55
  $: currentYear = startOfDay(now()).getFullYear();
43
56
  $: quickYearOptions = quickYears ?? [currentYear - 1, currentYear, currentYear + 1];
44
57
  $: quickMonthOptions = [
@@ -71,8 +84,38 @@
71
84
  quickMonth = '';
72
85
  }
73
86
 
74
- function resolvePreset(preset: DateRangePreset): DateRangeFilterValue {
75
- return resolveDateRangePreset(preset, now(), weekStartsOn);
87
+ function isSelectEntry(entry: DateRangePresetEntry): entry is DateRangePresetSelect {
88
+ return typeof entry === 'object' && 'type' in entry && entry.type === 'select';
89
+ }
90
+
91
+ function isPresetButtonEntry(entry: DateRangePresetEntry): entry is DateRangePreset | CustomDateRangePreset {
92
+ if (typeof entry === 'string') {
93
+ return !structuralEntryKeys.has(entry);
94
+ }
95
+ return !isSelectEntry(entry);
96
+ }
97
+
98
+ function presetKeyOf(entry: DateRangePreset | CustomDateRangePreset): string {
99
+ return typeof entry === 'string' ? entry : entry.key;
100
+ }
101
+
102
+ function resolvePresetEntry(entry: DateRangePreset | CustomDateRangePreset): DateRangeFilterValue {
103
+ if (typeof entry === 'string') {
104
+ return resolveDateRangePreset(entry, now(), weekStartsOn);
105
+ }
106
+
107
+ const range = entry.resolve({ now: now(), weekStartsOn });
108
+ return {
109
+ startDate: formatDate(range.startDate),
110
+ endDate: formatDate(range.endDate),
111
+ preset: entry.key
112
+ };
113
+ }
114
+
115
+ function findPresetEntryByKey(key: string): DateRangePreset | CustomDateRangePreset | undefined {
116
+ return presetEntries
117
+ .filter(isPresetButtonEntry)
118
+ .find((entry) => presetKeyOf(entry) === key);
76
119
  }
77
120
 
78
121
  function emit(next: DateRangeFilterValue) {
@@ -132,18 +175,22 @@
132
175
  emit(resolveQuickRange(Number(quickYear), Number(nextValue)));
133
176
  }
134
177
 
135
- function applyPreset(preset: DateRangePreset) {
178
+ function applyPresetEntry(entry: DateRangePreset | CustomDateRangePreset) {
136
179
  clearQuickSelection();
137
- if (value.preset === preset) {
180
+ if (value.preset === presetKeyOf(entry)) {
138
181
  emit(emptyRange());
139
182
  return;
140
183
  }
141
184
 
142
- emit(resolvePreset(preset));
185
+ emit(resolvePresetEntry(entry));
143
186
  }
144
187
 
145
- function labelFor(preset: DateRangePreset) {
146
- return presetLabels[preset] ?? messages.dateRange.presetLabels[preset];
188
+ function labelFor(entry: DateRangePreset | CustomDateRangePreset) {
189
+ if (typeof entry === 'string') {
190
+ return presetLabels[entry] ?? messages.dateRange.presetLabels[entry] ?? entry;
191
+ }
192
+
193
+ return entry.label ?? presetLabels[entry.key] ?? entry.key;
147
194
  }
148
195
 
149
196
  function monthLabel(month: number) {
@@ -151,8 +198,14 @@
151
198
  }
152
199
 
153
200
  onMount(() => {
154
- if (defaultPreset && isEmptyRange(value)) {
155
- emit(resolvePreset(defaultPreset));
201
+ if (!defaultPreset || !isEmptyRange(value)) {
202
+ return;
203
+ }
204
+
205
+ const entry = findPresetEntryByKey(defaultPreset) ??
206
+ (isDateRangePreset(defaultPreset) ? defaultPreset : undefined);
207
+ if (entry) {
208
+ emit(resolvePresetEntry(entry));
156
209
  }
157
210
  });
158
211
  </script>
@@ -175,40 +228,59 @@
175
228
  />
176
229
  </label>
177
230
  <div class="suu-filter-preset-row">
178
- {#each presets as preset}
179
- <button
180
- type="button"
181
- class="suu-filter-preset"
182
- class:suu-filter-preset--active={value.preset === preset}
183
- on:click={() => applyPreset(preset)}
184
- >
185
- {labelFor(preset)}
186
- </button>
187
- {#if preset === 'last30Days' || preset === 'thisYear'}
231
+ {#each presetEntries as entry}
232
+ {#if entry === 'divider'}
188
233
  <span class="suu-filter-preset-divider" aria-hidden="true"></span>
234
+ {:else if entry === 'quickMonth'}
235
+ <label class="suu-filter-preset-select">
236
+ <span class="suu-visually-hidden">{messages.dateRange.quickMonthLabel}</span>
237
+ <Dropdown
238
+ ariaLabel={messages.dateRange.quickMonthLabel}
239
+ value={quickMonth}
240
+ options={quickMonthOptions}
241
+ fitViewport={true}
242
+ fitContent={true}
243
+ onChange={(nextValue) => updateQuickMonth(String(nextValue))}
244
+ />
245
+ </label>
246
+ {:else if entry === 'quickYear'}
247
+ <label class="suu-filter-preset-select">
248
+ <span class="suu-visually-hidden">{messages.dateRange.quickYearLabel}</span>
249
+ <Dropdown
250
+ ariaLabel={messages.dateRange.quickYearLabel}
251
+ value={quickYear}
252
+ options={quickYearDropdownOptions}
253
+ fitViewport={true}
254
+ fitContent={true}
255
+ onChange={(nextValue) => updateQuickYear(String(nextValue))}
256
+ />
257
+ </label>
258
+ {:else if isSelectEntry(entry)}
259
+ <label class="suu-filter-preset-select">
260
+ <span class="suu-visually-hidden">{entry.ariaLabel ?? entry.key}</span>
261
+ <Dropdown
262
+ ariaLabel={entry.ariaLabel ?? entry.key}
263
+ value={entry.value}
264
+ options={entry.options}
265
+ fitViewport={true}
266
+ fitContent={true}
267
+ onChange={(nextValue) => {
268
+ if (isSelectEntry(entry)) {
269
+ void entry.onChange(String(nextValue));
270
+ }
271
+ }}
272
+ />
273
+ </label>
274
+ {:else}
275
+ <button
276
+ type="button"
277
+ class="suu-filter-preset"
278
+ class:suu-filter-preset--active={value.preset === presetKeyOf(entry)}
279
+ on:click={() => applyPresetEntry(entry)}
280
+ >
281
+ {labelFor(entry)}
282
+ </button>
189
283
  {/if}
190
284
  {/each}
191
- <label class="suu-filter-preset-select">
192
- <span class="suu-visually-hidden">{messages.dateRange.quickMonthLabel}</span>
193
- <Dropdown
194
- ariaLabel={messages.dateRange.quickMonthLabel}
195
- value={quickMonth}
196
- options={quickMonthOptions}
197
- fitViewport={true}
198
- fitContent={true}
199
- onChange={(nextValue) => updateQuickMonth(String(nextValue))}
200
- />
201
- </label>
202
- <label class="suu-filter-preset-select">
203
- <span class="suu-visually-hidden">{messages.dateRange.quickYearLabel}</span>
204
- <Dropdown
205
- ariaLabel={messages.dateRange.quickYearLabel}
206
- value={quickYear}
207
- options={quickYearDropdownOptions}
208
- fitViewport={true}
209
- fitContent={true}
210
- onChange={(nextValue) => updateQuickYear(String(nextValue))}
211
- />
212
- </label>
213
285
  </div>
214
286
  </div>
@@ -1,5 +1,5 @@
1
1
  import { type UiLanguage } from '../i18n.js';
2
- import type { DateRangeFilterValue, DateRangePreset } from './types.js';
2
+ import type { DateRangeFilterValue, DateRangePresetEntry } from './types.js';
3
3
  interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
4
4
  new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
5
5
  $$bindings?: Bindings;
@@ -18,8 +18,9 @@ declare const DateRangeFilter: $$__sveltets_2_IsomorphicComponent<{
18
18
  language?: UiLanguage;
19
19
  startLabel?: string | undefined;
20
20
  endLabel?: string | undefined;
21
- presetLabels?: Partial<Record<DateRangePreset, string>>;
22
- defaultPreset?: DateRangePreset | undefined;
21
+ presets?: DateRangePresetEntry[] | undefined;
22
+ presetLabels?: Partial<Record<string, string>>;
23
+ defaultPreset?: string | undefined;
23
24
  quickYears?: number[] | undefined;
24
25
  now?: () => Date;
25
26
  weekStartsOn?: 0 | 1;
@@ -1 +1 @@
1
- {"version":3,"file":"DateRangeFilter.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/data-table/DateRangeFilter.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAgMxE,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IAC9G,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,eAAe;YAV2O,oBAAoB;eAAa,UAAU;iBAAe,MAAM,GAAG,SAAS;eAAa,MAAM,GAAG,SAAS;mBAAiB,OAAO,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;oBAAkB,eAAe,GAAG,SAAS;iBAAe,MAAM,EAAE,GAAG,SAAS;UAAQ,MAAM,IAAI;mBAAiB,CAAC,GAAG,CAAC;eAAa,CAAC,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC,GAAG,SAAS;;;kBAUxf,CAAC;AACnF,KAAK,eAAe,GAAG,YAAY,CAAC,OAAO,eAAe,CAAC,CAAC;AAC9D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"DateRangeFilter.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/data-table/DateRangeFilter.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5D,OAAO,KAAK,EAER,oBAAoB,EAEpB,oBAAoB,EAErB,MAAM,YAAY,CAAC;AA2PtB,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IAC9G,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,eAAe;YAV8P,oBAAoB;eAAa,UAAU;iBAAe,MAAM,GAAG,SAAS;eAAa,MAAM,GAAG,SAAS;cAAY,oBAAoB,EAAE,GAAG,SAAS;mBAAiB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;oBAAkB,MAAM,GAAG,SAAS;iBAAe,MAAM,EAAE,GAAG,SAAS;UAAQ,MAAM,IAAI;mBAAiB,CAAC,GAAG,CAAC;eAAa,CAAC,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC,GAAG,SAAS;;;kBAUviB,CAAC;AACnF,KAAK,eAAe,GAAG,YAAY,CAAC,OAAO,eAAe,CAAC,CAAC;AAC9D,eAAe,eAAe,CAAC"}
@@ -3,11 +3,16 @@
3
3
  <script lang="ts">
4
4
  import Dropdown from '../dropdown/Dropdown.svelte';
5
5
  import DropdownMultiSelect from '../dropdown/DropdownMultiSelect.svelte';
6
- import DropdownSearch from '../dropdown-search/DropdownSearch.svelte';
6
+ import type {
7
+ DropdownLoadContext,
8
+ DropdownLoadOptionsResult,
9
+ DropdownOption,
10
+ DropdownSelection
11
+ } from '../dropdown/types.js';
7
12
  import type { UiLanguage } from '../i18n.js';
8
13
  import DateRangeFilter from './DateRangeFilter.svelte';
9
14
  import NumberRangeFilter from './NumberRangeFilter.svelte';
10
- import type { FilterControl } from './types.js';
15
+ import type { DropdownSearchChangeDetail, DropdownSearchItem, FilterControl } from './types.js';
11
16
 
12
17
  export let control: FilterControl;
13
18
  export let language: UiLanguage = 'en_us';
@@ -32,6 +37,111 @@
32
37
  }
33
38
  }
34
39
 
40
+ // The removed DropdownSearch spoke free text in `value` while the root
41
+ // Dropdown reports option identifiers. This adapter keeps the legacy
42
+ // `{ value: label, selectedItem, selectedItems, status }` detail by resolving
43
+ // each identifier back to the item that loadOptions returned.
44
+ let dropdownSearchQuery = '';
45
+ const dropdownSearchItems = new Map<string, DropdownSearchItem>();
46
+
47
+ function resolveDropdownSearchItemLabel(item: DropdownSearchItem): string {
48
+ return control.type === 'dropdownSearch' && control.getItemLabel
49
+ ? control.getItemLabel(item)
50
+ : item.label;
51
+ }
52
+
53
+ // The root Dropdown types labels against its own option shape; the options it
54
+ // hands back are the very `DropdownSearchItem` objects we returned, so the
55
+ // structural widening is lossless for consumers' getItemLabel callbacks.
56
+ function dropdownSearchOptionLabel(option: DropdownOption): string {
57
+ return resolveDropdownSearchItemLabel(option as DropdownSearchItem);
58
+ }
59
+
60
+ function findDropdownSearchItem(identifier: string): DropdownSearchItem | undefined {
61
+ const cached = dropdownSearchItems.get(identifier);
62
+ if (cached !== undefined) {
63
+ return cached;
64
+ }
65
+ if (control.type === 'dropdownSearch' && control.selectedItem?.value === identifier) {
66
+ return control.selectedItem;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ async function loadDropdownSearchOptions(
72
+ rawQuery: string,
73
+ context: DropdownLoadContext
74
+ ): Promise<DropdownLoadOptionsResult> {
75
+ if (control.type !== 'dropdownSearch') {
76
+ return { options: [] };
77
+ }
78
+
79
+ const query = rawQuery.trim();
80
+ // minLength has no root Dropdown counterpart, so the wrapper keeps the old
81
+ // "below threshold returns no options" contract itself.
82
+ if (query.length < (control.minLength ?? 1)) {
83
+ return { options: [] };
84
+ }
85
+
86
+ const result = await control.loadOptions(query, context);
87
+ // Stale responses must not seed the identifier cache either.
88
+ if (context.signal.aborted) {
89
+ return { options: [] };
90
+ }
91
+ const items = result?.options ?? [];
92
+ for (const item of items) {
93
+ dropdownSearchItems.set(item.value, item);
94
+ }
95
+
96
+ return { options: items };
97
+ }
98
+
99
+ function handleDropdownSearchQueryChange(query: string) {
100
+ dropdownSearchQuery = query;
101
+ }
102
+
103
+ function handleDropdownSearchChange(selection: DropdownSelection) {
104
+ if (control.type !== 'dropdownSearch') {
105
+ return;
106
+ }
107
+
108
+ const identifier = Array.isArray(selection) ? (selection[0] ?? '') : selection;
109
+ // The root Dropdown reports an empty identifier when the user types over a
110
+ // controlled selection (input/value divergence). That divergent-clear is
111
+ // dropped so `onChange` stays a selection-change (submit) event; a real
112
+ // selection always carries a non-empty identifier.
113
+ if (!identifier) {
114
+ return;
115
+ }
116
+ const item = findDropdownSearchItem(identifier);
117
+ const detail: DropdownSearchChangeDetail = {
118
+ value: item ? resolveDropdownSearchItemLabel(item) : identifier,
119
+ selectedItem: item ?? null,
120
+ selectedItems: [],
121
+ status: item ? 'valid' : dropdownSearchQuery.trim() ? 'invalid' : 'empty'
122
+ };
123
+ void control.onChange(detail);
124
+ }
125
+
126
+ // External value/selected-item changes rebuild the root Dropdown instance:
127
+ // controlled `value` resets its internal query, results, and draft, and
128
+ // `selectedOptions` then displays the new selection.
129
+ $: dropdownSearchKey = control.type === 'dropdownSearch'
130
+ ? `${control.value}\u0000${control.selectedItem?.value ?? ''}`
131
+ : '';
132
+ // A selected item displays through `selectedOptions`; a free-text external
133
+ // value has no option to resolve and falls back to the Dropdown value itself.
134
+ $: dropdownSearchValue = control.type === 'dropdownSearch'
135
+ ? control.selectedItem
136
+ ? control.selectedItem.value
137
+ : control.value
138
+ : '';
139
+ // The raw item is handed to the Dropdown untouched so its `getItemLabel` prop
140
+ // is the single label conversion on the display path.
141
+ $: dropdownSearchSelectedOptions = control.type === 'dropdownSearch' && control.selectedItem
142
+ ? [control.selectedItem]
143
+ : [];
144
+
35
145
  </script>
36
146
 
37
147
  {#if control.type === 'container'}
@@ -110,37 +220,36 @@
110
220
  {/each}
111
221
  </div>
112
222
  {:else if control.type === 'dropdownSearch'}
113
- <DropdownSearch
114
- value={control.value}
115
- selectedItem={control.selectedItem}
116
- status={control.status}
117
- placeholder={control.placeholder ?? ''}
118
- ariaLabel={control.ariaLabel}
119
- debounceMs={control.debounceMs ?? 500}
120
- limit={control.limit ?? 10}
121
- minLength={control.minLength ?? 1}
122
- closeOnValid={control.closeOnValid ?? false}
123
- showOptionsOnFocus={control.showOptionsOnFocus ?? false}
124
- focusOptions={control.focusOptions ?? []}
125
- footerText={control.footerText}
126
- {language}
127
- noResultsText={control.noResultsText}
128
- loadingText={control.loadingText}
129
- clearLabel={control.clearLabel}
130
- searchOnExternalValueChange={control.searchOnExternalValueChange ?? false}
131
- width={control.width}
132
- minWidth={control.minWidth}
133
- maxWidth={control.maxWidth}
134
- getItemLabel={control.getItemLabel}
135
- loadOptions={control.loadOptions}
136
- onChange={(detail) => control.type === 'dropdownSearch' && void control.onChange(detail)}
137
- />
223
+ {#key dropdownSearchKey}
224
+ <Dropdown
225
+ value={dropdownSearchValue}
226
+ search
227
+ inputStyle="input"
228
+ portal
229
+ selectedOptions={dropdownSearchSelectedOptions}
230
+ loadOptions={loadDropdownSearchOptions}
231
+ searchDebounceMs={control.debounceMs ?? 500}
232
+ searchLimit={control.limit ?? 10}
233
+ placeholder={control.placeholder ?? ''}
234
+ ariaLabel={control.ariaLabel}
235
+ {language}
236
+ noResultsText={control.noResultsText}
237
+ loadingText={control.loadingText}
238
+ width={control.width}
239
+ minWidth={control.minWidth}
240
+ maxWidth={control.maxWidth}
241
+ getItemLabel={dropdownSearchOptionLabel}
242
+ onChange={handleDropdownSearchChange}
243
+ onSearchChange={handleDropdownSearchQueryChange}
244
+ />
245
+ {/key}
138
246
  {:else if control.type === 'dateRange'}
139
247
  <DateRangeFilter
140
248
  value={control.value}
141
249
  {language}
142
250
  startLabel={control.startLabel}
143
251
  endLabel={control.endLabel}
252
+ presets={control.presets}
144
253
  presetLabels={control.presetLabels ?? {}}
145
254
  defaultPreset={control.defaultPreset}
146
255
  quickYears={control.quickYears}