@placeholderco/placeholder-ui 1.0.3 → 1.0.6

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 (136) hide show
  1. package/LICENSE +26 -26
  2. package/README.md +179 -179
  3. package/dist/display/Alert.svelte +179 -179
  4. package/dist/display/Avatar.svelte +166 -166
  5. package/dist/display/LinkCollection.svelte +161 -161
  6. package/dist/display/Paper.svelte +118 -118
  7. package/dist/form/Autocomplete.svelte +223 -191
  8. package/dist/form/Autocomplete.svelte.d.ts +3 -1
  9. package/dist/form/AutocompleteMulti.svelte +356 -0
  10. package/dist/form/AutocompleteMulti.svelte.d.ts +28 -0
  11. package/dist/form/Checkbox.svelte +201 -201
  12. package/dist/form/Chips.svelte +128 -128
  13. package/dist/form/ComboBox.svelte +158 -158
  14. package/dist/form/ComboBox.svelte.d.ts +1 -1
  15. package/dist/form/ComboBoxItemBuilder.svelte +460 -460
  16. package/dist/form/ComboBoxMulti.svelte +197 -197
  17. package/dist/form/ComboBoxMulti.svelte.d.ts +1 -1
  18. package/dist/form/CronBuilder.svelte +693 -693
  19. package/dist/form/DatePicker.svelte +672 -672
  20. package/dist/form/DateTimePicker.svelte +712 -712
  21. package/dist/form/FileInput.svelte +235 -235
  22. package/dist/form/FormGroup.svelte +68 -68
  23. package/dist/form/Number.svelte +238 -238
  24. package/dist/form/PasswordInput.svelte +252 -252
  25. package/dist/form/RadioGroup.svelte +210 -210
  26. package/dist/form/Rating.svelte +235 -235
  27. package/dist/form/SegmentedControl.svelte +149 -149
  28. package/dist/form/Select.svelte +590 -590
  29. package/dist/form/Select.svelte.d.ts +1 -1
  30. package/dist/form/SelectMulti.svelte +613 -613
  31. package/dist/form/SelectMulti.svelte.d.ts +1 -1
  32. package/dist/form/Slider.svelte +358 -358
  33. package/dist/form/Switch.svelte +147 -147
  34. package/dist/form/TextArea.svelte +148 -148
  35. package/dist/form/Textbox.svelte +228 -228
  36. package/dist/form/TimePicker.svelte +267 -267
  37. package/dist/icon/Icon.svelte +52 -52
  38. package/dist/icon/alert-octagon.svg +5 -5
  39. package/dist/icon/alert-triangle.svg +5 -5
  40. package/dist/icon/archive.svg +1 -1
  41. package/dist/icon/arrow-down.svg +1 -1
  42. package/dist/icon/arrow-left.svg +1 -1
  43. package/dist/icon/arrow-right.svg +1 -1
  44. package/dist/icon/arrow-up.svg +1 -1
  45. package/dist/icon/at.svg +1 -1
  46. package/dist/icon/bell.svg +1 -1
  47. package/dist/icon/bookmark.svg +1 -1
  48. package/dist/icon/calendar.svg +1 -1
  49. package/dist/icon/camera.svg +1 -1
  50. package/dist/icon/chart-bar.svg +1 -1
  51. package/dist/icon/chart-line.svg +1 -1
  52. package/dist/icon/chart-pie.svg +1 -1
  53. package/dist/icon/checkbox.svg +1 -1
  54. package/dist/icon/checklist.svg +1 -1
  55. package/dist/icon/circle-check.svg +1 -1
  56. package/dist/icon/circle-x.svg +1 -1
  57. package/dist/icon/clock.svg +1 -1
  58. package/dist/icon/credit-card.svg +1 -1
  59. package/dist/icon/dots-vertical.svg +1 -1
  60. package/dist/icon/dots.svg +1 -1
  61. package/dist/icon/external-link.svg +1 -1
  62. package/dist/icon/eye-off.svg +1 -1
  63. package/dist/icon/eye.svg +1 -1
  64. package/dist/icon/filter.svg +1 -1
  65. package/dist/icon/fingerprint.svg +1 -1
  66. package/dist/icon/flag.svg +1 -1
  67. package/dist/icon/heart.svg +1 -1
  68. package/dist/icon/home.svg +1 -1
  69. package/dist/icon/key.svg +1 -1
  70. package/dist/icon/list-check.svg +1 -1
  71. package/dist/icon/login.svg +1 -1
  72. package/dist/icon/logout.svg +1 -1
  73. package/dist/icon/map-pin.svg +1 -1
  74. package/dist/icon/maximize.svg +1 -1
  75. package/dist/icon/microphone.svg +1 -1
  76. package/dist/icon/minimize.svg +1 -1
  77. package/dist/icon/note.svg +1 -1
  78. package/dist/icon/player-pause.svg +1 -1
  79. package/dist/icon/printer.svg +1 -1
  80. package/dist/icon/qrcode.svg +1 -1
  81. package/dist/icon/send.svg +1 -1
  82. package/dist/icon/settings.svg +1 -1
  83. package/dist/icon/share.svg +1 -1
  84. package/dist/icon/shopping-cart.svg +1 -1
  85. package/dist/icon/sort-ascending.svg +1 -1
  86. package/dist/icon/sort-descending.svg +1 -1
  87. package/dist/icon/star.svg +1 -1
  88. package/dist/icon/tag.svg +1 -1
  89. package/dist/icon/trending-down.svg +1 -1
  90. package/dist/icon/trending-up.svg +1 -1
  91. package/dist/icon/upload.svg +1 -1
  92. package/dist/icon/volume-off.svg +1 -1
  93. package/dist/icon/volume.svg +1 -1
  94. package/dist/icon/world.svg +1 -1
  95. package/dist/icon/zoom-in.svg +1 -1
  96. package/dist/icon/zoom-out.svg +1 -1
  97. package/dist/index.d.ts +1 -0
  98. package/dist/index.js +1 -0
  99. package/dist/layout/AppShell.svelte +169 -169
  100. package/dist/layout/CustomNavbar.svelte +61 -61
  101. package/dist/layout/Navbar.svelte +206 -206
  102. package/dist/layout/NavbarItemDisplay.svelte +29 -29
  103. package/dist/layout/Sidenav.svelte +712 -712
  104. package/dist/styles/components.css +199 -199
  105. package/dist/styles/dark.css +146 -146
  106. package/dist/styles/index.css +116 -116
  107. package/dist/styles/reset.css +110 -110
  108. package/dist/styles/semantic.css +86 -86
  109. package/dist/styles/tokens.css +203 -197
  110. package/dist/styles/utilities.css +523 -523
  111. package/dist/ui/Accordion.svelte +289 -289
  112. package/dist/ui/ActionIcon.svelte +76 -76
  113. package/dist/ui/Badge.svelte +329 -279
  114. package/dist/ui/Breadcrumbs.svelte +131 -131
  115. package/dist/ui/Button.svelte +432 -370
  116. package/dist/ui/ButtonVariant.d.ts +1 -1
  117. package/dist/ui/Dialog.svelte +307 -307
  118. package/dist/ui/Drawer.svelte +524 -524
  119. package/dist/ui/Dropdown.svelte +97 -97
  120. package/dist/ui/Dropzone.svelte +122 -122
  121. package/dist/ui/Link.svelte +32 -32
  122. package/dist/ui/Loader.svelte +70 -70
  123. package/dist/ui/LoadingOverlay.svelte +53 -53
  124. package/dist/ui/Pagination.svelte +135 -135
  125. package/dist/ui/Popover.svelte +225 -225
  126. package/dist/ui/Progress.svelte +191 -191
  127. package/dist/ui/RingProgress.svelte +141 -141
  128. package/dist/ui/Skeleton.svelte +85 -85
  129. package/dist/ui/Stepper.svelte +355 -355
  130. package/dist/ui/Table.svelte +345 -345
  131. package/dist/ui/Tabs.svelte +146 -146
  132. package/dist/ui/ThemeSwitcher.svelte +39 -39
  133. package/dist/ui/Timeline.svelte +225 -225
  134. package/dist/ui/Toaster.svelte +6 -6
  135. package/dist/ui/Tooltip.svelte +434 -434
  136. package/package.json +14 -14
@@ -1,613 +1,613 @@
1
- <script lang="ts">
2
- import { clickOutside } from "../actions/ClickOutside.js";
3
- import type { NotifyModel } from "../models/NotifyModel.js";
4
- import type {
5
- ComboBoxGroup,
6
- ComboBoxItem,
7
- FetchFunctionType,
8
- RetrieveLabelFunctionType,
9
- SearchFunctionType,
10
- } from "../models/ComboBoxItem.js";
11
- import ComboBoxMulti from "./ComboBoxMulti.svelte";
12
- import FormGroup from "./FormGroup.svelte";
13
- import Icon from "../icon/Icon.svelte";
14
- import Loader from "../ui/Loader.svelte";
15
- import { useDialogEvents } from "../ui/DialogEvents.svelte.js";
16
- import selectorSvg from "../icon/selector.svg?raw";
17
- import Textbox from "./Textbox.svelte";
18
- import Badge from "../ui/Badge.svelte";
19
- import ActionIcon from "../ui/ActionIcon.svelte";
20
- import { iconX } from "../icon/index.js";
21
- import { onMount, tick, type Snippet } from "svelte";
22
-
23
- interface Props {
24
- label?: string;
25
- values?: string[];
26
- rawValues?: ComboBoxItem[];
27
- placeholder?: string;
28
- class?: string;
29
- containerClass?: string;
30
- disabled?: boolean;
31
- required?: boolean;
32
- showError?: boolean;
33
- errorText?: string;
34
- autofocus?: boolean; // will also cause dialog autofocus on first open only
35
- autofocusDialog?: boolean; // will cause autofocus on every open, not just the first time
36
- allowSearch?: boolean;
37
- hideNoResults?: boolean;
38
- closeOnSelection?: boolean;
39
- loadingText?: string;
40
- options?: ComboBoxItem[];
41
- groupedOptions?: ComboBoxGroup[];
42
- tooltipLocation?: "top" | "bottom" | "left" | "right";
43
- fetchFunction?: FetchFunctionType | null;
44
- searchFunction?: SearchFunctionType | null;
45
- retrieveLabelFunction?: RetrieveLabelFunctionType | null;
46
- tooltipContent?: Snippet;
47
- tooltipText?: string;
48
- onchange?: (e: string[]) => void;
49
- onchangeRaw?: (e: ComboBoxItem[]) => void;
50
- }
51
-
52
- let {
53
- label = "",
54
- values = $bindable([]),
55
- rawValues = $bindable([]),
56
- placeholder = "\u00a0", // non-breaking space to prevent the height collapsing
57
- class: classes = "",
58
- containerClass = "",
59
- disabled = false,
60
- required = false,
61
- showError = false,
62
- errorText = "",
63
- autofocus = false,
64
- autofocusDialog = false,
65
- allowSearch = false,
66
- hideNoResults = false,
67
- closeOnSelection = false,
68
- loadingText = "",
69
- options = [],
70
- groupedOptions = [],
71
- tooltipLocation = "top",
72
- fetchFunction = null,
73
- searchFunction = null,
74
- retrieveLabelFunction = null,
75
- tooltipContent,
76
- tooltipText,
77
- onchange,
78
- onchangeRaw,
79
- }: Props = $props();
80
-
81
- let internalValues: string[] = $state([]);
82
- let filterString = $state("");
83
- let preloading = $state(false);
84
- let searching = $state(false);
85
- let timeout: number;
86
-
87
- let allGroups: ComboBoxGroup[] = $state([]);
88
- let filteredGroups: ComboBoxGroup[] = $state([]);
89
-
90
- let open = $state(false);
91
- let hideCombobox = $state(false);
92
-
93
- if (values === null || values === undefined) values = [];
94
-
95
- let isEmpty: boolean = $derived(rawValues?.length === 0);
96
-
97
- onMount(() => {
98
- initOptions(options);
99
- if (autofocus) {
100
- tick().then(() => {
101
- focus();
102
- });
103
- }
104
- });
105
-
106
- useDialogEvents({
107
- onFirstOpen: () => {
108
- if (autofocus) {
109
- // Focus the input when dialog opens
110
- setTimeout(() => {
111
- focus();
112
- }, 150);
113
- }
114
- },
115
- onOpen: () => {
116
- if (autofocusDialog) {
117
- // Focus the input every time dialog opens
118
- setTimeout(() => {
119
- focus();
120
- }, 150);
121
- }
122
- },
123
- });
124
-
125
- function focus() {
126
- open = true;
127
- tick().then(() => textboxElement?.focus());
128
- }
129
-
130
- let canReloadValues = false;
131
-
132
- $effect(() => {
133
- const currentValues = values || [];
134
- const currentInternalValues = internalValues || [];
135
-
136
- const arraysAreDifferent =
137
- currentValues.length !== currentInternalValues.length ||
138
- currentValues.some(
139
- (value, index) => value !== currentInternalValues[index],
140
- );
141
-
142
- if (arraysAreDifferent) {
143
- internalValues = [...currentValues];
144
-
145
- // Reset all options selection state
146
- for (let option of allGroups.flatMap((x) => x.items)) {
147
- option.selected = false;
148
- }
149
-
150
- // Update rawValues based on new values
151
- let newRawValues: ComboBoxItem[] = [];
152
-
153
- for (let value of currentValues) {
154
- const foundOption = allGroups
155
- .flatMap((x) => x.items)
156
- .find((x) => x.value === value);
157
-
158
- if (foundOption) {
159
- foundOption.selected = true;
160
- newRawValues.push({ ...foundOption });
161
- } else if (value != null) {
162
- // Create a new option if not found in allOptions
163
- const newOption = {
164
- label: String(value),
165
- value: value,
166
- selected: true,
167
- };
168
- newRawValues.push(newOption);
169
- }
170
- }
171
-
172
- rawValues = newRawValues;
173
- }
174
- });
175
-
176
- function initOptions(options: ComboBoxItem[]) {
177
- if (fetchFunction) {
178
- preloading = true;
179
- fetchFunction().then((response: NotifyModel<ComboBoxItem[]>) => {
180
- convertOptions(response.object!);
181
- preloadValues();
182
- preloading = false;
183
- });
184
- } else {
185
- if (groupedOptions.length) {
186
- convertGroupOptions(groupedOptions);
187
- } else if (options.length) {
188
- convertOptions(options);
189
- } else if (searchFunction) {
190
- // Initialize empty groups for search function
191
- allGroups = [];
192
- filteredGroups = [];
193
- allowSearch = true;
194
- } else {
195
- filteredGroups = [];
196
- }
197
- preloadValues();
198
- }
199
- }
200
-
201
- function preloadValues(overwrite: boolean = false) {
202
- if (!values || values.length === 0) {
203
- rawValues = [];
204
- values = [];
205
-
206
- for (let option of allGroups.flatMap((x) => x.items))
207
- option.selected = false;
208
-
209
- return;
210
- }
211
-
212
- if (rawValues.length > 0 && !overwrite) return;
213
-
214
- let promises = [];
215
- let newRawValues: ComboBoxItem[] = [];
216
-
217
- for (let option of allGroups.flatMap((x) => x.items))
218
- option.selected = false;
219
-
220
- for (let value of values) {
221
- const foundOption = allGroups
222
- .flatMap((x) => x.items)
223
- .find((x) => x.value === value);
224
-
225
- if (foundOption !== undefined) {
226
- foundOption.selected = true;
227
- newRawValues.push(foundOption);
228
- } else if (searchFunction && retrieveLabelFunction) {
229
- preloading = true;
230
- let object = { label: value, value: value, selected: true };
231
-
232
- promises.push(
233
- retrieveLabelFunction(value).then((response) => {
234
- object.label = response.object ?? value!.toString();
235
- }),
236
- );
237
-
238
- newRawValues.push(object);
239
- } else {
240
- newRawValues.push({
241
- label: value,
242
- value: value,
243
- selected: true,
244
- });
245
- }
246
- }
247
-
248
- if (promises && promises.length > 0) {
249
- preloading = true;
250
-
251
- Promise.all(promises).then((x) => {
252
- rawValues = newRawValues;
253
- preloading = false;
254
- });
255
- } else {
256
- rawValues = newRawValues;
257
- }
258
- }
259
-
260
- function convertOptions(options: ComboBoxItem[]) {
261
- const allOptions = options.map((option) => {
262
- return {
263
- ...option,
264
- selected: values && values.some((x) => x === option.value),
265
- };
266
- });
267
- allGroups = [{ label: "", items: [...allOptions] }];
268
- filteredGroups = [{ label: "", items: [...allOptions] }];
269
- }
270
-
271
- function convertGroupOptions(groups: ComboBoxGroup[]) {
272
- allGroups = groups.map((group) => {
273
- return {
274
- ...group,
275
- items: group.items.map((option) => {
276
- return {
277
- ...option,
278
- selected:
279
- values && values.some((x) => x === option.value),
280
- groupName: group.label,
281
- };
282
- }),
283
- };
284
- });
285
- filteredGroups = [...allGroups];
286
- }
287
-
288
- function onSelection(selectedValue: ComboBoxItem) {
289
- // Find the actual option in allOptions to ensure we're working with the correct reference
290
- const optionIndex = allGroups
291
- .flatMap((x) => x.items)
292
- .findIndex((opt) => opt.value === selectedValue.value);
293
- const actualOption =
294
- optionIndex !== -1
295
- ? allGroups.flatMap((x) => x.items)[optionIndex]
296
- : selectedValue;
297
-
298
- actualOption.selected = !actualOption.selected;
299
-
300
- if (actualOption.selected && !values.includes(actualOption.value)) {
301
- values = [...values, actualOption.value];
302
- rawValues = [...rawValues, actualOption];
303
- } else if (!actualOption.selected) {
304
- values = values.filter((value) => value !== actualOption.value);
305
- rawValues = rawValues.filter(
306
- (rawValue) => rawValue.value !== actualOption.value,
307
- );
308
- }
309
-
310
- onchange?.(values);
311
- onchangeRaw?.(rawValues);
312
-
313
- if (closeOnSelection) {
314
- open = false;
315
- }
316
- if (textboxElement) {
317
- (textboxElement as HTMLInputElement).select();
318
- }
319
- }
320
-
321
- function onFilterChange(value: string) {
322
- if (searchFunction && value) {
323
- clearTimeout(timeout);
324
- searching = true;
325
- timeout = window.setTimeout(() => {
326
- searchFunction(value).then(
327
- (response: NotifyModel<ComboBoxItem[]>) => {
328
- convertOptions(response.object!);
329
- searching = false;
330
- open = true;
331
- hideCombobox = false;
332
- },
333
- );
334
- }, 300);
335
- } else if (allGroups) {
336
- filteredGroups = allGroups.filter((group) =>
337
- group.items.some((x) =>
338
- x.label.toLowerCase().includes(value.toLowerCase()),
339
- ),
340
- );
341
-
342
- if (filteredGroups.length > 0) {
343
- filteredGroups = filteredGroups.map((group) => ({
344
- ...group,
345
- items: group.items.filter((x) =>
346
- x.label.toLowerCase().includes(value.toLowerCase()),
347
- ),
348
- }));
349
- }
350
-
351
- hideCombobox = false;
352
- }
353
- }
354
-
355
- let comboBoxEl: ReturnType<typeof ComboBoxMulti> | undefined =
356
- $state(undefined);
357
- let textboxElement: HTMLElement | undefined = $state(undefined);
358
- let buttonElement: HTMLElement;
359
- let comboBoxKeyDown: ((e: KeyboardEvent) => void) | undefined =
360
- $state(undefined);
361
-
362
- function onKeyDown(e: KeyboardEvent) {
363
- if (!open) return;
364
-
365
- if (e.key === "Escape") {
366
- e.preventDefault();
367
- open = false;
368
- } else if (comboBoxKeyDown) {
369
- comboBoxKeyDown(e);
370
- }
371
-
372
- comboBoxEl?.handleKeyDown?.(e);
373
- }
374
-
375
- function closePopover(e: MouseEvent) {
376
- if (textboxElement && textboxElement.contains(e.target as HTMLElement))
377
- return;
378
- if (buttonElement.contains(e.target as HTMLElement)) return;
379
-
380
- open = false;
381
- }
382
-
383
- const extraClasses: string[] = $derived.by(() => {
384
- const classes = [];
385
- if (open) classes.push("!border-accent");
386
- if (allowSearch && open && !isEmpty)
387
- classes.push("border-b-0 rounded-b-none");
388
- if (disabled) classes.push("disabled");
389
- if (showError) classes.push("show-error");
390
-
391
- return classes;
392
- });
393
- </script>
394
-
395
- <div
396
- class="select-container {containerClass}"
397
- class:mb-[1px]={allowSearch && open && !isEmpty}
398
- >
399
- <FormGroup {label} {required} {tooltipContent} {tooltipLocation}>
400
- <button
401
- bind:this={buttonElement}
402
- onclick={() => {
403
- open = !open;
404
- if (open && allowSearch) {
405
- tick().then(() => textboxElement?.focus());
406
- }
407
- }}
408
- onkeydown={(e) => onKeyDown(e)}
409
- disabled={preloading || disabled}
410
- class="select {extraClasses} {classes}"
411
- >
412
- {#if isEmpty || preloading}
413
- <span class="placeholder"
414
- >{preloading ? "Loading..." : placeholder}</span
415
- >
416
- {:else}
417
- <div class="selected-items">
418
- {#each rawValues as value}
419
- {#if disabled}
420
- <Badge>{value.label}</Badge>
421
- {:else}
422
- <Badge
423
- onDelete={() => {
424
- onSelection(value);
425
- tick().then(() => (open = false));
426
- }}>{value.label}</Badge
427
- >
428
- {/if}
429
- <!-- <span class="inline-flex items-center bg-primary text-white rounded-md px-2">
430
- {value.label}
431
- <button
432
- class="leading-0 ml-1 size-4 rounded hover:bg-neutral-400/25"
433
- {disabled}
434
- onclick={(e) => {
435
- if (disabled) return;
436
-
437
- value.selected = !value.selected;
438
- onSelection(value);
439
- open = false;
440
- e.stopPropagation();
441
- }}
442
-
443
- </button>
444
- </span> -->
445
- {/each}
446
- </div>
447
- {/if}
448
- {#if preloading || searching}
449
- <div class="loader">
450
- <Loader sizeOverride="1.125rem" />
451
- </div>
452
- {:else if open && !isEmpty}
453
- <div class="btn-clear-container">
454
- <ActionIcon
455
- variant="secondary-subtle"
456
- svg={iconX}
457
- size="0.75rem"
458
- onclick={(e: MouseEvent) => {
459
- values = [];
460
- rawValues = [];
461
-
462
- onchange?.(values);
463
- onchangeRaw?.(rawValues);
464
-
465
- options.forEach((x) => (x.selected = false));
466
- allGroups
467
- .flatMap((x) => x.items)
468
- .forEach((x) => (x.selected = false));
469
-
470
- e.stopPropagation();
471
- }}
472
- />
473
- </div>
474
- {:else}
475
- <div class="select-icon">
476
- <Icon svg={selectorSvg} size="1rem" />
477
- </div>
478
- {/if}
479
- </button>
480
- </FormGroup>
481
- <div
482
- class="select-panel top-full
483
- {open ? '' : 'hidden'}"
484
- >
485
- <div class="select-panel-inner" use:clickOutside={closePopover}>
486
- {#if allowSearch}
487
- <Textbox
488
- bind:value={filterString}
489
- class="w-full
490
- {open ? '!border-accent' : 'hidden'}
491
- {open && !isEmpty ? 'border-t-0 rounded-t-none' : ''}"
492
- bind:textboxElement
493
- placeholder="Start typing to search..."
494
- disabled={preloading}
495
- noAutocomplete
496
- oninput={() => onFilterChange(filterString)}
497
- onkeydown={onKeyDown}
498
- />
499
- {/if}
500
- <ComboBoxMulti
501
- bind:this={comboBoxEl}
502
- {filterString}
503
- values={rawValues}
504
- {onSelection}
505
- onkeydown={comboBoxKeyDown}
506
- groupedOptions={filteredGroups}
507
- open={open && !hideCombobox}
508
- loading={searching}
509
- {hideNoResults}
510
- {loadingText}
511
- />
512
- </div>
513
- </div>
514
- {#if showError && errorText}
515
- <div class="text-error text-xs">{errorText}</div>
516
- {/if}
517
- </div>
518
-
519
- <style>
520
- .select-container {
521
- position: relative;
522
- }
523
-
524
- .select {
525
- font-size: var(--pui-font-size-md);
526
- line-height: var(--pui-line-height-normal);
527
- position: relative;
528
- display: flex;
529
- flex-wrap: wrap;
530
- padding: var(--pui-spacing-1) var(--pui-spacing-2);
531
- border-radius: var(--pui-radius-base);
532
- text-align: left;
533
- background-color: var(--pui-input-bg);
534
- color: var(--pui-text-primary);
535
- border-color: var(--pui-border-default);
536
- cursor: pointer;
537
- width: 100%;
538
- align-items: center;
539
- border: 1px solid var(--pui-border-default);
540
- padding-right: var(--pui-spacing-8);
541
- }
542
-
543
- .select:focus {
544
- border-color: var(--pui-accent-color);
545
- outline: 2px solid transparent;
546
- outline-offset: var(--pui-focus-ring-offset);
547
- }
548
-
549
- .select-icon {
550
- position: absolute;
551
- right: var(--pui-spacing-2);
552
- width: var(--pui-spacing-4);
553
- display: flex;
554
- }
555
-
556
- .btn-clear-container {
557
- position: absolute;
558
- line-height: 0;
559
- right: var(--pui-spacing-1);
560
- }
561
-
562
- .select-btn-clear {
563
- border-radius: var(--pui-radius-base);
564
- padding: var(--pui-spacing-1);
565
- }
566
-
567
- .select-panel {
568
- position: absolute;
569
- width: 100%;
570
- left: 0;
571
- z-index: var(--pui-z-dropdown);
572
- }
573
-
574
- .select-panel-inner {
575
- position: relative;
576
- width: 100%;
577
- }
578
-
579
- .placeholder {
580
- color: var(--pui-text-placeholder);
581
- }
582
-
583
- .hidden {
584
- display: none;
585
- }
586
-
587
- .disabled {
588
- opacity: 0.5;
589
- cursor: not-allowed;
590
- background-color: var(--pui-bg-disabled);
591
- }
592
-
593
- .selected-items {
594
- display: flex;
595
- flex-wrap: wrap;
596
- gap: var(--pui-spacing-1);
597
- }
598
-
599
- .loader {
600
- position: absolute;
601
- right: var(--pui-spacing-2_5);
602
- width: var(--pui-spacing-4);
603
- top: 6px;
604
- }
605
-
606
- .show-error {
607
- border-color: var(--pui-text-danger);
608
- }
609
-
610
- .text-error {
611
- color: var(--pui-text-danger);
612
- }
613
- </style>
1
+ <script lang="ts">
2
+ import { clickOutside } from "../actions/ClickOutside.js";
3
+ import type { NotifyModel } from "../models/NotifyModel.js";
4
+ import type {
5
+ ComboBoxGroup,
6
+ ComboBoxItem,
7
+ FetchFunctionType,
8
+ RetrieveLabelFunctionType,
9
+ SearchFunctionType,
10
+ } from "../models/ComboBoxItem.js";
11
+ import ComboBoxMulti from "./ComboBoxMulti.svelte";
12
+ import FormGroup from "./FormGroup.svelte";
13
+ import Icon from "../icon/Icon.svelte";
14
+ import Loader from "../ui/Loader.svelte";
15
+ import { useDialogEvents } from "../ui/DialogEvents.svelte.js";
16
+ import selectorSvg from "../icon/selector.svg?raw";
17
+ import Textbox from "./Textbox.svelte";
18
+ import Badge from "../ui/Badge.svelte";
19
+ import ActionIcon from "../ui/ActionIcon.svelte";
20
+ import { iconX } from "../icon/index.js";
21
+ import { onMount, tick, type Snippet } from "svelte";
22
+
23
+ interface Props {
24
+ label?: string;
25
+ values?: string[];
26
+ rawValues?: ComboBoxItem[];
27
+ placeholder?: string;
28
+ class?: string;
29
+ containerClass?: string;
30
+ disabled?: boolean;
31
+ required?: boolean;
32
+ showError?: boolean;
33
+ errorText?: string;
34
+ autofocus?: boolean; // will also cause dialog autofocus on first open only
35
+ autofocusDialog?: boolean; // will cause autofocus on every open, not just the first time
36
+ allowSearch?: boolean;
37
+ showNoResultsMessage?: boolean;
38
+ closeOnSelection?: boolean;
39
+ loadingText?: string;
40
+ options?: ComboBoxItem[];
41
+ groupedOptions?: ComboBoxGroup[];
42
+ tooltipLocation?: "top" | "bottom" | "left" | "right";
43
+ fetchFunction?: FetchFunctionType | null;
44
+ searchFunction?: SearchFunctionType | null;
45
+ retrieveLabelFunction?: RetrieveLabelFunctionType | null;
46
+ tooltipContent?: Snippet;
47
+ tooltipText?: string;
48
+ onchange?: (e: string[]) => void;
49
+ onchangeRaw?: (e: ComboBoxItem[]) => void;
50
+ }
51
+
52
+ let {
53
+ label = "",
54
+ values = $bindable([]),
55
+ rawValues = $bindable([]),
56
+ placeholder = "\u00a0", // non-breaking space to prevent the height collapsing
57
+ class: classes = "",
58
+ containerClass = "",
59
+ disabled = false,
60
+ required = false,
61
+ showError = false,
62
+ errorText = "",
63
+ autofocus = false,
64
+ autofocusDialog = false,
65
+ allowSearch = false,
66
+ showNoResultsMessage = true,
67
+ closeOnSelection = false,
68
+ loadingText = "",
69
+ options = [],
70
+ groupedOptions = [],
71
+ tooltipLocation = "top",
72
+ fetchFunction = null,
73
+ searchFunction = null,
74
+ retrieveLabelFunction = null,
75
+ tooltipContent,
76
+ tooltipText,
77
+ onchange,
78
+ onchangeRaw,
79
+ }: Props = $props();
80
+
81
+ let internalValues: string[] = $state([]);
82
+ let filterString = $state("");
83
+ let preloading = $state(false);
84
+ let searching = $state(false);
85
+ let timeout: number;
86
+
87
+ let allGroups: ComboBoxGroup[] = $state([]);
88
+ let filteredGroups: ComboBoxGroup[] = $state([]);
89
+
90
+ let open = $state(false);
91
+ let hideCombobox = $state(false);
92
+
93
+ if (values === null || values === undefined) values = [];
94
+
95
+ let isEmpty: boolean = $derived(rawValues?.length === 0);
96
+
97
+ onMount(() => {
98
+ initOptions(options);
99
+ if (autofocus) {
100
+ tick().then(() => {
101
+ focus();
102
+ });
103
+ }
104
+ });
105
+
106
+ useDialogEvents({
107
+ onFirstOpen: () => {
108
+ if (autofocus) {
109
+ // Focus the input when dialog opens
110
+ setTimeout(() => {
111
+ focus();
112
+ }, 150);
113
+ }
114
+ },
115
+ onOpen: () => {
116
+ if (autofocusDialog) {
117
+ // Focus the input every time dialog opens
118
+ setTimeout(() => {
119
+ focus();
120
+ }, 150);
121
+ }
122
+ },
123
+ });
124
+
125
+ function focus() {
126
+ open = true;
127
+ tick().then(() => textboxElement?.focus());
128
+ }
129
+
130
+ let canReloadValues = false;
131
+
132
+ $effect(() => {
133
+ const currentValues = values || [];
134
+ const currentInternalValues = internalValues || [];
135
+
136
+ const arraysAreDifferent =
137
+ currentValues.length !== currentInternalValues.length ||
138
+ currentValues.some(
139
+ (value, index) => value !== currentInternalValues[index],
140
+ );
141
+
142
+ if (arraysAreDifferent) {
143
+ internalValues = [...currentValues];
144
+
145
+ // Reset all options selection state
146
+ for (let option of allGroups.flatMap((x) => x.items)) {
147
+ option.selected = false;
148
+ }
149
+
150
+ // Update rawValues based on new values
151
+ let newRawValues: ComboBoxItem[] = [];
152
+
153
+ for (let value of currentValues) {
154
+ const foundOption = allGroups
155
+ .flatMap((x) => x.items)
156
+ .find((x) => x.value === value);
157
+
158
+ if (foundOption) {
159
+ foundOption.selected = true;
160
+ newRawValues.push({ ...foundOption });
161
+ } else if (value != null) {
162
+ // Create a new option if not found in allOptions
163
+ const newOption = {
164
+ label: String(value),
165
+ value: value,
166
+ selected: true,
167
+ };
168
+ newRawValues.push(newOption);
169
+ }
170
+ }
171
+
172
+ rawValues = newRawValues;
173
+ }
174
+ });
175
+
176
+ function initOptions(options: ComboBoxItem[]) {
177
+ if (fetchFunction) {
178
+ preloading = true;
179
+ fetchFunction().then((response: NotifyModel<ComboBoxItem[]>) => {
180
+ convertOptions(response.object!);
181
+ preloadValues();
182
+ preloading = false;
183
+ });
184
+ } else {
185
+ if (groupedOptions.length) {
186
+ convertGroupOptions(groupedOptions);
187
+ } else if (options.length) {
188
+ convertOptions(options);
189
+ } else if (searchFunction) {
190
+ // Initialize empty groups for search function
191
+ allGroups = [];
192
+ filteredGroups = [];
193
+ allowSearch = true;
194
+ } else {
195
+ filteredGroups = [];
196
+ }
197
+ preloadValues();
198
+ }
199
+ }
200
+
201
+ function preloadValues(overwrite: boolean = false) {
202
+ if (!values || values.length === 0) {
203
+ rawValues = [];
204
+ values = [];
205
+
206
+ for (let option of allGroups.flatMap((x) => x.items))
207
+ option.selected = false;
208
+
209
+ return;
210
+ }
211
+
212
+ if (rawValues.length > 0 && !overwrite) return;
213
+
214
+ let promises = [];
215
+ let newRawValues: ComboBoxItem[] = [];
216
+
217
+ for (let option of allGroups.flatMap((x) => x.items))
218
+ option.selected = false;
219
+
220
+ for (let value of values) {
221
+ const foundOption = allGroups
222
+ .flatMap((x) => x.items)
223
+ .find((x) => x.value === value);
224
+
225
+ if (foundOption !== undefined) {
226
+ foundOption.selected = true;
227
+ newRawValues.push(foundOption);
228
+ } else if (searchFunction && retrieveLabelFunction) {
229
+ preloading = true;
230
+ let object = { label: value, value: value, selected: true };
231
+
232
+ promises.push(
233
+ retrieveLabelFunction(value).then((response) => {
234
+ object.label = response.object ?? value!.toString();
235
+ }),
236
+ );
237
+
238
+ newRawValues.push(object);
239
+ } else {
240
+ newRawValues.push({
241
+ label: value,
242
+ value: value,
243
+ selected: true,
244
+ });
245
+ }
246
+ }
247
+
248
+ if (promises && promises.length > 0) {
249
+ preloading = true;
250
+
251
+ Promise.all(promises).then((x) => {
252
+ rawValues = newRawValues;
253
+ preloading = false;
254
+ });
255
+ } else {
256
+ rawValues = newRawValues;
257
+ }
258
+ }
259
+
260
+ function convertOptions(options: ComboBoxItem[]) {
261
+ const allOptions = options.map((option) => {
262
+ return {
263
+ ...option,
264
+ selected: values && values.some((x) => x === option.value),
265
+ };
266
+ });
267
+ allGroups = [{ label: "", items: [...allOptions] }];
268
+ filteredGroups = [{ label: "", items: [...allOptions] }];
269
+ }
270
+
271
+ function convertGroupOptions(groups: ComboBoxGroup[]) {
272
+ allGroups = groups.map((group) => {
273
+ return {
274
+ ...group,
275
+ items: group.items.map((option) => {
276
+ return {
277
+ ...option,
278
+ selected:
279
+ values && values.some((x) => x === option.value),
280
+ groupName: group.label,
281
+ };
282
+ }),
283
+ };
284
+ });
285
+ filteredGroups = [...allGroups];
286
+ }
287
+
288
+ function onSelection(selectedValue: ComboBoxItem) {
289
+ // Find the actual option in allOptions to ensure we're working with the correct reference
290
+ const optionIndex = allGroups
291
+ .flatMap((x) => x.items)
292
+ .findIndex((opt) => opt.value === selectedValue.value);
293
+ const actualOption =
294
+ optionIndex !== -1
295
+ ? allGroups.flatMap((x) => x.items)[optionIndex]
296
+ : selectedValue;
297
+
298
+ actualOption.selected = !actualOption.selected;
299
+
300
+ if (actualOption.selected && !values.includes(actualOption.value)) {
301
+ values = [...values, actualOption.value];
302
+ rawValues = [...rawValues, actualOption];
303
+ } else if (!actualOption.selected) {
304
+ values = values.filter((value) => value !== actualOption.value);
305
+ rawValues = rawValues.filter(
306
+ (rawValue) => rawValue.value !== actualOption.value,
307
+ );
308
+ }
309
+
310
+ onchange?.(values);
311
+ onchangeRaw?.(rawValues);
312
+
313
+ if (closeOnSelection) {
314
+ open = false;
315
+ }
316
+ if (textboxElement) {
317
+ (textboxElement as HTMLInputElement).select();
318
+ }
319
+ }
320
+
321
+ function onFilterChange(value: string) {
322
+ if (searchFunction && value) {
323
+ clearTimeout(timeout);
324
+ searching = true;
325
+ timeout = window.setTimeout(() => {
326
+ searchFunction(value).then(
327
+ (response: NotifyModel<ComboBoxItem[]>) => {
328
+ convertOptions(response.object!);
329
+ searching = false;
330
+ open = true;
331
+ hideCombobox = false;
332
+ },
333
+ );
334
+ }, 300);
335
+ } else if (allGroups) {
336
+ filteredGroups = allGroups.filter((group) =>
337
+ group.items.some((x) =>
338
+ x.label.toLowerCase().includes(value.toLowerCase()),
339
+ ),
340
+ );
341
+
342
+ if (filteredGroups.length > 0) {
343
+ filteredGroups = filteredGroups.map((group) => ({
344
+ ...group,
345
+ items: group.items.filter((x) =>
346
+ x.label.toLowerCase().includes(value.toLowerCase()),
347
+ ),
348
+ }));
349
+ }
350
+
351
+ hideCombobox = false;
352
+ }
353
+ }
354
+
355
+ let comboBoxEl: ReturnType<typeof ComboBoxMulti> | undefined =
356
+ $state(undefined);
357
+ let textboxElement: HTMLElement | undefined = $state(undefined);
358
+ let buttonElement: HTMLElement;
359
+ let comboBoxKeyDown: ((e: KeyboardEvent) => void) | undefined =
360
+ $state(undefined);
361
+
362
+ function onKeyDown(e: KeyboardEvent) {
363
+ if (!open) return;
364
+
365
+ if (e.key === "Escape") {
366
+ e.preventDefault();
367
+ open = false;
368
+ } else if (comboBoxKeyDown) {
369
+ comboBoxKeyDown(e);
370
+ }
371
+
372
+ comboBoxEl?.handleKeyDown?.(e);
373
+ }
374
+
375
+ function closePopover(e: MouseEvent) {
376
+ if (textboxElement && textboxElement.contains(e.target as HTMLElement))
377
+ return;
378
+ if (buttonElement.contains(e.target as HTMLElement)) return;
379
+
380
+ open = false;
381
+ }
382
+
383
+ const extraClasses: string[] = $derived.by(() => {
384
+ const classes = [];
385
+ if (open) classes.push("!border-accent");
386
+ if (allowSearch && open && !isEmpty)
387
+ classes.push("border-b-0 rounded-b-none");
388
+ if (disabled) classes.push("disabled");
389
+ if (showError) classes.push("show-error");
390
+
391
+ return classes;
392
+ });
393
+ </script>
394
+
395
+ <div
396
+ class="select-container {containerClass}"
397
+ class:mb-[1px]={allowSearch && open && !isEmpty}
398
+ >
399
+ <FormGroup {label} {required} {tooltipContent} {tooltipLocation}>
400
+ <button
401
+ bind:this={buttonElement}
402
+ onclick={() => {
403
+ open = !open;
404
+ if (open && allowSearch) {
405
+ tick().then(() => textboxElement?.focus());
406
+ }
407
+ }}
408
+ onkeydown={(e) => onKeyDown(e)}
409
+ disabled={preloading || disabled}
410
+ class="select {extraClasses} {classes}"
411
+ >
412
+ {#if isEmpty || preloading}
413
+ <span class="placeholder"
414
+ >{preloading ? "Loading..." : placeholder}</span
415
+ >
416
+ {:else}
417
+ <div class="selected-items">
418
+ {#each rawValues as value}
419
+ {#if disabled}
420
+ <Badge>{value.label}</Badge>
421
+ {:else}
422
+ <Badge
423
+ onDelete={() => {
424
+ onSelection(value);
425
+ tick().then(() => (open = false));
426
+ }}>{value.label}</Badge
427
+ >
428
+ {/if}
429
+ <!-- <span class="inline-flex items-center bg-primary text-white rounded-md px-2">
430
+ {value.label}
431
+ <button
432
+ class="leading-0 ml-1 size-4 rounded hover:bg-neutral-400/25"
433
+ {disabled}
434
+ onclick={(e) => {
435
+ if (disabled) return;
436
+
437
+ value.selected = !value.selected;
438
+ onSelection(value);
439
+ open = false;
440
+ e.stopPropagation();
441
+ }}
442
+
443
+ </button>
444
+ </span> -->
445
+ {/each}
446
+ </div>
447
+ {/if}
448
+ {#if preloading || searching}
449
+ <div class="loader">
450
+ <Loader sizeOverride="1.125rem" />
451
+ </div>
452
+ {:else if open && !isEmpty}
453
+ <div class="btn-clear-container">
454
+ <ActionIcon
455
+ variant="secondary-subtle"
456
+ svg={iconX}
457
+ size="0.75rem"
458
+ onclick={(e: MouseEvent) => {
459
+ values = [];
460
+ rawValues = [];
461
+
462
+ onchange?.(values);
463
+ onchangeRaw?.(rawValues);
464
+
465
+ options.forEach((x) => (x.selected = false));
466
+ allGroups
467
+ .flatMap((x) => x.items)
468
+ .forEach((x) => (x.selected = false));
469
+
470
+ e.stopPropagation();
471
+ }}
472
+ />
473
+ </div>
474
+ {:else}
475
+ <div class="select-icon">
476
+ <Icon svg={selectorSvg} size="1rem" />
477
+ </div>
478
+ {/if}
479
+ </button>
480
+ </FormGroup>
481
+ <div
482
+ class="select-panel top-full
483
+ {open ? '' : 'hidden'}"
484
+ >
485
+ <div class="select-panel-inner" use:clickOutside={closePopover}>
486
+ {#if allowSearch}
487
+ <Textbox
488
+ bind:value={filterString}
489
+ class="w-full
490
+ {open ? '!border-accent' : 'hidden'}
491
+ {open && !isEmpty ? 'border-t-0 rounded-t-none' : ''}"
492
+ bind:textboxElement
493
+ placeholder="Start typing to search..."
494
+ disabled={preloading}
495
+ noAutocomplete
496
+ oninput={() => onFilterChange(filterString)}
497
+ onkeydown={onKeyDown}
498
+ />
499
+ {/if}
500
+ <ComboBoxMulti
501
+ bind:this={comboBoxEl}
502
+ {filterString}
503
+ values={rawValues}
504
+ {onSelection}
505
+ onkeydown={comboBoxKeyDown}
506
+ groupedOptions={filteredGroups}
507
+ open={open && !hideCombobox}
508
+ loading={searching}
509
+ {showNoResultsMessage}
510
+ {loadingText}
511
+ />
512
+ </div>
513
+ </div>
514
+ {#if showError && errorText}
515
+ <div class="text-error text-xs">{errorText}</div>
516
+ {/if}
517
+ </div>
518
+
519
+ <style>
520
+ .select-container {
521
+ position: relative;
522
+ }
523
+
524
+ .select {
525
+ font-size: var(--pui-font-size-md);
526
+ line-height: var(--pui-line-height-normal);
527
+ position: relative;
528
+ display: flex;
529
+ flex-wrap: wrap;
530
+ padding: var(--pui-spacing-1) var(--pui-spacing-2);
531
+ border-radius: var(--pui-radius-base);
532
+ text-align: left;
533
+ background-color: var(--pui-input-bg);
534
+ color: var(--pui-text-primary);
535
+ border-color: var(--pui-border-default);
536
+ cursor: pointer;
537
+ width: 100%;
538
+ align-items: center;
539
+ border: 1px solid var(--pui-border-default);
540
+ padding-right: var(--pui-spacing-8);
541
+ }
542
+
543
+ .select:focus {
544
+ border-color: var(--pui-accent-color);
545
+ outline: 2px solid transparent;
546
+ outline-offset: var(--pui-focus-ring-offset);
547
+ }
548
+
549
+ .select-icon {
550
+ position: absolute;
551
+ right: var(--pui-spacing-2);
552
+ width: var(--pui-spacing-4);
553
+ display: flex;
554
+ }
555
+
556
+ .btn-clear-container {
557
+ position: absolute;
558
+ line-height: 0;
559
+ right: var(--pui-spacing-1);
560
+ }
561
+
562
+ .select-btn-clear {
563
+ border-radius: var(--pui-radius-base);
564
+ padding: var(--pui-spacing-1);
565
+ }
566
+
567
+ .select-panel {
568
+ position: absolute;
569
+ width: 100%;
570
+ left: 0;
571
+ z-index: var(--pui-z-dropdown);
572
+ }
573
+
574
+ .select-panel-inner {
575
+ position: relative;
576
+ width: 100%;
577
+ }
578
+
579
+ .placeholder {
580
+ color: var(--pui-text-placeholder);
581
+ }
582
+
583
+ .hidden {
584
+ display: none;
585
+ }
586
+
587
+ .disabled {
588
+ opacity: 0.5;
589
+ cursor: not-allowed;
590
+ background-color: var(--pui-bg-disabled);
591
+ }
592
+
593
+ .selected-items {
594
+ display: flex;
595
+ flex-wrap: wrap;
596
+ gap: var(--pui-spacing-1);
597
+ }
598
+
599
+ .loader {
600
+ position: absolute;
601
+ right: var(--pui-spacing-2_5);
602
+ width: var(--pui-spacing-4);
603
+ top: 6px;
604
+ }
605
+
606
+ .show-error {
607
+ border-color: var(--pui-text-danger);
608
+ }
609
+
610
+ .text-error {
611
+ color: var(--pui-text-danger);
612
+ }
613
+ </style>