@valentinkolb/cloud 0.3.1 → 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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,448 @@
1
+ import { mutation, timed } from "@valentinkolb/stdlib/solid";
2
+ import type { JSX } from "solid-js";
3
+ import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
4
+ import { createInputA11y, InputWrapper } from "./util";
5
+
6
+ export type MultiSelectOption =
7
+ | string
8
+ | {
9
+ id: string;
10
+ label?: string;
11
+ description?: string;
12
+ icon?: string;
13
+ color?: string;
14
+ };
15
+
16
+ type NormalizedOption = {
17
+ id: string;
18
+ label: string;
19
+ description?: string;
20
+ icon?: string;
21
+ color?: string;
22
+ };
23
+
24
+ export type MultiSelectFetchDataFn = (query: string, signal: AbortSignal) => Promise<MultiSelectOption[]>;
25
+
26
+ export type MultiSelectInputProps = {
27
+ label?: string;
28
+ description?: string;
29
+ placeholder?: string;
30
+ icon?: string;
31
+ activeIcon?: string;
32
+ value?: () => string[];
33
+ onChange?: (value: string[]) => void;
34
+ error?: () => string | undefined;
35
+ options?: MultiSelectOption[];
36
+ fetchData?: MultiSelectFetchDataFn;
37
+ selectedOptions?: () => MultiSelectOption[];
38
+ fetchDebounceMs?: number;
39
+ required?: boolean;
40
+ clearable?: boolean;
41
+ disabled?: boolean;
42
+ };
43
+
44
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
45
+
46
+ const expandHex = (color: string): string => {
47
+ if (color.length !== 4) return color;
48
+ return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
49
+ };
50
+
51
+ const hexToRgb = (color: string): { r: number; g: number; b: number } | null => {
52
+ if (!HEX_COLOR.test(color)) return null;
53
+ const hex = expandHex(color).slice(1);
54
+ return {
55
+ r: Number.parseInt(hex.slice(0, 2), 16),
56
+ g: Number.parseInt(hex.slice(2, 4), 16),
57
+ b: Number.parseInt(hex.slice(4, 6), 16),
58
+ };
59
+ };
60
+
61
+ const pillStyle = (color?: string): JSX.CSSProperties | undefined => {
62
+ const rgb = color ? hexToRgb(color.trim()) : null;
63
+ if (!rgb) return undefined;
64
+ const { r, g, b } = rgb;
65
+ return {
66
+ "background-color": `rgba(${r}, ${g}, ${b}, 0.12)`,
67
+ color: `rgb(${r}, ${g}, ${b})`,
68
+ };
69
+ };
70
+
71
+ const iconColorStyle = (color?: string): JSX.CSSProperties | undefined =>
72
+ color && HEX_COLOR.test(color.trim()) ? { color: color.trim() } : undefined;
73
+
74
+ const MultiSelectInput = (props: MultiSelectInputProps) => {
75
+ const placeholder = () => props.placeholder ?? "Select...";
76
+ const icon = () => props.icon ?? "ti ti-chevron-down";
77
+ const activeIcon = () => props.activeIcon ?? "ti ti-chevron-up";
78
+ const disabled = () => props.disabled ?? false;
79
+ const clearable = () => props.clearable ?? false;
80
+ const value = () => props.value?.() ?? [];
81
+ const a11y = createInputA11y({ description: props.description, error: props.error });
82
+ const isSearchable = () => Boolean(props.fetchData);
83
+
84
+ const normalize = (option: MultiSelectOption): NormalizedOption =>
85
+ typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option };
86
+
87
+ const [optionCache, setOptionCache] = createSignal<Record<string, NormalizedOption>>({});
88
+ const fetchMut = mutation.create<NormalizedOption[], string>({
89
+ mutation: async (query, { abortSignal }) => {
90
+ if (!props.fetchData) return [];
91
+ const raw = await props.fetchData(query, abortSignal);
92
+ return raw.map(normalize);
93
+ },
94
+ });
95
+
96
+ const triggerFetch = (query: string) => {
97
+ if (!props.fetchData) return;
98
+ fetchMut.abort();
99
+ void fetchMut.mutate(query);
100
+ };
101
+
102
+ const debounce = timed.debounce((q: string) => triggerFetch(q), props.fetchDebounceMs ?? 200);
103
+
104
+ const options = createMemo(() => {
105
+ if (isSearchable()) return fetchMut.data() ?? [];
106
+ return (props.options ?? []).map(normalize);
107
+ });
108
+ const selectedOptionHints = createMemo(() => (props.selectedOptions?.() ?? []).map(normalize));
109
+ const optionById = createMemo(() => {
110
+ const map = new Map<string, NormalizedOption>();
111
+ for (const option of Object.values(optionCache())) map.set(option.id, option);
112
+ for (const option of selectedOptionHints()) map.set(option.id, option);
113
+ for (const option of options()) map.set(option.id, option);
114
+ return map;
115
+ });
116
+ const selectedOptions = createMemo(() => value().map((id) => optionById().get(id) ?? { id, label: id }));
117
+
118
+ const [isOpen, setIsOpen] = createSignal(false);
119
+ const [query, setQuery] = createSignal("");
120
+ const [focusedIndex, setFocusedIndex] = createSignal(-1);
121
+ const [isDarkTheme, setIsDarkTheme] = createSignal(false);
122
+
123
+ let triggerRef: HTMLDivElement | undefined;
124
+ let dialogRef: HTMLDialogElement | undefined;
125
+ let searchInputRef: HTMLInputElement | undefined;
126
+ let optionRefs: HTMLDivElement[] = [];
127
+
128
+ const filteredOptions = createMemo(() => {
129
+ if (isSearchable()) return options();
130
+ const q = query().trim().toLowerCase();
131
+ if (!q) return options();
132
+ return options().filter((option) => [option.label, option.description, option.id].some((part) => part?.toLowerCase().includes(q)));
133
+ });
134
+ const waitingForRemoteOptions = () => isSearchable() && (fetchMut.loading() || Boolean(fetchMut.error()));
135
+ const visibleOptions = () => (waitingForRemoteOptions() ? [] : filteredOptions());
136
+
137
+ const isSelected = (id: string) => value().includes(id);
138
+ const nextWithout = (id: string) => value().filter((item) => item !== id);
139
+ const emit = (next: string[]) => props.onChange?.([...new Set(next)]);
140
+
141
+ const syncTheme = () => {
142
+ if (typeof document === "undefined") return;
143
+ setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
144
+ };
145
+
146
+ const focusOption = (index: number) => {
147
+ setFocusedIndex(index);
148
+ optionRefs[index]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
149
+ };
150
+
151
+ const navigateOptions = (direction: "next" | "previous") => {
152
+ const count = filteredOptions().length;
153
+ if (!count) return;
154
+ const current = focusedIndex();
155
+ const next = direction === "next" ? (current < count - 1 ? current + 1 : 0) : current > 0 ? current - 1 : count - 1;
156
+ focusOption(next);
157
+ };
158
+
159
+ const positionDialog = () => {
160
+ if (!dialogRef || !triggerRef) return;
161
+ const rect = triggerRef.getBoundingClientRect();
162
+ const maxHeight = 320;
163
+ const spaceBelow = window.innerHeight - rect.bottom;
164
+ const spaceAbove = rect.top;
165
+ dialogRef.style.left = `${rect.left}px`;
166
+ dialogRef.style.width = `${rect.width}px`;
167
+ if (spaceBelow < maxHeight && spaceAbove > spaceBelow) {
168
+ dialogRef.style.top = "auto";
169
+ dialogRef.style.bottom = `${window.innerHeight - rect.top + 8}px`;
170
+ } else {
171
+ dialogRef.style.top = `${rect.bottom + 8}px`;
172
+ dialogRef.style.bottom = "auto";
173
+ }
174
+ };
175
+
176
+ const toggleDropdown = (open: boolean) => {
177
+ if (disabled()) return;
178
+ syncTheme();
179
+ setIsOpen(open);
180
+ if (!open) {
181
+ dialogRef?.close();
182
+ setFocusedIndex(-1);
183
+ setQuery("");
184
+ if (isSearchable()) {
185
+ debounce.cancel();
186
+ fetchMut.abort();
187
+ }
188
+ triggerRef?.focus();
189
+ return;
190
+ }
191
+ setFocusedIndex(filteredOptions().length > 0 ? 0 : -1);
192
+ positionDialog();
193
+ dialogRef?.showModal();
194
+ if (isSearchable()) triggerFetch("");
195
+ queueMicrotask(() => searchInputRef?.focus());
196
+ };
197
+
198
+ const toggleOption = (option: NormalizedOption) => {
199
+ setOptionCache({ ...optionCache(), [option.id]: option });
200
+ const next = isSelected(option.id) ? nextWithout(option.id) : [...value(), option.id];
201
+ emit(next);
202
+ };
203
+
204
+ const removeOption = (event: MouseEvent, id: string) => {
205
+ event.stopPropagation();
206
+ emit(nextWithout(id));
207
+ };
208
+
209
+ const clearValue = (event: MouseEvent) => {
210
+ event.stopPropagation();
211
+ emit([]);
212
+ triggerRef?.focus();
213
+ };
214
+
215
+ const handleKeyDown = (event: KeyboardEvent) => {
216
+ const open = isOpen();
217
+ switch (event.key) {
218
+ case "ArrowDown":
219
+ event.preventDefault();
220
+ open ? navigateOptions("next") : toggleDropdown(true);
221
+ break;
222
+ case "ArrowUp":
223
+ event.preventDefault();
224
+ if (open) navigateOptions("previous");
225
+ break;
226
+ case "Enter":
227
+ if (open && focusedIndex() >= 0) {
228
+ event.preventDefault();
229
+ const option = filteredOptions()[focusedIndex()];
230
+ if (option) toggleOption(option);
231
+ }
232
+ break;
233
+ case "Escape":
234
+ if (open) {
235
+ event.preventDefault();
236
+ toggleDropdown(false);
237
+ }
238
+ break;
239
+ case "Backspace":
240
+ if (open && query().length === 0 && value().length > 0) {
241
+ event.preventDefault();
242
+ emit(value().slice(0, -1));
243
+ }
244
+ break;
245
+ case "Tab":
246
+ if (open) toggleDropdown(false);
247
+ break;
248
+ }
249
+ };
250
+
251
+ const handleDialogClick = (event: MouseEvent) => {
252
+ if (event.target === dialogRef) toggleDropdown(false);
253
+ };
254
+
255
+ onCleanup(() => dialogRef?.close());
256
+
257
+ return (
258
+ <InputWrapper
259
+ label={props.label}
260
+ description={props.description}
261
+ error={props.error?.()}
262
+ required={props.required}
263
+ inputId={a11y.inputId}
264
+ descriptionId={a11y.descriptionId}
265
+ errorId={a11y.errorId}
266
+ >
267
+ <div class="relative">
268
+ <div class="group relative flex-1">
269
+ <div class="pointer-events-none absolute left-2 top-1/2 z-10 flex -translate-y-1/2 items-center text-zinc-500">
270
+ <i class={`${isOpen() ? activeIcon() : icon()} ${isOpen() ? "text-blue-500" : ""}`} />
271
+ </div>
272
+
273
+ <div
274
+ ref={triggerRef}
275
+ id={a11y.inputId}
276
+ class={`input relative flex h-8 w-full items-center gap-1.5 overflow-hidden py-1.5 pl-9 pr-8 ${
277
+ isOpen() ? "!border-blue-500 !bg-white dark:!border-blue-400 dark:!bg-zinc-900" : ""
278
+ } ${disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
279
+ onClick={() => toggleDropdown(!isOpen())}
280
+ onKeyDown={handleKeyDown}
281
+ tabIndex={disabled() ? -1 : 0}
282
+ role="combobox"
283
+ aria-expanded={isOpen()}
284
+ aria-haspopup="listbox"
285
+ aria-label={!props.label ? "Select options" : undefined}
286
+ aria-describedby={a11y.ariaDescribedBy()}
287
+ aria-invalid={!!props.error?.()}
288
+ aria-required={props.required}
289
+ aria-disabled={disabled()}
290
+ >
291
+ <Show
292
+ when={selectedOptions().length > 0}
293
+ fallback={<span class="truncate text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}
294
+ >
295
+ <div class="multi-select-pill-strip absolute inset-y-0 left-9 right-8 flex items-center gap-1 overflow-x-auto overflow-y-hidden whitespace-nowrap">
296
+ <For each={selectedOptions()}>
297
+ {(option) => (
298
+ <span
299
+ class="inline-flex max-w-36 shrink-0 items-center gap-1 rounded-full px-1 text-xs leading-5"
300
+ style={pillStyle(option.color)}
301
+ >
302
+ <Show when={option.icon}>
303
+ <i class={`${option.icon} text-[13px]`} />
304
+ </Show>
305
+ <span class="truncate">{option.label}</span>
306
+ <button
307
+ type="button"
308
+ class="-mr-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full opacity-70 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
309
+ onClick={(event) => removeOption(event, option.id)}
310
+ tabIndex={-1}
311
+ aria-label={`Remove ${option.label}`}
312
+ >
313
+ <i class="ti ti-x text-[11px]" />
314
+ </button>
315
+ </span>
316
+ )}
317
+ </For>
318
+ </div>
319
+ </Show>
320
+ </div>
321
+
322
+ <Show when={clearable() && selectedOptions().length > 0 && !disabled()}>
323
+ <button
324
+ type="button"
325
+ class="absolute inset-y-0 right-2 flex items-center px-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
326
+ onClick={clearValue}
327
+ tabIndex={-1}
328
+ aria-label="Clear selection"
329
+ >
330
+ <i class="ti ti-x text-sm" />
331
+ </button>
332
+ </Show>
333
+ </div>
334
+
335
+ <dialog
336
+ ref={dialogRef}
337
+ class="popup border border-zinc-200 p-1 backdrop:bg-transparent dark:border-zinc-700"
338
+ classList={{ dark: isDarkTheme() }}
339
+ onKeyDown={handleKeyDown}
340
+ onClick={handleDialogClick}
341
+ aria-label="Options"
342
+ >
343
+ <input
344
+ ref={searchInputRef}
345
+ type="text"
346
+ placeholder="Search..."
347
+ value={query()}
348
+ onInput={(event) => {
349
+ const next = event.currentTarget.value;
350
+ setQuery(next);
351
+ setFocusedIndex(0);
352
+ if (isSearchable()) {
353
+ debounce.debouncedFn(next);
354
+ } else {
355
+ setFocusedIndex(filteredOptions().length > 0 ? 0 : -1);
356
+ }
357
+ }}
358
+ class="w-full border-0 bg-transparent px-3 py-1.5 text-sm text-zinc-700 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-300 dark:placeholder:text-zinc-500"
359
+ aria-label="Search options"
360
+ />
361
+ <div
362
+ class="flex max-h-72 flex-col gap-1 overflow-y-auto"
363
+ role="listbox"
364
+ aria-label={props.label || "Options"}
365
+ aria-multiselectable="true"
366
+ >
367
+ <Show when={isSearchable() && fetchMut.loading()}>
368
+ <div class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
369
+ <i class="ti ti-loader-2 animate-spin" /> Loading...
370
+ </div>
371
+ </Show>
372
+ <Show when={isSearchable() && !fetchMut.loading() && fetchMut.error()}>
373
+ <div class="flex items-center gap-2 px-3 py-1.5 text-xs text-red-500">
374
+ <i class="ti ti-alert-triangle shrink-0" />
375
+ <span class="flex-1 truncate">{fetchMut.error()?.message ?? "Failed to load"}</span>
376
+ <button
377
+ type="button"
378
+ class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
379
+ onClick={() => fetchMut.retry()}
380
+ >
381
+ Retry
382
+ </button>
383
+ </div>
384
+ </Show>
385
+ <For
386
+ each={visibleOptions()}
387
+ fallback={
388
+ <Show when={!waitingForRemoteOptions()}>
389
+ <div class="px-3 py-1.5 text-xs text-zinc-400 dark:text-zinc-500">
390
+ {isSearchable() ? "No results" : "No options available"}
391
+ </div>
392
+ </Show>
393
+ }
394
+ >
395
+ {(option, index) => {
396
+ const selected = () => isSelected(option.id);
397
+ const focused = () => index() === focusedIndex();
398
+
399
+ return (
400
+ <div
401
+ ref={(el) => (optionRefs[index()] = el)}
402
+ class={`group flex cursor-pointer select-none items-center gap-3 rounded px-2 py-2 text-sm transition-colors ${
403
+ focused() ? "bg-blue-50 dark:bg-blue-950/35" : "hover:bg-zinc-100 dark:hover:bg-zinc-800"
404
+ }`}
405
+ onClick={() => toggleOption(option)}
406
+ onKeyDown={(event) => {
407
+ if (event.key === "Enter" || event.key === " ") {
408
+ event.preventDefault();
409
+ toggleOption(option);
410
+ }
411
+ }}
412
+ onMouseEnter={() => setFocusedIndex(index())}
413
+ role="option"
414
+ aria-label={option.label}
415
+ aria-selected={selected()}
416
+ tabIndex={-1}
417
+ >
418
+ <span
419
+ class={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
420
+ selected()
421
+ ? "border-blue-500 bg-blue-500 text-white"
422
+ : "border-zinc-300 bg-white text-transparent dark:border-zinc-600 dark:bg-zinc-900"
423
+ }`}
424
+ >
425
+ <i class="ti ti-check text-[12px]" />
426
+ </span>
427
+ <Show when={option.icon}>
428
+ <i class={`${option.icon} shrink-0 text-zinc-500 dark:text-zinc-400`} style={iconColorStyle(option.color)} />
429
+ </Show>
430
+ <div class="min-w-0 flex-1">
431
+ <div class="truncate text-zinc-800 dark:text-zinc-200">{option.label}</div>
432
+ <Show when={option.description}>
433
+ <div class="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">{option.description}</div>
434
+ </Show>
435
+ </div>
436
+ </div>
437
+ );
438
+ }}
439
+ </For>
440
+ </div>
441
+ </dialog>
442
+ </div>
443
+ </InputWrapper>
444
+ );
445
+ };
446
+
447
+ export { MultiSelectInput };
448
+ export default MultiSelectInput;