@valentinkolb/cloud 0.4.0 → 0.5.1

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 -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 +116 -13
  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 +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  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/notifications/index.ts +82 -11
  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 +79 -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 +58 -0
  92. package/src/shared/redirect.ts +56 -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
@@ -23,13 +23,46 @@ export type DialogCore = {
23
23
  isOpen: () => boolean;
24
24
  };
25
25
 
26
- type DialogState = {
27
- element?: HTMLDialogElement;
26
+ /**
27
+ * One level on the dialog stack. Multiple levels can coexist (a deeper
28
+ * dialog opens on top of a shallower one — e.g. a `prompts.confirm`
29
+ * called from inside a `prompts.dialog`'s view), but only the topmost
30
+ * level is visible. Lower levels stay mounted (`display:none`) so their
31
+ * SolidJS state survives across the round-trip.
32
+ */
33
+ type DialogStackEntry = {
34
+ /** Container `<div>` inside the shared `<dialog>`; one per level. */
35
+ container: HTMLDivElement;
36
+ /** SolidJS `render` disposer. Called only when this level is popped
37
+ * off the stack — NOT when it's merely hidden by a deeper level. */
28
38
  dispose?: () => void;
39
+ /** Promise resolver for this level's `open()` call. */
29
40
  resolve?: (value: unknown) => void;
41
+ panelClassName: string;
42
+ cancelBehavior: NonNullable<OpenDialogOptions["cancelBehavior"]>;
43
+ initialFocus: NonNullable<OpenDialogOptions["initialFocus"]>;
44
+ };
45
+
46
+ type DialogState = {
47
+ /** Shared `<dialog>` element. One on the page, regardless of stack
48
+ * depth — only the topmost level's container is visible. */
49
+ element?: HTMLDialogElement;
50
+ /** Active stack of dialog levels. Top of stack is the visible one.
51
+ * Empty means no dialog is shown; `<dialog>.close()` has been called
52
+ * and scroll is unlocked. */
53
+ stack: DialogStackEntry[];
30
54
  scrollLocked?: boolean;
31
55
  previousBodyOverflow?: string;
32
56
  previousHtmlOverflow?: string;
57
+ /** Tracks whether the most recent `mousedown` had `event.target ===
58
+ * dialog` (i.e. on the backdrop itself, not on dialog content).
59
+ * Used by the click handler to distinguish a real backdrop click
60
+ * from a phantom one — e.g. when an option in an open `popover`
61
+ * is mousedown'd, the popover hides synchronously, and the
62
+ * subsequent click event gets retargeted to the dialog because the
63
+ * option is now `display:none`. We only close on a click whose
64
+ * mousedown was ALSO on the backdrop. */
65
+ mouseDownOnDialog?: boolean;
33
66
  };
34
67
 
35
68
  const DEFAULT_PANEL_CLASS = "dialog-panel";
@@ -41,25 +74,49 @@ const resolveInitialFocusTarget = (dialog: HTMLDialogElement, initialFocus: Open
41
74
  return dialog.querySelector<HTMLElement>("input:not([type='hidden']), textarea, select, button");
42
75
  };
43
76
 
44
- const applyCancelBehavior = <T>(dialog: HTMLDialogElement, close: DialogClose<T>, behavior: OpenDialogOptions["cancelBehavior"]) => {
45
- dialog.oncancel = (event) => {
46
- if (behavior === "ignore") {
77
+ export const createDialogCore = (): DialogCore => {
78
+ const state: DialogState = { stack: [] };
79
+
80
+ /**
81
+ * Wires up Esc-cancel + backdrop-click behaviour to the dialog. Re-
82
+ * called whenever the topmost level changes (push/pop) so the
83
+ * handlers always reflect the current top's `cancelBehavior` and
84
+ * `close` callback.
85
+ *
86
+ * Backdrop-click detection guards against phantom clicks: it only
87
+ * fires when BOTH `mousedown` AND `click` target the dialog element
88
+ * itself. A click whose `mousedown` was on a child (and got
89
+ * retargeted to the dialog because the child went `display:none`
90
+ * mid-gesture — e.g. a popover hiding from inside its own option's
91
+ * onMouseDown) is rejected as not a real backdrop click.
92
+ */
93
+ const applyCancelBehavior = (
94
+ dialog: HTMLDialogElement,
95
+ close: () => void,
96
+ behavior: OpenDialogOptions["cancelBehavior"],
97
+ ) => {
98
+ dialog.oncancel = (event) => {
99
+ if (behavior === "ignore") {
100
+ event.preventDefault();
101
+ return;
102
+ }
47
103
  event.preventDefault();
48
- return;
49
- }
50
- event.preventDefault();
51
- close(undefined);
52
- };
104
+ close();
105
+ };
53
106
 
54
- dialog.onclick = (event) => {
55
- if (event.target !== dialog) return;
56
- if (behavior === "ignore") return;
57
- close(undefined);
58
- };
59
- };
107
+ dialog.onmousedown = (event) => {
108
+ state.mouseDownOnDialog = event.target === dialog;
109
+ };
60
110
 
61
- export const createDialogCore = (): DialogCore => {
62
- const state: DialogState = {};
111
+ dialog.onclick = (event) => {
112
+ const wasRealBackdropClick = state.mouseDownOnDialog === true;
113
+ state.mouseDownOnDialog = false;
114
+ if (event.target !== dialog) return;
115
+ if (!wasRealBackdropClick) return;
116
+ if (behavior === "ignore") return;
117
+ close();
118
+ };
119
+ };
63
120
 
64
121
  const ensureDialogElement = () => {
65
122
  if (typeof document === "undefined") throw new Error("Dialog core is browser-only");
@@ -71,11 +128,6 @@ export const createDialogCore = (): DialogCore => {
71
128
  return element;
72
129
  };
73
130
 
74
- const clearRenderedContent = () => {
75
- state.dispose?.();
76
- state.dispose = undefined;
77
- };
78
-
79
131
  const lockPageScroll = () => {
80
132
  if (typeof document === "undefined" || state.scrollLocked) return;
81
133
 
@@ -96,50 +148,126 @@ export const createDialogCore = (): DialogCore => {
96
148
  state.previousHtmlOverflow = undefined;
97
149
  };
98
150
 
99
- const close: DialogCore["close"] = (result) => {
151
+ /**
152
+ * Pop the topmost level off the stack. Disposes its SolidJS render,
153
+ * removes its container, resolves its promise. If this empties the
154
+ * stack the underlying `<dialog>` is closed and scroll unlocked;
155
+ * otherwise the previous level is unhidden and its dialog chrome
156
+ * (className, cancel handlers, initial focus) is restored.
157
+ */
158
+ const popTop = (result?: unknown) => {
159
+ const top = state.stack.pop();
160
+ if (!top) return;
161
+
162
+ top.dispose?.();
163
+ top.container.remove();
164
+
100
165
  const dialog = state.element;
101
- if (!dialog) return;
166
+ const previous = state.stack[state.stack.length - 1];
102
167
 
103
- clearRenderedContent();
104
- if (dialog.open) dialog.close();
105
- unlockPageScroll();
168
+ if (previous && dialog) {
169
+ // Restore the level that was hidden when this one opened — it
170
+ // was never disposed, so its SolidJS state is still live.
171
+ previous.container.style.display = "";
172
+ dialog.className = previous.panelClassName;
173
+ applyCancelBehavior(dialog, () => popTop(undefined), previous.cancelBehavior);
106
174
 
107
- const resolve = state.resolve;
108
- state.resolve = undefined;
109
- resolve?.(result);
175
+ // Re-run the previous level's initial focus resolver. The
176
+ // browser's modal focus trap would otherwise pick the first
177
+ // focusable in DOM order, which can be wrong if the previous
178
+ // view declared a custom `initialFocus` function.
179
+ requestAnimationFrame(() => {
180
+ resolveInitialFocusTarget(dialog, previous.initialFocus)?.focus();
181
+ });
182
+ } else if (dialog) {
183
+ // Stack is empty — close the underlying dialog for real.
184
+ if (dialog.open) dialog.close();
185
+ unlockPageScroll();
186
+ }
187
+
188
+ // Resolve LAST so the awaiting caller observes the unmounted state.
189
+ top.resolve?.(result);
110
190
  };
111
191
 
112
192
  const open = <T>(view: DialogRender<T>, options: OpenDialogOptions = {}): Promise<T | undefined> => {
113
193
  const dialog = ensureDialogElement();
114
- if (dialog.open) close(undefined);
115
194
 
116
- dialog.className = options.panelClassName ?? DEFAULT_PANEL_CLASS;
117
- dialog.innerHTML = "";
195
+ // Hide the currently-visible level (if any). We don't dispose —
196
+ // its SolidJS render keeps running so its signals, scroll, focus
197
+ // intent, etc. all survive the round-trip.
198
+ const previousTop = state.stack[state.stack.length - 1];
199
+ if (previousTop) {
200
+ previousTop.container.style.display = "none";
201
+ }
202
+
203
+ const panelClassName = options.panelClassName ?? DEFAULT_PANEL_CLASS;
204
+ const cancelBehavior = options.cancelBehavior ?? "resolve-undefined";
205
+ const initialFocus = options.initialFocus ?? "first-input";
206
+
207
+ dialog.className = panelClassName;
118
208
 
119
- const content = document.createElement("div");
120
- content.className = options.contentClassName ?? DEFAULT_CONTENT_CLASS;
121
- dialog.appendChild(content);
209
+ const container = document.createElement("div");
210
+ container.className = options.contentClassName ?? DEFAULT_CONTENT_CLASS;
211
+ dialog.appendChild(container);
212
+
213
+ const entry: DialogStackEntry = {
214
+ container,
215
+ panelClassName,
216
+ cancelBehavior,
217
+ initialFocus,
218
+ };
122
219
 
123
220
  return new Promise((resolve) => {
124
- state.resolve = (value) => resolve(value as T | undefined);
221
+ entry.resolve = (value) => resolve(value as T | undefined);
222
+
223
+ // Per-entry close callback. Idempotent and safe to call from a
224
+ // stale closure — if this entry has already been popped (or a
225
+ // deeper level is now on top), the call no-ops instead of
226
+ // accidentally popping someone else's level.
227
+ const closeTyped: DialogClose<T> = (result) => {
228
+ if (state.stack[state.stack.length - 1] !== entry) return;
229
+ popTop(result);
230
+ };
231
+
232
+ entry.dispose = render(() => view(closeTyped, { dialog }), container);
233
+
234
+ applyCancelBehavior(dialog, () => closeTyped(undefined), cancelBehavior);
125
235
 
126
- const closeTyped: DialogClose<T> = (result) => close(result);
127
- state.dispose = render(() => view(closeTyped, { dialog }), content);
236
+ // Push only after dispose is set so a synchronous close from the
237
+ // view's render path still finds itself on top.
238
+ state.stack.push(entry);
128
239
 
129
- applyCancelBehavior(dialog, closeTyped, options.cancelBehavior ?? "resolve-undefined");
130
- dialog.showModal();
131
- lockPageScroll();
240
+ const wasFirstLevel = state.stack.length === 1;
241
+ if (wasFirstLevel) {
242
+ dialog.showModal();
243
+ lockPageScroll();
244
+ }
132
245
 
133
246
  requestAnimationFrame(() => {
134
- resolveInitialFocusTarget(dialog, options.initialFocus ?? "first-input")?.focus();
247
+ resolveInitialFocusTarget(dialog, initialFocus)?.focus();
135
248
  });
136
249
  });
137
250
  };
138
251
 
252
+ /**
253
+ * Close ALL levels on the stack. The first pop receives `result`,
254
+ * the rest get `undefined`. Used by external callers that want to
255
+ * dismiss the dialog system entirely (e.g. in cleanup / route
256
+ * change). Internal per-level closing goes through the `close`
257
+ * callback passed to each view, which only pops its own level.
258
+ */
259
+ const close: DialogCore["close"] = (result) => {
260
+ let first = true;
261
+ while (state.stack.length > 0) {
262
+ popTop(first ? result : undefined);
263
+ first = false;
264
+ }
265
+ };
266
+
139
267
  return {
140
268
  open,
141
269
  close,
142
- isOpen: () => !!state.element?.open,
270
+ isOpen: () => state.stack.length > 0,
143
271
  };
144
272
  };
145
273
 
@@ -1,4 +1,4 @@
1
- import { createSignal, createEffect, Show, createMemo } from "solid-js";
1
+ import { createSignal, createEffect, Show } from "solid-js";
2
2
  import Dropdown from "../misc/Dropdown";
3
3
  import type { DropdownItem } from "../misc/Dropdown";
4
4
 
@@ -41,6 +41,8 @@ type FilterChipProps = {
41
41
  * Also hides the count in the trigger.
42
42
  */
43
43
  defaultValue?: string[];
44
+ /** Render only the trigger icon while keeping the label as accessible title text. */
45
+ iconOnly?: boolean;
44
46
  };
45
47
 
46
48
  // =============================================================================
@@ -50,7 +52,7 @@ type FilterChipProps = {
50
52
  /**
51
53
  * Filter chip using the shared Dropdown component.
52
54
  * Each section can be single-select or multi-select independently.
53
- * Changes are applied when the dropdown closes.
55
+ * Changes are committed immediately so URL-backed filters update without losing focus.
54
56
  */
55
57
  export default function FilterChip(props: FilterChipProps) {
56
58
  // Local selection state (tracks pending changes)
@@ -60,12 +62,6 @@ export default function FilterChip(props: FilterChipProps) {
60
62
  createEffect(() => setLocalValue([...props.value]));
61
63
 
62
64
  // Computed values
63
- const hasChanges = createMemo(() => {
64
- const local = localValue();
65
- const original = props.value;
66
- return local.length !== original.length || local.some((v) => !original.includes(v));
67
- });
68
-
69
65
  const isActive = () => props.isActive ?? localValue().length > 0;
70
66
  const isSelected = (value: string) => localValue().includes(value);
71
67
  const selectedCount = () => localValue().length;
@@ -81,6 +77,11 @@ export default function FilterChip(props: FilterChipProps) {
81
77
  // Find which section a value belongs to
82
78
  const getSectionForValue = (value: string) => props.options.findIndex((s) => s.options.some((o) => o.value === value));
83
79
 
80
+ const commitValue = (nextValue: string[]) => {
81
+ setLocalValue(nextValue);
82
+ props.onChange(nextValue);
83
+ };
84
+
84
85
  // Toggle option selection
85
86
  const toggleOption = (value: string) => {
86
87
  const sectionIndex = getSectionForValue(value);
@@ -89,25 +90,20 @@ export default function FilterChip(props: FilterChipProps) {
89
90
 
90
91
  const isMultiple = section.multiple ?? false;
91
92
 
92
- setLocalValue((prev) => {
93
- const isCurrentlySelected = prev.includes(value);
94
-
95
- if (isMultiple) {
96
- return isCurrentlySelected ? prev.filter((v) => v !== value) : [...prev, value];
97
- }
93
+ const prev = localValue();
94
+ const isCurrentlySelected = prev.includes(value);
95
+ if (isMultiple) {
96
+ commitValue(isCurrentlySelected ? prev.filter((v) => v !== value) : [...prev, value]);
97
+ return;
98
+ }
98
99
 
99
- // Single-select: replace any value from this section
100
- const sectionValues = new Set(section.options.map((o) => o.value));
101
- const otherValues = prev.filter((v) => !sectionValues.has(v));
102
- return isCurrentlySelected ? otherValues : [...otherValues, value];
103
- });
100
+ // Single-select: replace any value from this section.
101
+ const sectionValues = new Set(section.options.map((o) => o.value));
102
+ const otherValues = prev.filter((v) => !sectionValues.has(v));
103
+ commitValue(isCurrentlySelected ? otherValues : [...otherValues, value]);
104
104
  };
105
105
 
106
- const clearOrReset = () => setLocalValue(props.defaultValue ? [...props.defaultValue] : []);
107
-
108
- const handleClose = () => {
109
- if (hasChanges()) props.onChange(localValue());
110
- };
106
+ const clearOrReset = () => commitValue(props.defaultValue ? [...props.defaultValue] : []);
111
107
 
112
108
  // Build dropdown elements
113
109
  const dropdownElements = (): DropdownItem[] => {
@@ -128,7 +124,14 @@ export default function FilterChip(props: FilterChipProps) {
128
124
  }}
129
125
  >
130
126
  <Show when={isMultiple}>
131
- <input type="checkbox" checked={isSelected(option.value)} readOnly aria-hidden="true" tabindex={-1} class="shrink-0 pointer-events-none" />
127
+ <input
128
+ type="checkbox"
129
+ checked={isSelected(option.value)}
130
+ readOnly
131
+ aria-hidden="true"
132
+ tabindex={-1}
133
+ class="shrink-0 pointer-events-none"
134
+ />
132
135
  </Show>
133
136
 
134
137
  <Show when={option.icon && !isMultiple}>
@@ -174,23 +177,22 @@ export default function FilterChip(props: FilterChipProps) {
174
177
  };
175
178
 
176
179
  const trigger = (
177
- <div class={`btn-input btn-input-sm ${isActive() ? "btn-input-active" : ""}`}>
180
+ <div
181
+ class={`btn-input btn-input-sm ${props.iconOnly ? "h-8 w-8 justify-center px-0" : ""} ${isActive() ? "btn-input-active" : ""}`}
182
+ role="button"
183
+ aria-label={props.label}
184
+ title={props.iconOnly ? props.label : undefined}
185
+ >
178
186
  <i class={`${props.icon} ${isActive() ? "text-blue-600 dark:text-blue-300" : "text-zinc-500 dark:text-zinc-400"}`} />
179
- <span class={isActive() ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-700 dark:text-zinc-300"}>
180
- {props.label}
181
- <Show when={!hasDefaultValue() && selectedCount() > 0}>{` (${selectedCount()})`}</Show>
182
- </span>
183
- <i class="ti ti-chevron-down text-zinc-400 text-[10px]" />
187
+ <Show when={!props.iconOnly}>
188
+ <span class={isActive() ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-700 dark:text-zinc-300"}>
189
+ {props.label}
190
+ <Show when={!hasDefaultValue() && selectedCount() > 0}>{` (${selectedCount()})`}</Show>
191
+ </span>
192
+ <i class="ti ti-chevron-down text-zinc-400 text-[10px]" />
193
+ </Show>
184
194
  </div>
185
195
  );
186
196
 
187
- return (
188
- <Dropdown
189
- trigger={trigger}
190
- elements={dropdownElements()}
191
- position={props.position ?? "bottom-left"}
192
- onClose={handleClose}
193
- width="w-52"
194
- />
195
- );
197
+ return <Dropdown trigger={trigger} elements={dropdownElements()} position={props.position ?? "bottom-left"} width="w-52" />;
196
198
  }
package/src/ui/index.ts CHANGED
@@ -1,17 +1,16 @@
1
- export * from "./misc";
2
- export * from "./ipa";
3
- export * from "./input";
1
+ export type { SettingsFieldProps, SettingsSaveBarProps } from "./admin-settings";
2
+ export { readSettingsError, SettingsField, SettingsSaveBar, sameSettingValue } from "./admin-settings";
3
+ export type { DialogClose, DialogCore, DialogRender, OpenDialogOptions } from "./dialog-core";
4
+ export { createDialogCore, dialogCore } from "./dialog-core";
4
5
  export * from "./filter";
6
+ export * from "./input";
7
+ export { layout, LAYOUT_UPDATE_EVENT, type LayoutBreadcrumb, type LayoutUpdate } from "./layout";
8
+ export * from "./misc";
9
+ export type { PromptSearchInput, PromptSearchItem, PromptSearchOptions } from "./prompts";
10
+ export { createFormState, DialogHeader, prompts } from "./prompts";
11
+ export type { ToastFn, ToastHandle, ToastOptions, ToastVariant } from "./toast";
12
+ export { toast } from "./toast";
5
13
  export * from "./widgets";
6
- export { currentPathWithQuery, refreshCurrentPath, navigateTo } from "./navigation";
7
- export { SettingsField, SettingsSaveBar, sameSettingValue, readSettingsError } from "./admin-settings";
8
- export type { SettingsFieldProps, SettingsSaveBarProps } from "./admin-settings";
9
- export { prompts, DialogHeader, createFormState } from "./prompts";
10
- export type { PromptSearchItem, PromptSearchInput, PromptSearchOptions } from "./prompts";
11
- export { dialogCore, createDialogCore } from "./dialog-core";
12
- export type { DialogClose, OpenDialogOptions, DialogRender, DialogCore } from "./dialog-core";
13
- export { default as SidebarLayout, SidebarFromSpec } from "./sidebar";
14
- export type { SidebarSpec, SidebarRow, SidebarSection, SidebarTreeNode, SidebarTreeSpec } from "./sidebar";
15
14
  // NOTE: islands (*.island.tsx) belong inside the consuming app's package, not
16
15
  // in cloud-lib. The SSR plugin discovers islands by import-path suffix; barrel
17
16
  // re-exports strip the `.island` segment and silently break hydration. Apps