@valentinkolb/cloud 0.4.0 → 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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,91 @@
1
+ import { type JSX, Show } from "solid-js";
2
+ import type { CheckboxInputProps } from "./types";
3
+
4
+ export type CheckboxCardProps = CheckboxInputProps & {
5
+ label: string | JSX.Element;
6
+ icon?: string;
7
+ color?: string;
8
+ variant?: "card" | "input";
9
+ };
10
+
11
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
12
+
13
+ const colorStyle = (color?: string): JSX.CSSProperties | undefined => {
14
+ if (!color || !HEX_COLOR.test(color.trim())) return undefined;
15
+ return { "background-color": color.trim() };
16
+ };
17
+
18
+ const CheckboxCard = ({
19
+ label,
20
+ description,
21
+ value,
22
+ onChange,
23
+ error,
24
+ required = false,
25
+ disabled = false,
26
+ icon,
27
+ color,
28
+ variant = "card",
29
+ }: CheckboxCardProps) => {
30
+ const inputId = crypto.randomUUID();
31
+ const checked = () => value?.() === true;
32
+
33
+ return (
34
+ <label
35
+ for={inputId}
36
+ class={`grid cursor-pointer select-none grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 rounded-lg border p-3 text-left transition-colors ${
37
+ variant === "input"
38
+ ? "border-zinc-100 bg-zinc-100 hover:border-zinc-200/70 hover:bg-zinc-200/70 dark:border-zinc-800 dark:bg-zinc-800 dark:hover:border-zinc-700 dark:hover:bg-zinc-700"
39
+ : "border-zinc-200 bg-white hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
40
+ }`}
41
+ classList={{
42
+ "border-blue-500 bg-blue-50/70 hover:bg-blue-50 dark:border-blue-500 dark:bg-blue-950/20 dark:hover:bg-blue-950/25":
43
+ checked() && !error?.(),
44
+ "border-red-500 bg-red-50/70 dark:border-red-500 dark:bg-red-950/20": !!error?.(),
45
+ "cursor-not-allowed opacity-60": disabled,
46
+ }}
47
+ >
48
+ <input
49
+ id={inputId}
50
+ type="checkbox"
51
+ checked={checked()}
52
+ onChange={(event) => onChange?.(event.currentTarget.checked)}
53
+ disabled={disabled}
54
+ aria-required={required}
55
+ aria-invalid={!!error?.()}
56
+ aria-describedby={error?.() ? `${inputId}-error` : description ? `${inputId}-description` : undefined}
57
+ class="h-4 w-4 self-center"
58
+ />
59
+ <span class="flex min-w-0 items-center gap-2 self-center text-sm font-medium leading-5 text-primary">
60
+ {icon ? (
61
+ <i class={`${icon} shrink-0 text-dimmed`} />
62
+ ) : color ? (
63
+ <span class="h-2.5 w-2.5 shrink-0 rounded-full" style={colorStyle(color)} />
64
+ ) : null}
65
+ <span class="min-w-0 truncate">{label}</span>
66
+ {required && (
67
+ <span class="text-red-500" aria-hidden="true">
68
+ *
69
+ </span>
70
+ )}
71
+ </span>
72
+ <Show when={description || error?.()}>
73
+ <span class="col-start-2 min-w-0">
74
+ {description && (
75
+ <span id={`${inputId}-description`} class="block text-xs leading-snug text-dimmed">
76
+ {description}
77
+ </span>
78
+ )}
79
+ {error?.() && (
80
+ <span id={`${inputId}-error`} class="mt-1 block text-xs text-red-500" role="alert" aria-live="polite">
81
+ {error()}
82
+ </span>
83
+ )}
84
+ </span>
85
+ </Show>
86
+ </label>
87
+ );
88
+ };
89
+
90
+ export { CheckboxCard };
91
+ export default CheckboxCard;
@@ -0,0 +1,375 @@
1
+ import { withMinLoadTime } from "@valentinkolb/stdlib";
2
+ import { mutation, timed } from "@valentinkolb/stdlib/solid";
3
+ import { createSignal, createUniqueId, For, onCleanup, Show } from "solid-js";
4
+
5
+ /**
6
+ * Single rendered row inside the Combobox dropdown. Caller's `fetchData`
7
+ * returns these directly — the component owns the rendering, no JSX
8
+ * callbacks. Mirrors the option shape used by `SelectInput` so the visual
9
+ * vocabulary stays consistent across the platform.
10
+ */
11
+ export type ComboboxOption = {
12
+ id: string;
13
+ label: string;
14
+ /** Optional dim line under the label. */
15
+ description?: string;
16
+ /** Tabler icon class name without the `ti ` prefix, e.g. `"ti-user"`. */
17
+ icon?: string;
18
+ };
19
+
20
+ export type ComboboxProps = {
21
+ placeholder?: string;
22
+ /**
23
+ * Async loader. Receives the current query (empty on initial open) and
24
+ * an AbortSignal that's tripped when the caller types again before the
25
+ * previous request resolves, or when the dropdown closes mid-flight.
26
+ * Throw to surface an error in the dropdown body — users get a Retry
27
+ * button.
28
+ */
29
+ fetchData: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
30
+ /**
31
+ * Fires when the user picks an option. Combobox is fire-and-forget —
32
+ * the input clears itself + closes the popover immediately after.
33
+ */
34
+ onSelect: (option: ComboboxOption) => void;
35
+ disabled?: boolean;
36
+ };
37
+
38
+ /**
39
+ * Searchable combobox. The trigger IS the search field — type directly
40
+ * to filter, click an option to fire `onSelect` (input clears, popover
41
+ * closes). Designed for "consume-and-clear" flows like "add a member":
42
+ * no value tracking, no selected-state. For stateful picks, use
43
+ * `SelectInput`.
44
+ *
45
+ * ### Why the Popover API + CSS anchor positioning
46
+ *
47
+ * The result list is rendered inside a `<div popover="manual">` opened
48
+ * via `showPopover()`, anchored to the input via `anchor-name` /
49
+ * `position-anchor`. That gets us three things at once:
50
+ *
51
+ * 1. **Top-layer rendering** — the popover escapes any ancestor
52
+ * `overflow:hidden` (modals, scroll containers, cards). Without
53
+ * that, a Combobox inside a `prompts.dialog()` would have its
54
+ * results clipped at the modal edge.
55
+ * 2. **No focus theft** — unlike `<dialog>.showModal()`, the popover
56
+ * API doesn't move focus. The input stays focused so the user
57
+ * can keep typing — true combobox feel, not a fake-trigger +
58
+ * real-input-in-modal pattern.
59
+ * 3. **Auto-flip placement** — `position-try-fallbacks` flips the
60
+ * popover above the input when there's no room below.
61
+ *
62
+ * `popover="manual"` (not `"auto"`) because clicking the input itself
63
+ * would trigger auto-close — the input is "outside" the popover from
64
+ * the API's perspective. With manual, we wire close handlers ourselves
65
+ * (Escape, blur with delay, click-outside).
66
+ *
67
+ * ### Behaviours worth knowing
68
+ *
69
+ * - **Open on focus / click**, with an immediate `fetchData("")` call
70
+ * so the caller's prepended "suggested" rows render before any
71
+ * typing.
72
+ * - **Debounced (200ms)** subsequent `fetchData` calls keyed on input
73
+ * value, with the previous request aborted before the next fires.
74
+ * - **Pick-then-clear**: `onSelect` fires, input clears, popover
75
+ * closes, focus stays on the input — ready for the next pick.
76
+ * - **Click-outside / Escape / Tab** all close the popover without
77
+ * picking. Blur is debounced ~150ms so a click on an option lands
78
+ * before the close kicks in.
79
+ * - **Keyboard nav**: Arrow up/down cycle focused option; Enter picks.
80
+ */
81
+ const Combobox = (props: ComboboxProps) => {
82
+ // Per-instance anchor name so multiple Comboboxes on a page don't
83
+ // share an anchor target. createUniqueId is SSR-safe.
84
+ const anchorName = `--cmbx-${createUniqueId()}`;
85
+
86
+ const [query, setQuery] = createSignal("");
87
+ const [isOpen, setIsOpen] = createSignal(false);
88
+ const [focusedIndex, setFocusedIndex] = createSignal(-1);
89
+
90
+ // Wrap fetchData in a stdlib mutation so we get loading + error +
91
+ // abort + retry for free. We `abort()` before each new fetch so a
92
+ // late response doesn't repaint the dropdown with stale results.
93
+ //
94
+ // `withMinLoadTime` guarantees the loader stays visible for at least
95
+ // 200ms — without it, sub-100ms responses would flash the spinner so
96
+ // briefly that it reads as a flicker. 200ms is the sweet spot: long
97
+ // enough to register as a "processing" cue, short enough that fast
98
+ // requests still feel snappy.
99
+ const fetchMut = mutation.create<ComboboxOption[], string>({
100
+ mutation: async (q, { abortSignal }) =>
101
+ withMinLoadTime(() => props.fetchData(q, abortSignal), 200),
102
+ });
103
+
104
+ const triggerFetch = (q: string) => {
105
+ fetchMut.abort();
106
+ void fetchMut.mutate(q);
107
+ };
108
+
109
+ // 200ms matches SelectInput's default — fast enough to feel live,
110
+ // slow enough not to hammer the server while typing.
111
+ const debounce = timed.debounce((q: string) => triggerFetch(q), 200);
112
+
113
+ const options = () => fetchMut.data() ?? [];
114
+
115
+ let inputRef: HTMLInputElement | undefined;
116
+ let popoverRef: HTMLDivElement | undefined;
117
+ let optionRefs: HTMLDivElement[] = [];
118
+ let blurTimeout: ReturnType<typeof setTimeout> | undefined;
119
+
120
+ const open = () => {
121
+ if (props.disabled || isOpen()) return;
122
+ setIsOpen(true);
123
+ setFocusedIndex(-1);
124
+ // Match popover width to the input on every open — accounts for
125
+ // responsive resizes between opens.
126
+ if (popoverRef && inputRef) {
127
+ popoverRef.style.width = `${inputRef.offsetWidth}px`;
128
+ popoverRef.showPopover();
129
+ }
130
+ // Eager empty-query fetch so the caller's suggested rows render
131
+ // before the user types anything.
132
+ triggerFetch("");
133
+ };
134
+
135
+ const close = () => {
136
+ if (!isOpen()) return;
137
+ setIsOpen(false);
138
+ setFocusedIndex(-1);
139
+ debounce.cancel();
140
+ fetchMut.abort();
141
+ setQuery("");
142
+ popoverRef?.hidePopover();
143
+ };
144
+
145
+ const select = (option: ComboboxOption) => {
146
+ props.onSelect(option);
147
+ close();
148
+ // Keep focus on the input so the user can immediately add another.
149
+ inputRef?.focus();
150
+ };
151
+
152
+ const handleInput = (value: string) => {
153
+ setQuery(value);
154
+ setFocusedIndex(-1);
155
+ debounce.debouncedFn(value);
156
+ };
157
+
158
+ const navigate = (direction: "next" | "prev") => {
159
+ const count = options().length;
160
+ if (count === 0) return;
161
+ let next = focusedIndex();
162
+ if (direction === "next") {
163
+ next = next < count - 1 ? next + 1 : 0;
164
+ } else {
165
+ next = next > 0 ? next - 1 : count - 1;
166
+ }
167
+ setFocusedIndex(next);
168
+ optionRefs[next]?.scrollIntoView({ block: "nearest" });
169
+ };
170
+
171
+ const handleKeyDown = (event: KeyboardEvent) => {
172
+ switch (event.key) {
173
+ case "ArrowDown":
174
+ event.preventDefault();
175
+ if (!isOpen()) {
176
+ open();
177
+ } else {
178
+ navigate("next");
179
+ }
180
+ break;
181
+ case "ArrowUp":
182
+ event.preventDefault();
183
+ if (isOpen()) navigate("prev");
184
+ break;
185
+ case "Enter": {
186
+ if (!isOpen()) return;
187
+ const opt = options()[focusedIndex()];
188
+ if (opt) {
189
+ event.preventDefault();
190
+ select(opt);
191
+ }
192
+ break;
193
+ }
194
+ case "Escape":
195
+ if (isOpen()) {
196
+ event.preventDefault();
197
+ close();
198
+ }
199
+ break;
200
+ case "Tab":
201
+ if (isOpen()) close();
202
+ break;
203
+ }
204
+ };
205
+
206
+ // Blur with delay — gives a clicked option time to fire its onClick
207
+ // (and therefore `select()`) before we tear down the popover.
208
+ const handleBlur = () => {
209
+ blurTimeout = setTimeout(() => close(), 150);
210
+ };
211
+
212
+ const cancelBlur = () => {
213
+ if (blurTimeout) {
214
+ clearTimeout(blurTimeout);
215
+ blurTimeout = undefined;
216
+ }
217
+ };
218
+
219
+ onCleanup(() => {
220
+ if (blurTimeout) clearTimeout(blurTimeout);
221
+ debounce.cancel();
222
+ fetchMut.abort();
223
+ });
224
+
225
+ return (
226
+ <div class="relative">
227
+ <div class="group relative">
228
+ <div
229
+ class={`pointer-events-none absolute inset-y-0 left-2 z-10 flex items-center ${
230
+ isOpen() ? "text-blue-500" : "text-zinc-500"
231
+ }`}
232
+ >
233
+ <i class="ti ti-search" />
234
+ </div>
235
+
236
+ <input
237
+ ref={inputRef}
238
+ type="text"
239
+ class={`input w-full pl-9 pr-8 ${
240
+ isOpen() ? "!border-blue-500 dark:!border-blue-400" : ""
241
+ } ${props.disabled ? "cursor-not-allowed opacity-50" : ""}`}
242
+ placeholder={props.placeholder ?? "Search..."}
243
+ value={query()}
244
+ // anchor-name lets the popover position itself relative to
245
+ // this element via CSS, no JS rect math.
246
+ style={`anchor-name: ${anchorName}`}
247
+ onFocus={open}
248
+ onClick={open}
249
+ onBlur={handleBlur}
250
+ onInput={(e) => handleInput(e.currentTarget.value)}
251
+ onKeyDown={handleKeyDown}
252
+ disabled={props.disabled}
253
+ role="combobox"
254
+ aria-expanded={isOpen()}
255
+ aria-autocomplete="list"
256
+ aria-controls="combobox-listbox"
257
+ />
258
+
259
+ <div class="pointer-events-none absolute inset-y-0 right-2 z-10 flex items-center text-zinc-500">
260
+ {/* Loader replaces the chevron during fetch — keeps the
261
+ dropdown body untouched (stale results stay visible) so
262
+ the user doesn't see a flicker between old and new. */}
263
+ <Show
264
+ when={fetchMut.loading()}
265
+ fallback={
266
+ <i
267
+ class={`ti ti-chevron-down transition-transform ${isOpen() ? "rotate-180" : ""}`}
268
+ />
269
+ }
270
+ >
271
+ <i class="ti ti-loader-2 animate-spin" />
272
+ </Show>
273
+ </div>
274
+ </div>
275
+
276
+ {/* Popover lives in the top layer — escapes any ancestor
277
+ overflow:hidden (modals, cards). Anchored to the input via
278
+ CSS, with auto-flip when there's no space below. The element
279
+ is always mounted (toggled via showPopover/hidePopover) so
280
+ the ref stays valid across opens. */}
281
+ <div
282
+ ref={popoverRef}
283
+ popover="manual"
284
+ // Cancel the blur-close when the user mouses into the popover —
285
+ // clicking an option needs the input to still be considered
286
+ // "active" until the option's onClick fires.
287
+ onMouseDown={cancelBlur}
288
+ class="paper max-h-60 overflow-y-auto p-1 border! border-zinc-300/60! dark:border-zinc-600/50!"
289
+ style={`position-anchor: ${anchorName}; position: fixed; inset: unset; margin: 0; top: anchor(bottom); left: anchor(left); margin-top: 4px; position-try-fallbacks: flip-block;`}
290
+ role="listbox"
291
+ id="combobox-listbox"
292
+ >
293
+ {/* Stale-while-revalidate: while a new fetch is in flight, the
294
+ previous options stay rendered (the loader sits in the input
295
+ chevron slot, see above). Errors replace the option list with
296
+ an inline retry. */}
297
+ <Show
298
+ when={fetchMut.error() && !fetchMut.loading()}
299
+ fallback={null}
300
+ >
301
+ <div class="flex items-center gap-2 px-3 py-1.5 text-xs text-red-500">
302
+ <i class="ti ti-alert-triangle shrink-0" />
303
+ <span class="flex-1 truncate">
304
+ {fetchMut.error()?.message ?? "Failed to load"}
305
+ </span>
306
+ <button
307
+ type="button"
308
+ class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
309
+ onMouseDown={(e) => {
310
+ e.preventDefault();
311
+ fetchMut.retry();
312
+ }}
313
+ >
314
+ Retry
315
+ </button>
316
+ </div>
317
+ </Show>
318
+
319
+ <Show when={!(fetchMut.error() && !fetchMut.loading())}>
320
+ <For
321
+ each={options()}
322
+ fallback={
323
+ <div class="px-3 py-2 text-xs text-zinc-400 dark:text-zinc-500">
324
+ {query().length >= 2
325
+ ? "No results found"
326
+ : "Type to search..."}
327
+ </div>
328
+ }
329
+ >
330
+ {(option, index) => {
331
+ const isFocused = () => index() === focusedIndex();
332
+ return (
333
+ <div
334
+ ref={(el) => (optionRefs[index()] = el)}
335
+ class="group flex cursor-pointer select-none items-center gap-3 rounded px-2 py-1.5 text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
336
+ classList={{
337
+ "bg-zinc-100 dark:bg-zinc-800": isFocused(),
338
+ }}
339
+ role="option"
340
+ aria-selected={isFocused()}
341
+ onMouseEnter={() => setFocusedIndex(index())}
342
+ // onMouseDown beats onBlur — the input's blur
343
+ // handler would otherwise close the popover before
344
+ // the click resolves.
345
+ onMouseDown={(e) => {
346
+ e.preventDefault();
347
+ select(option);
348
+ }}
349
+ >
350
+ <Show when={option.icon}>
351
+ <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
352
+ <i class={`ti ${option.icon} text-sm`} />
353
+ </div>
354
+ </Show>
355
+ <div class="min-w-0 flex-1">
356
+ <div class="truncate text-zinc-700 dark:text-zinc-300">
357
+ {option.label}
358
+ </div>
359
+ <Show when={option.description}>
360
+ <div class="truncate text-xs text-zinc-500 dark:text-zinc-400">
361
+ {option.description}
362
+ </div>
363
+ </Show>
364
+ </div>
365
+ </div>
366
+ );
367
+ }}
368
+ </For>
369
+ </Show>
370
+ </div>
371
+ </div>
372
+ );
373
+ };
374
+
375
+ export default Combobox;