@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.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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;
|