@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.
- 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 +113 -10
- 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 +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- 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/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 +64 -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 +49 -0
- package/src/shared/redirect.ts +52 -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,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic single- or multi-line text editor with completion support.
|
|
3
|
+
*
|
|
4
|
+
* Same completion engine as `<MarkdownEditor>` but without any
|
|
5
|
+
* markdown opinions — works for plain text, formulas, code, search
|
|
6
|
+
* builders, mentions, etc.
|
|
7
|
+
*
|
|
8
|
+
* Two visual modes:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Plain textarea (default)** — visible textarea, no overlay.
|
|
11
|
+
* The dropdown anchors to the textarea's bottom edge (like the
|
|
12
|
+
* `<Select>` dropdown). Simpler, no glyph-alignment work
|
|
13
|
+
* needed.
|
|
14
|
+
*
|
|
15
|
+
* 2. **Overlay mode** (when `highlight` is provided) — invisible
|
|
16
|
+
* textarea on top of a preview div that renders the user's
|
|
17
|
+
* `highlight(text)` HTML. Overtype pattern: the textarea cursor
|
|
18
|
+
* sits exactly over the highlighted glyphs. Ghost preview is
|
|
19
|
+
* injected at the caret in the preview, and the dropdown
|
|
20
|
+
* anchors there.
|
|
21
|
+
*
|
|
22
|
+
* Async suggestions
|
|
23
|
+
* -----------------
|
|
24
|
+
* Sync `suggest` returns hit the editor synchronously (immediate
|
|
25
|
+
* dropdown/ghost). Async `suggest` (returning a Promise) goes
|
|
26
|
+
* through a debounced AbortController-aware fetch:
|
|
27
|
+
*
|
|
28
|
+
* - `loading` state shows a spinner row in the dropdown.
|
|
29
|
+
* - Previous suggestions stay visible (dimmed) while fetching.
|
|
30
|
+
* - Each keystroke aborts the previous in-flight request.
|
|
31
|
+
* - Errors surface a Retry button.
|
|
32
|
+
*
|
|
33
|
+
* Focus / keyboard model
|
|
34
|
+
* ----------------------
|
|
35
|
+
* Same contract as MarkdownEditor's completion dropdown — Popover API
|
|
36
|
+
* (top-layer, modal-safe, no focus capture). ArrowUp/Down navigate,
|
|
37
|
+
* Tab/Enter accept, Esc closes. When completions are configured and
|
|
38
|
+
* a dropdown is active, Tab is "trapped" (swallowed if no ghost) so
|
|
39
|
+
* a single Tab press always has a useful effect — see the MarkdownEditor
|
|
40
|
+
* comment on focus-trap rationale.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack, For, Show } from "solid-js";
|
|
44
|
+
import { timed } from "@valentinkolb/stdlib/solid";
|
|
45
|
+
import {
|
|
46
|
+
type Completion,
|
|
47
|
+
type QueryContext,
|
|
48
|
+
type Suggestion,
|
|
49
|
+
applySuggestion,
|
|
50
|
+
buildSuggestContext,
|
|
51
|
+
collectKnownLabels,
|
|
52
|
+
detectQuery,
|
|
53
|
+
displayLabel,
|
|
54
|
+
plainTextHighlight,
|
|
55
|
+
renderWithOverlay,
|
|
56
|
+
resetCompletionState,
|
|
57
|
+
resolveSuggestions,
|
|
58
|
+
suggestSync,
|
|
59
|
+
tryExpand,
|
|
60
|
+
tryRestore,
|
|
61
|
+
} from "../completion";
|
|
62
|
+
|
|
63
|
+
export type AutocompleteEditorProps = {
|
|
64
|
+
/** Reactive value accessor — current text. */
|
|
65
|
+
value?: () => string | undefined | null;
|
|
66
|
+
/** Fired on every input event (use this for controlled state). */
|
|
67
|
+
onInput?: (value: string) => void;
|
|
68
|
+
/** Fired on textarea change (commit on blur). */
|
|
69
|
+
onChange?: (value: string) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Fired when the user presses the submit gesture:
|
|
72
|
+
* - single-line mode: bare Enter
|
|
73
|
+
* - multi-line mode: Cmd/Ctrl+Enter
|
|
74
|
+
*/
|
|
75
|
+
onSubmit?: () => void;
|
|
76
|
+
|
|
77
|
+
/** Completion definitions. Each defines a trigger + suggest fn. */
|
|
78
|
+
completions?: Completion[];
|
|
79
|
+
/** Keep abbreviation-style Backspace restore after accepting an expansion. Default true. */
|
|
80
|
+
restoreExpansionOnBackspace?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Optional syntax-highlighter: plain text → safe HTML. When
|
|
84
|
+
* provided, the editor switches to overlay mode (invisible
|
|
85
|
+
* textarea + preview div). When omitted, plain textarea.
|
|
86
|
+
*
|
|
87
|
+
* Width-safe requirements: monospace font, no font-weight /
|
|
88
|
+
* font-style changes between tokens (use colour/background only).
|
|
89
|
+
* See `MarkdownEditor`'s highlighter for a reference implementation.
|
|
90
|
+
*/
|
|
91
|
+
highlight?: (text: string) => string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Single-line mode: Enter calls `onSubmit`, no newlines allowed.
|
|
95
|
+
* Default `false` (multi-line textarea).
|
|
96
|
+
*/
|
|
97
|
+
singleLine?: boolean;
|
|
98
|
+
|
|
99
|
+
/** Approximate visible rows (ignored when `singleLine`). Default 3. */
|
|
100
|
+
lines?: number;
|
|
101
|
+
/** Fill the height provided by the parent container in multi-line mode. */
|
|
102
|
+
fill?: boolean;
|
|
103
|
+
|
|
104
|
+
placeholder?: string;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
spellcheck?: boolean;
|
|
107
|
+
id?: string;
|
|
108
|
+
name?: string;
|
|
109
|
+
ariaLabel?: string;
|
|
110
|
+
ariaDescribedBy?: string;
|
|
111
|
+
ariaInvalid?: boolean;
|
|
112
|
+
ariaRequired?: boolean;
|
|
113
|
+
maxLength?: number;
|
|
114
|
+
/** Visual error state — adds red border. */
|
|
115
|
+
error?: boolean;
|
|
116
|
+
/** Visual variant. Defaults to the compact zinc input surface. */
|
|
117
|
+
variant?: "default" | "paper";
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
121
|
+
let textareaEl: HTMLTextAreaElement | undefined;
|
|
122
|
+
let previewEl: HTMLDivElement | undefined;
|
|
123
|
+
let dropdownEl: HTMLDivElement | undefined;
|
|
124
|
+
|
|
125
|
+
/* ── State ─────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
const [composing, setComposing] = createSignal(false);
|
|
128
|
+
const [localValue, setLocalValue] = createSignal(props.value?.() ?? "");
|
|
129
|
+
|
|
130
|
+
type CompletionState = {
|
|
131
|
+
ctx: QueryContext;
|
|
132
|
+
suggestions: Suggestion[];
|
|
133
|
+
selectedIndex: number;
|
|
134
|
+
};
|
|
135
|
+
const [completionState, setCompletionState] = createSignal<CompletionState | null>(null);
|
|
136
|
+
const [loading, setLoading] = createSignal(false);
|
|
137
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
138
|
+
const [isDarkTheme, setIsDarkTheme] = createSignal(false);
|
|
139
|
+
|
|
140
|
+
const activeSuggestion = (): Suggestion | null => {
|
|
141
|
+
const s = completionState();
|
|
142
|
+
return s ? (s.suggestions[s.selectedIndex] ?? null) : null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const suggestionGhost = (state: CompletionState, suggestion: Suggestion): { at: number; text: string } | undefined => {
|
|
146
|
+
const edit = suggestion.textEdit;
|
|
147
|
+
if (!edit) return { at: state.ctx.end, text: suggestion.text.slice(state.ctx.text.length) };
|
|
148
|
+
if (edit.end !== state.ctx.end) return undefined;
|
|
149
|
+
const replacing = localValue().slice(edit.start, edit.end);
|
|
150
|
+
if (!edit.text.toLowerCase().startsWith(replacing.toLowerCase())) return undefined;
|
|
151
|
+
return { at: edit.end, text: edit.text.slice(replacing.length) };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Async fetch lifecycle. One AbortController per in-flight request;
|
|
155
|
+
// each new keystroke aborts the previous so stale results never
|
|
156
|
+
// overwrite fresher state.
|
|
157
|
+
let currentAbort: AbortController | null = null;
|
|
158
|
+
let lastAsyncCompletion: Completion | null = null;
|
|
159
|
+
let lastAsyncQuery: string = "";
|
|
160
|
+
let lastAsyncCtx: ReturnType<typeof buildSuggestContext> | null = null;
|
|
161
|
+
|
|
162
|
+
/* ── Memoised inputs ──────────────────────────────────── */
|
|
163
|
+
|
|
164
|
+
const completions = createMemo(() => props.completions);
|
|
165
|
+
const knownLabels = createMemo(() => collectKnownLabels(completions()));
|
|
166
|
+
const useOverlay = createMemo(() => Boolean(props.highlight));
|
|
167
|
+
|
|
168
|
+
/* ── External value sync ──────────────────────────────── */
|
|
169
|
+
|
|
170
|
+
createEffect(() => {
|
|
171
|
+
const incoming = props.value?.();
|
|
172
|
+
if (incoming === undefined || incoming === null) return;
|
|
173
|
+
if (incoming !== untrack(localValue)) setLocalValue(incoming);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/* ── Imperative textarea sync ──────────────────────────── */
|
|
177
|
+
|
|
178
|
+
createEffect(() => {
|
|
179
|
+
const target = localValue();
|
|
180
|
+
if (composing()) return;
|
|
181
|
+
if (textareaEl && textareaEl.value !== target) {
|
|
182
|
+
textareaEl.value = target;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/* ── Overlay preview rendering ─────────────────────────── */
|
|
187
|
+
|
|
188
|
+
createEffect(() => {
|
|
189
|
+
if (!useOverlay() || !previewEl) return;
|
|
190
|
+
const state = completionState();
|
|
191
|
+
const active = activeSuggestion();
|
|
192
|
+
|
|
193
|
+
const ghostArg = state && active ? suggestionGhost(state, active) : undefined;
|
|
194
|
+
|
|
195
|
+
const highlight = props.highlight ?? plainTextHighlight;
|
|
196
|
+
previewEl.innerHTML = renderWithOverlay(localValue(), highlight, {
|
|
197
|
+
ghost: ghostArg,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (textareaEl) {
|
|
201
|
+
previewEl.scrollTop = textareaEl.scrollTop;
|
|
202
|
+
previewEl.scrollLeft = textareaEl.scrollLeft;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/* ── Completion pipeline ───────────────────────────────── */
|
|
207
|
+
|
|
208
|
+
const clearCompletion = (): void => {
|
|
209
|
+
setCompletionState(null);
|
|
210
|
+
setLoading(false);
|
|
211
|
+
setError(null);
|
|
212
|
+
closeDropdown();
|
|
213
|
+
currentAbort?.abort();
|
|
214
|
+
currentAbort = null;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const recomputeCompletion = (): void => {
|
|
218
|
+
if (!textareaEl) return;
|
|
219
|
+
const ctx = detectQuery(textareaEl, completions());
|
|
220
|
+
if (!ctx) {
|
|
221
|
+
clearCompletion();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const suggestCtx = buildSuggestContext(textareaEl, ctx);
|
|
226
|
+
|
|
227
|
+
// Spin up an abort signal for THIS attempt — we'll either
|
|
228
|
+
// consume it sync below, or hand it to the async path.
|
|
229
|
+
currentAbort?.abort();
|
|
230
|
+
currentAbort = new AbortController();
|
|
231
|
+
const signal = currentAbort.signal;
|
|
232
|
+
|
|
233
|
+
const result = resolveSuggestions(ctx.completion, ctx.query, suggestCtx, signal);
|
|
234
|
+
|
|
235
|
+
if (result.kind === "sync") {
|
|
236
|
+
setError(null);
|
|
237
|
+
setLoading(false);
|
|
238
|
+
applySuggestionList(ctx, result.data);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Async path: keep previous suggestions visible (dimmed via
|
|
243
|
+
// `loading` state) and schedule the debounced fetch.
|
|
244
|
+
setError(null);
|
|
245
|
+
setLoading(true);
|
|
246
|
+
lastAsyncCompletion = ctx.completion;
|
|
247
|
+
lastAsyncQuery = ctx.query;
|
|
248
|
+
lastAsyncCtx = suggestCtx;
|
|
249
|
+
debouncedFetch.debouncedFn(ctx, suggestCtx, result.promise, signal);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Take a fresh suggestion list, filter to usable ones, and merge
|
|
253
|
+
* with the current selection state. */
|
|
254
|
+
const applySuggestionList = (ctx: QueryContext, list: Suggestion[]): void => {
|
|
255
|
+
const lower = ctx.text.toLowerCase();
|
|
256
|
+
const currentText = localValue();
|
|
257
|
+
const usable = list.filter((s) => {
|
|
258
|
+
const edit = s.textEdit;
|
|
259
|
+
if (edit) {
|
|
260
|
+
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) || edit.start < 0 || edit.end < edit.start) return false;
|
|
261
|
+
if (edit.end > currentText.length) return false;
|
|
262
|
+
return currentText.slice(edit.start, edit.end) !== edit.text;
|
|
263
|
+
}
|
|
264
|
+
return s.text.toLowerCase().startsWith(lower) && s.text.length > ctx.text.length;
|
|
265
|
+
});
|
|
266
|
+
if (usable.length === 0) {
|
|
267
|
+
clearCompletion();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Preserve highlight across keystrokes when the same suggestion
|
|
272
|
+
// is still present — less jarring than always resetting.
|
|
273
|
+
const prev = completionState();
|
|
274
|
+
const prevSelected = prev?.suggestions[prev.selectedIndex]?.text;
|
|
275
|
+
const keptIndex = prevSelected ? usable.findIndex((s) => s.text === prevSelected) : -1;
|
|
276
|
+
const selectedIndex = keptIndex >= 0 ? keptIndex : 0;
|
|
277
|
+
|
|
278
|
+
setCompletionState({ ctx, suggestions: usable, selectedIndex });
|
|
279
|
+
|
|
280
|
+
if (ctx.completion.dropdown) {
|
|
281
|
+
if (!dropdownOpenSignal()) setDropdownOpenSignal(true);
|
|
282
|
+
queueMicrotask(positionDropdown);
|
|
283
|
+
} else if (dropdownOpenSignal()) {
|
|
284
|
+
closeDropdown();
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Debounced async fetch — kicks off ~180ms after the LAST
|
|
289
|
+
// keystroke. The debounce delays the actual await so we don't
|
|
290
|
+
// hammer remote endpoints on every char.
|
|
291
|
+
const debouncedFetch = timed.debounce(
|
|
292
|
+
(ctx: QueryContext, suggestCtx: ReturnType<typeof buildSuggestContext>, promise: Promise<Suggestion[]>, signal: AbortSignal) => {
|
|
293
|
+
void runFetch(ctx, suggestCtx, promise, signal);
|
|
294
|
+
},
|
|
295
|
+
180,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const runFetch = async (
|
|
299
|
+
ctx: QueryContext,
|
|
300
|
+
suggestCtx: ReturnType<typeof buildSuggestContext>,
|
|
301
|
+
promise: Promise<Suggestion[]>,
|
|
302
|
+
signal: AbortSignal,
|
|
303
|
+
): Promise<void> => {
|
|
304
|
+
try {
|
|
305
|
+
const list = await promise;
|
|
306
|
+
if (signal.aborted) return; // newer keystroke superseded us
|
|
307
|
+
setLoading(false);
|
|
308
|
+
applySuggestionList(ctx, list);
|
|
309
|
+
} catch (e: unknown) {
|
|
310
|
+
if (signal.aborted) return;
|
|
311
|
+
if (e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError") return;
|
|
312
|
+
setLoading(false);
|
|
313
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/** Retry the most recent failed async fetch. */
|
|
318
|
+
const retryAsync = (): void => {
|
|
319
|
+
if (!lastAsyncCompletion || !lastAsyncCtx) return;
|
|
320
|
+
setError(null);
|
|
321
|
+
setLoading(true);
|
|
322
|
+
currentAbort?.abort();
|
|
323
|
+
currentAbort = new AbortController();
|
|
324
|
+
const signal = currentAbort.signal;
|
|
325
|
+
const promise = lastAsyncCompletion.suggest(lastAsyncQuery, lastAsyncCtx, signal);
|
|
326
|
+
if (!(promise instanceof Promise)) {
|
|
327
|
+
// Suggest turned sync between calls — treat as direct result.
|
|
328
|
+
setLoading(false);
|
|
329
|
+
// Re-detect ctx so we have a fresh queryContext.
|
|
330
|
+
recomputeCompletion();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
void runFetch(
|
|
334
|
+
// Reuse prev queryCtx by re-detecting; cheaper than caching it.
|
|
335
|
+
detectQuery(textareaEl!, completions())!,
|
|
336
|
+
lastAsyncCtx,
|
|
337
|
+
promise,
|
|
338
|
+
signal,
|
|
339
|
+
);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/* ── Dropdown positioning + open/close ─────────────────── */
|
|
343
|
+
|
|
344
|
+
const [dropdownOpenSignal, setDropdownOpenSignal] = createSignal(false);
|
|
345
|
+
|
|
346
|
+
const positionDropdown = (): void => {
|
|
347
|
+
if (!dropdownEl || !textareaEl) return;
|
|
348
|
+
// The popover may have been unmounted by `<Show>` between the
|
|
349
|
+
// microtask scheduling and this call (e.g. state cleared in a
|
|
350
|
+
// fast keystroke sequence). `showPopover` throws on disconnected
|
|
351
|
+
// elements, so bail when the ref points to a detached node.
|
|
352
|
+
if (!dropdownEl.isConnected) return;
|
|
353
|
+
syncTheme();
|
|
354
|
+
|
|
355
|
+
// In overlay mode, anchor to the caret marker in the preview.
|
|
356
|
+
// Otherwise anchor to the textarea's bottom edge.
|
|
357
|
+
let rect: DOMRect;
|
|
358
|
+
if (useOverlay() && previewEl) {
|
|
359
|
+
const anchorEl = previewEl.querySelector<HTMLElement>("[data-completion-anchor]");
|
|
360
|
+
rect = anchorEl ? anchorEl.getBoundingClientRect() : textareaEl.getBoundingClientRect();
|
|
361
|
+
} else {
|
|
362
|
+
rect = textareaEl.getBoundingClientRect();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const dropdownMaxHeight = 260;
|
|
366
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
367
|
+
const spaceAbove = rect.top;
|
|
368
|
+
const openAbove = spaceBelow < dropdownMaxHeight && spaceAbove > spaceBelow;
|
|
369
|
+
|
|
370
|
+
const dropdownWidth = 280;
|
|
371
|
+
const margin = 8;
|
|
372
|
+
const left = Math.min(rect.left, window.innerWidth - dropdownWidth - margin);
|
|
373
|
+
|
|
374
|
+
dropdownEl.style.left = `${Math.max(margin, left)}px`;
|
|
375
|
+
dropdownEl.style.width = `${dropdownWidth}px`;
|
|
376
|
+
|
|
377
|
+
if (openAbove) {
|
|
378
|
+
dropdownEl.style.top = "auto";
|
|
379
|
+
dropdownEl.style.bottom = `${window.innerHeight - rect.top + 4}px`;
|
|
380
|
+
} else {
|
|
381
|
+
dropdownEl.style.top = `${rect.bottom + 4}px`;
|
|
382
|
+
dropdownEl.style.bottom = "auto";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!dropdownEl.matches(":popover-open")) {
|
|
386
|
+
dropdownEl.showPopover();
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const closeDropdown = (): void => {
|
|
391
|
+
if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
|
|
392
|
+
if (dropdownOpenSignal()) setDropdownOpenSignal(false);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const syncTheme = (): void => {
|
|
396
|
+
if (typeof document === "undefined") return;
|
|
397
|
+
setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const acceptActiveSuggestion = (): boolean => {
|
|
401
|
+
if (!textareaEl) return false;
|
|
402
|
+
const state = completionState();
|
|
403
|
+
const active = activeSuggestion();
|
|
404
|
+
if (!state || !active) return false;
|
|
405
|
+
if (!active.textEdit && active.text === state.ctx.text) {
|
|
406
|
+
clearCompletion();
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
const applied = applySuggestion(textareaEl, state.ctx, active, { trackExpansion: props.restoreExpansionOnBackspace ?? true });
|
|
410
|
+
clearCompletion();
|
|
411
|
+
if (!applied) return false;
|
|
412
|
+
queueMicrotask(recomputeCompletion);
|
|
413
|
+
return true;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const moveSelection = (direction: 1 | -1): void => {
|
|
417
|
+
const state = completionState();
|
|
418
|
+
if (!state) return;
|
|
419
|
+
const len = state.suggestions.length;
|
|
420
|
+
if (len === 0) return;
|
|
421
|
+
const next = (state.selectedIndex + direction + len) % len;
|
|
422
|
+
setCompletionState({ ...state, selectedIndex: next });
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/* ── Event handlers ───────────────────────────────────── */
|
|
426
|
+
|
|
427
|
+
onMount(() => {
|
|
428
|
+
const onSelectionChange = (): void => {
|
|
429
|
+
if (document.activeElement === textareaEl) recomputeCompletion();
|
|
430
|
+
};
|
|
431
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
432
|
+
onCleanup(() => document.removeEventListener("selectionchange", onSelectionChange));
|
|
433
|
+
onCleanup(() => {
|
|
434
|
+
if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
|
|
435
|
+
currentAbort?.abort();
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const onInput = (e: InputEvent & { currentTarget: HTMLTextAreaElement }): void => {
|
|
440
|
+
if (e.inputType.startsWith("insert") && tryExpand(e.currentTarget, completions())) return;
|
|
441
|
+
const v = e.currentTarget.value;
|
|
442
|
+
setLocalValue(v);
|
|
443
|
+
props.onInput?.(v);
|
|
444
|
+
recomputeCompletion();
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const onChange = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
|
|
448
|
+
props.onChange?.(e.currentTarget.value);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
452
|
+
if (!textareaEl) return;
|
|
453
|
+
if (e.isComposing) return;
|
|
454
|
+
|
|
455
|
+
if ((props.restoreExpansionOnBackspace ?? true) && e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
456
|
+
if (tryRestore(textareaEl)) {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const hasCompletions = (completions()?.length ?? 0) > 0;
|
|
463
|
+
const state = completionState();
|
|
464
|
+
const hasActive = state !== null;
|
|
465
|
+
const isDropdown = state?.ctx.completion.dropdown === true;
|
|
466
|
+
|
|
467
|
+
if (hasActive && isDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
moveSelection(e.key === "ArrowDown" ? 1 : -1);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Enter: in single-line mode → submit. In multi-line → insert
|
|
474
|
+
// newline (unless dropdown is open, then accept the row).
|
|
475
|
+
if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
476
|
+
if (hasActive && isDropdown && dropdownOpenSignal()) {
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
acceptActiveSuggestion();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (props.singleLine) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
props.onSubmit?.();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Cmd/Ctrl+Enter submits in multi-line mode.
|
|
489
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !props.singleLine) {
|
|
490
|
+
if (props.onSubmit) {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
props.onSubmit();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (e.key === "Tab" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
498
|
+
if (hasActive) {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
acceptActiveSuggestion();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (hasCompletions) {
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (e.key === "Escape") {
|
|
510
|
+
if (hasActive) {
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
clearCompletion();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (hasCompletions) {
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
textareaEl.blur();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const onScroll = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
|
|
523
|
+
if (!previewEl) return;
|
|
524
|
+
previewEl.scrollTop = e.currentTarget.scrollTop;
|
|
525
|
+
previewEl.scrollLeft = e.currentTarget.scrollLeft;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/* ── Render ───────────────────────────────────────────── */
|
|
529
|
+
|
|
530
|
+
const surfaceStyle = (): string => {
|
|
531
|
+
if (props.fill && !props.singleLine) return `--ac-h: 100%`;
|
|
532
|
+
if (props.singleLine) return `--ac-h: 2.5rem`;
|
|
533
|
+
const lines = props.lines ?? 3;
|
|
534
|
+
return `--ac-h: ${lines * 1.5}rem`;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<div
|
|
539
|
+
class="ac-editor"
|
|
540
|
+
data-fill={props.fill && !props.singleLine ? "true" : undefined}
|
|
541
|
+
data-disabled={props.disabled ? "true" : undefined}
|
|
542
|
+
data-error={props.error ? "true" : undefined}
|
|
543
|
+
data-variant={props.variant === "paper" ? "paper" : undefined}
|
|
544
|
+
>
|
|
545
|
+
<div class="ac-editor-surface" style={surfaceStyle()}>
|
|
546
|
+
<Show when={!localValue() && props.placeholder}>
|
|
547
|
+
<div class="ac-editor-placeholder" aria-hidden="true">
|
|
548
|
+
{props.placeholder}
|
|
549
|
+
</div>
|
|
550
|
+
</Show>
|
|
551
|
+
<Show when={useOverlay()}>
|
|
552
|
+
<div ref={(el) => (previewEl = el)} class="ac-editor-layer ac-editor-preview" aria-hidden="true" />
|
|
553
|
+
</Show>
|
|
554
|
+
<textarea
|
|
555
|
+
ref={(el) => (textareaEl = el)}
|
|
556
|
+
id={props.id}
|
|
557
|
+
name={props.name}
|
|
558
|
+
class={useOverlay() ? "ac-editor-layer ac-editor-input ac-editor-input--overlay" : "ac-editor-layer ac-editor-input"}
|
|
559
|
+
onInput={onInput}
|
|
560
|
+
onChange={onChange}
|
|
561
|
+
onKeyDown={onKeyDown}
|
|
562
|
+
onScroll={onScroll}
|
|
563
|
+
onCompositionStart={() => setComposing(true)}
|
|
564
|
+
onCompositionEnd={() => setComposing(false)}
|
|
565
|
+
onBlur={(e) => {
|
|
566
|
+
const next = e.relatedTarget as HTMLElement | null;
|
|
567
|
+
if (next && dropdownEl && dropdownEl.contains(next)) return;
|
|
568
|
+
resetCompletionState();
|
|
569
|
+
clearCompletion();
|
|
570
|
+
}}
|
|
571
|
+
disabled={props.disabled}
|
|
572
|
+
spellcheck={props.spellcheck ?? false}
|
|
573
|
+
maxLength={props.maxLength}
|
|
574
|
+
rows={props.singleLine ? 1 : (props.lines ?? 3)}
|
|
575
|
+
aria-label={props.ariaLabel}
|
|
576
|
+
aria-describedby={props.ariaDescribedBy}
|
|
577
|
+
aria-invalid={props.ariaInvalid}
|
|
578
|
+
aria-required={props.ariaRequired}
|
|
579
|
+
/>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
{/* Dropdown popover — only mounted when there's something to
|
|
583
|
+
show (sync state OR async loading with a previous state).
|
|
584
|
+
Avoids the empty-popover fade-out flicker that the global
|
|
585
|
+
[popover] close transition would cause. */}
|
|
586
|
+
<Show when={completionState() || loading() || error()}>
|
|
587
|
+
<div
|
|
588
|
+
ref={(el) => (dropdownEl = el)}
|
|
589
|
+
popover="manual"
|
|
590
|
+
class="popup fixed inset-auto m-0 border border-zinc-200 p-1 dark:border-zinc-700"
|
|
591
|
+
classList={{ dark: isDarkTheme() }}
|
|
592
|
+
>
|
|
593
|
+
<div class="flex max-h-60 flex-col gap-0.5 overflow-y-auto" role="listbox" aria-label="Suggestions">
|
|
594
|
+
<Show when={loading()}>
|
|
595
|
+
<div class="flex items-center gap-2 px-2 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
596
|
+
<i class="ti ti-loader-2 animate-spin" /> Loading...
|
|
597
|
+
</div>
|
|
598
|
+
</Show>
|
|
599
|
+
<Show when={error()}>
|
|
600
|
+
<div class="flex items-center gap-2 px-2 py-1.5 text-xs text-red-500">
|
|
601
|
+
<i class="ti ti-alert-triangle shrink-0" />
|
|
602
|
+
<span class="flex-1 truncate">{error()}</span>
|
|
603
|
+
<button
|
|
604
|
+
type="button"
|
|
605
|
+
class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
606
|
+
onMouseDown={(e) => {
|
|
607
|
+
e.preventDefault();
|
|
608
|
+
retryAsync();
|
|
609
|
+
}}
|
|
610
|
+
>
|
|
611
|
+
Retry
|
|
612
|
+
</button>
|
|
613
|
+
</div>
|
|
614
|
+
</Show>
|
|
615
|
+
<Show when={completionState()}>
|
|
616
|
+
{(state) => (
|
|
617
|
+
<For each={state().suggestions}>
|
|
618
|
+
{(suggestion, index) => {
|
|
619
|
+
const isSelected = () => index() === state().selectedIndex;
|
|
620
|
+
return (
|
|
621
|
+
<div
|
|
622
|
+
onMouseDown={(e) => {
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
setCompletionState({ ...state(), selectedIndex: index() });
|
|
625
|
+
acceptActiveSuggestion();
|
|
626
|
+
textareaEl?.focus();
|
|
627
|
+
}}
|
|
628
|
+
onMouseEnter={() => setCompletionState({ ...state(), selectedIndex: index() })}
|
|
629
|
+
role="option"
|
|
630
|
+
aria-selected={isSelected()}
|
|
631
|
+
class={`group flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors ${
|
|
632
|
+
isSelected()
|
|
633
|
+
? "bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300"
|
|
634
|
+
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
635
|
+
} ${loading() ? "opacity-60" : ""}`}
|
|
636
|
+
>
|
|
637
|
+
<span class="font-mono truncate">{displayLabel(suggestion, state().ctx.completion)}</span>
|
|
638
|
+
<Show when={suggestion.hint}>
|
|
639
|
+
<span class="ml-auto text-xs text-zinc-500 dark:text-zinc-400 truncate">{suggestion.hint}</span>
|
|
640
|
+
</Show>
|
|
641
|
+
</div>
|
|
642
|
+
);
|
|
643
|
+
}}
|
|
644
|
+
</For>
|
|
645
|
+
)}
|
|
646
|
+
</Show>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</Show>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
export default AutocompleteEditor;
|
|
655
|
+
export type { Completion, Suggestion, SuggestContext } from "../completion";
|
|
656
|
+
export { abbreviations } from "../completion";
|