@checkstack/ui 1.9.0 → 1.11.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/CHANGELOG.md +417 -0
- package/package.json +15 -7
- package/scripts/generate-stdlib-types.ts +2 -2
- package/src/components/ActionCard.tsx +221 -0
- package/src/components/CodeEditor/CodeEditor.tsx +51 -9
- package/src/components/CodeEditor/TypefoxEditor.tsx +868 -0
- package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
- package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
- package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
- package/src/components/CodeEditor/index.ts +2 -0
- package/src/components/CodeEditor/scriptContext.test.ts +41 -0
- package/src/components/CodeEditor/scriptContext.ts +76 -1
- package/src/components/CodeEditor/templateValidation.ts +51 -0
- package/src/components/CodeEditor/types.ts +109 -0
- package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
- package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
- package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
- package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
- package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
- package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
- package/src/components/DynamicForm/DynamicForm.tsx +2 -0
- package/src/components/DynamicForm/FormField.tsx +29 -9
- package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +16 -7
- package/src/components/DynamicForm/types.ts +11 -0
- package/src/components/ListEmptyState.tsx +51 -0
- package/src/components/QueryErrorState.tsx +64 -0
- package/src/components/ResponsiveTable.tsx +92 -0
- package/src/components/Skeleton.tsx +39 -0
- package/src/components/TemplateInput.tsx +104 -0
- package/src/components/TemplateInputToggle.tsx +111 -0
- package/src/components/TemplateValueInput.test.ts +98 -0
- package/src/components/TemplateValueInput.tsx +470 -0
- package/src/components/VariablePicker.tsx +271 -0
- package/src/hooks/useInitOnceForKey.test.ts +27 -0
- package/src/hooks/useInitOnceForKey.ts +21 -18
- package/src/index.ts +10 -0
- package/src/utils/toastTemplates.test.ts +82 -0
- package/src/utils/toastTemplates.ts +47 -0
- package/stories/ActionCard.stories.tsx +62 -0
- package/stories/Alert.stories.tsx +5 -5
- package/stories/ListEmptyState.stories.tsx +48 -0
- package/stories/QueryErrorState.stories.tsx +40 -0
- package/stories/ResponsiveTable.stories.tsx +93 -0
- package/stories/Skeleton.stories.tsx +53 -0
- package/stories/TemplateInputToggle.stories.tsx +77 -0
- package/stories/TemplateValueInput.stories.tsx +65 -0
- package/stories/VariablePicker.stories.tsx +109 -0
- package/stories/toastTemplates.stories.tsx +60 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
- package/src/components/CodeEditor/monacoStdlib.ts +0 -62
- package/src/components/CodeEditor/monacoWorkers.ts +0 -118
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input } from "./Input";
|
|
3
|
+
import type { TemplateProperty } from "./CodeEditor";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect if the cursor sits inside an unclosed `{{` template context.
|
|
7
|
+
*
|
|
8
|
+
* Exported so callers (and tests) can reuse the same detection rules used
|
|
9
|
+
* inside the picker for upstream "show the variable picker" decisions.
|
|
10
|
+
*/
|
|
11
|
+
export function detectTemplateContext(
|
|
12
|
+
text: string,
|
|
13
|
+
cursorPos: number,
|
|
14
|
+
): { isInTemplate: boolean; query: string; startPos: number } {
|
|
15
|
+
const textBefore = text.slice(0, cursorPos);
|
|
16
|
+
const lastOpenBrace = textBefore.lastIndexOf("{{");
|
|
17
|
+
const lastCloseBrace = textBefore.lastIndexOf("}}");
|
|
18
|
+
|
|
19
|
+
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
|
|
20
|
+
return {
|
|
21
|
+
isInTemplate: true,
|
|
22
|
+
query: textBefore.slice(lastOpenBrace + 2),
|
|
23
|
+
startPos: lastOpenBrace,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { isInTemplate: false, query: "", startPos: -1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Staged-completion contracts ─────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** A single row offered by a {@link TemplateCompletionProvider}. */
|
|
32
|
+
export interface TemplateCompletionItem {
|
|
33
|
+
/** Primary label rendered in the row. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Right-aligned hint (type / "comparator" / "filter"). */
|
|
36
|
+
detail?: string;
|
|
37
|
+
/** Tooltip + reserved for a future detail surface. */
|
|
38
|
+
description?: string;
|
|
39
|
+
/** Text spliced into the field, replacing `[replaceStart, replaceEnd]`. */
|
|
40
|
+
insertText: string;
|
|
41
|
+
/**
|
|
42
|
+
* Caret position after insertion, relative to the END of `insertText`.
|
|
43
|
+
* `0` (default) lands after the inserted text; negative values move
|
|
44
|
+
* back into it (e.g. `-1` to sit inside a filter's `()`).
|
|
45
|
+
*/
|
|
46
|
+
caretOffset?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a provider returns for the current cursor position. */
|
|
50
|
+
export interface TemplateCompletionResult {
|
|
51
|
+
/** Optional section heading shown above the rows. */
|
|
52
|
+
heading?: string;
|
|
53
|
+
/** Absolute char range in the value replaced when an item is chosen. */
|
|
54
|
+
replaceStart: number;
|
|
55
|
+
replaceEnd: number;
|
|
56
|
+
items: TemplateCompletionItem[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pluggable, context-aware completion. Given the full value + cursor,
|
|
61
|
+
* returns the candidate set (or `null` to hide the popup). When supplied
|
|
62
|
+
* it fully replaces the legacy `templateProperties` `{{`-detection flow —
|
|
63
|
+
* the provider owns deciding when and what to suggest, so it can do
|
|
64
|
+
* staged field → operator → value → filter completion.
|
|
65
|
+
*/
|
|
66
|
+
export type TemplateCompletionProvider = (args: {
|
|
67
|
+
value: string;
|
|
68
|
+
cursor: number;
|
|
69
|
+
}) => TemplateCompletionResult | null;
|
|
70
|
+
|
|
71
|
+
// ─── Popup placement ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Default popup height ceiling (px) — matches the prior `max-h-72`. */
|
|
74
|
+
const POPUP_MAX_HEIGHT = 288;
|
|
75
|
+
/** Smallest popup height (px) we'll shrink to before just overflowing. */
|
|
76
|
+
const POPUP_MIN_HEIGHT = 120;
|
|
77
|
+
|
|
78
|
+
export interface PopupPlacementInput {
|
|
79
|
+
/** Input's top edge, viewport coords (`getBoundingClientRect().top`). */
|
|
80
|
+
inputTop: number;
|
|
81
|
+
/** Input's bottom edge, viewport coords. */
|
|
82
|
+
inputBottom: number;
|
|
83
|
+
/** Input's left edge, viewport coords. */
|
|
84
|
+
inputLeft: number;
|
|
85
|
+
/** Popup's natural rendered width (px). */
|
|
86
|
+
popupWidth: number;
|
|
87
|
+
/** Popup's natural rendered height (px). */
|
|
88
|
+
popupHeight: number;
|
|
89
|
+
viewportWidth: number;
|
|
90
|
+
viewportHeight: number;
|
|
91
|
+
/** Gap kept from each viewport edge (px). */
|
|
92
|
+
margin?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PopupPlacement {
|
|
96
|
+
/** Open above the input instead of below it. */
|
|
97
|
+
openUp: boolean;
|
|
98
|
+
/** Anchor to the input's right edge instead of its left. */
|
|
99
|
+
alignRight: boolean;
|
|
100
|
+
/** Height ceiling so the list scrolls within the viewport. */
|
|
101
|
+
maxHeight: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decide where the completion popup should open so it never spills off
|
|
106
|
+
* the viewport. Pure (no DOM) so the edge logic is unit-testable:
|
|
107
|
+
*
|
|
108
|
+
* - flip **up** when there isn't room below and there's more room above;
|
|
109
|
+
* - anchor **right** when a left-anchored popup would overflow the
|
|
110
|
+
* right edge;
|
|
111
|
+
* - cap the height to the chosen side's available space (clamped to
|
|
112
|
+
* `[POPUP_MIN_HEIGHT, POPUP_MAX_HEIGHT]`).
|
|
113
|
+
*/
|
|
114
|
+
export function computePopupPlacement({
|
|
115
|
+
inputTop,
|
|
116
|
+
inputBottom,
|
|
117
|
+
inputLeft,
|
|
118
|
+
popupWidth,
|
|
119
|
+
popupHeight,
|
|
120
|
+
viewportWidth,
|
|
121
|
+
viewportHeight,
|
|
122
|
+
margin = 8,
|
|
123
|
+
}: PopupPlacementInput): PopupPlacement {
|
|
124
|
+
const spaceBelow = viewportHeight - inputBottom - margin;
|
|
125
|
+
const spaceAbove = inputTop - margin;
|
|
126
|
+
const openUp = spaceBelow < popupHeight && spaceAbove > spaceBelow;
|
|
127
|
+
const alignRight = inputLeft + popupWidth > viewportWidth - margin;
|
|
128
|
+
const available = openUp ? spaceAbove : spaceBelow;
|
|
129
|
+
const maxHeight = Math.max(
|
|
130
|
+
POPUP_MIN_HEIGHT,
|
|
131
|
+
Math.min(POPUP_MAX_HEIGHT, available),
|
|
132
|
+
);
|
|
133
|
+
return { openUp, alignRight, maxHeight };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build a minimal field-only provider from a flat `TemplateProperty`
|
|
138
|
+
* list. Fires inside an (un)closed `{{ … }}` block and inserts
|
|
139
|
+
* `{{ path }}`. This is the engine behind the `templateProperties`
|
|
140
|
+
* shorthand — `DynamicForm` / `KeyValueEditor` and other config-field
|
|
141
|
+
* surfaces pass an array and get simple reference insertion without
|
|
142
|
+
* building a grammar-aware provider.
|
|
143
|
+
*/
|
|
144
|
+
function buildSimpleProvider(
|
|
145
|
+
properties: TemplateProperty[],
|
|
146
|
+
): TemplateCompletionProvider {
|
|
147
|
+
return ({ value, cursor }) => {
|
|
148
|
+
const detected = detectTemplateContext(value, cursor);
|
|
149
|
+
if (!detected.isInTemplate) return null;
|
|
150
|
+
const query = detected.query.trim().toLowerCase();
|
|
151
|
+
const matches = properties.filter(
|
|
152
|
+
(prop) =>
|
|
153
|
+
query === "" ||
|
|
154
|
+
`${prop.templateRef ?? prop.path} ${prop.path} ${prop.description ?? ""}`
|
|
155
|
+
.toLowerCase()
|
|
156
|
+
.includes(query),
|
|
157
|
+
);
|
|
158
|
+
if (matches.length === 0) return null;
|
|
159
|
+
return {
|
|
160
|
+
heading: "Fields",
|
|
161
|
+
replaceStart: detected.startPos,
|
|
162
|
+
replaceEnd: cursor,
|
|
163
|
+
items: matches.map((prop) => ({
|
|
164
|
+
label: prop.templateRef ?? prop.path,
|
|
165
|
+
detail: prop.type,
|
|
166
|
+
description: prop.description,
|
|
167
|
+
insertText: `{{${prop.templateRef ?? prop.path}}}`,
|
|
168
|
+
})),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface TemplateValueInputProps {
|
|
174
|
+
/** DOM id forwarded to the underlying input. */
|
|
175
|
+
id?: string;
|
|
176
|
+
/** Current value. */
|
|
177
|
+
value: string;
|
|
178
|
+
/** Called whenever the value changes. */
|
|
179
|
+
onChange: (value: string) => void;
|
|
180
|
+
/** Placeholder text. */
|
|
181
|
+
placeholder?: string;
|
|
182
|
+
/**
|
|
183
|
+
* Legacy flat completion: properties offered when the user types `{{`.
|
|
184
|
+
* Each pick inserts `{{ path }}`. Ignored when `completionProvider`
|
|
185
|
+
* is supplied. Omit both to disable autocomplete entirely.
|
|
186
|
+
*/
|
|
187
|
+
templateProperties?: TemplateProperty[];
|
|
188
|
+
/**
|
|
189
|
+
* Staged, context-aware completion. Takes precedence over
|
|
190
|
+
* `templateProperties`. The provider decides when to show the popup
|
|
191
|
+
* and what to insert (fields, comparators, enum values, filters).
|
|
192
|
+
*/
|
|
193
|
+
completionProvider?: TemplateCompletionProvider;
|
|
194
|
+
/** Optional extra class on the wrapper. */
|
|
195
|
+
className?: string;
|
|
196
|
+
/** Forwarded to the input. */
|
|
197
|
+
disabled?: boolean;
|
|
198
|
+
/** Forwarded to the input. */
|
|
199
|
+
autoFocus?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Single-line input with template autocomplete.
|
|
204
|
+
*
|
|
205
|
+
* Two modes:
|
|
206
|
+
*
|
|
207
|
+
* - **Provider mode** (`completionProvider` set) — the provider is
|
|
208
|
+
* consulted on every edit and cursor move and returns a
|
|
209
|
+
* `{ items, replaceStart, replaceEnd }` result. This powers the
|
|
210
|
+
* automation editor's staged field → operator → value → filter
|
|
211
|
+
* completion.
|
|
212
|
+
* - **Legacy mode** (`templateProperties` set) — typing `{{` pops a
|
|
213
|
+
* flat list of paths; picking one inserts `{{ path }}`. Still used
|
|
214
|
+
* by simpler surfaces (e.g. the key/value editor).
|
|
215
|
+
*/
|
|
216
|
+
export const TemplateValueInput: React.FC<TemplateValueInputProps> = ({
|
|
217
|
+
id,
|
|
218
|
+
value,
|
|
219
|
+
onChange,
|
|
220
|
+
placeholder,
|
|
221
|
+
templateProperties,
|
|
222
|
+
completionProvider,
|
|
223
|
+
className,
|
|
224
|
+
disabled,
|
|
225
|
+
autoFocus,
|
|
226
|
+
}) => {
|
|
227
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
228
|
+
const popupRef = React.useRef<HTMLDivElement>(null);
|
|
229
|
+
const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
|
|
230
|
+
const [showPopup, setShowPopup] = React.useState(false);
|
|
231
|
+
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
232
|
+
const [result, setResult] = React.useState<TemplateCompletionResult | null>(
|
|
233
|
+
null,
|
|
234
|
+
);
|
|
235
|
+
// Where the popup opens relative to the input, recomputed from the
|
|
236
|
+
// live viewport so it never spills off-screen near an edge: flip above
|
|
237
|
+
// the input when there's more room there, anchor to the right edge
|
|
238
|
+
// when a left-anchored popup would overflow, and cap the height to the
|
|
239
|
+
// available space (the list scrolls within it).
|
|
240
|
+
const [placement, setPlacement] = React.useState<{
|
|
241
|
+
openUp: boolean;
|
|
242
|
+
alignRight: boolean;
|
|
243
|
+
maxHeight: number;
|
|
244
|
+
}>({ openUp: false, alignRight: false, maxHeight: 288 });
|
|
245
|
+
|
|
246
|
+
// Single completion engine: an explicit provider when supplied,
|
|
247
|
+
// otherwise a field-only provider synthesized from templateProperties.
|
|
248
|
+
// Either way the rest of the component renders one code path.
|
|
249
|
+
const provider = React.useMemo<TemplateCompletionProvider | null>(() => {
|
|
250
|
+
if (completionProvider) return completionProvider;
|
|
251
|
+
if (templateProperties && templateProperties.length > 0) {
|
|
252
|
+
return buildSimpleProvider(templateProperties);
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}, [completionProvider, templateProperties]);
|
|
256
|
+
|
|
257
|
+
const refresh = (nextValue: string, cursorPos: number) => {
|
|
258
|
+
if (!provider) {
|
|
259
|
+
setShowPopup(false);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const next = provider({ value: nextValue, cursor: cursorPos });
|
|
263
|
+
setResult(next);
|
|
264
|
+
setShowPopup(next !== null && next.items.length > 0);
|
|
265
|
+
setSelectedIndex(0);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
269
|
+
const newValue = event.target.value;
|
|
270
|
+
onChange(newValue);
|
|
271
|
+
refresh(newValue, event.target.selectionStart ?? newValue.length);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Re-evaluate when the caret moves without an edit (Left/Right arrows,
|
|
275
|
+
// clicks, focus) — the staged provider's context shifts by cursor
|
|
276
|
+
// position.
|
|
277
|
+
const handleCaretMove = () => {
|
|
278
|
+
if (!provider) return;
|
|
279
|
+
const cursorPos = inputRef.current?.selectionStart ?? value.length;
|
|
280
|
+
refresh(value, cursorPos);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Keys that drive the popup itself rather than the caret. When the
|
|
284
|
+
// popup is open these are handled in `handleKeyDown` (which calls
|
|
285
|
+
// preventDefault), so their keyup must NOT trigger a refresh — doing
|
|
286
|
+
// so would reset `selectedIndex` to 0 and make Up/Down jump back to
|
|
287
|
+
// the first row.
|
|
288
|
+
const POPUP_NAV_KEYS = new Set([
|
|
289
|
+
"ArrowUp",
|
|
290
|
+
"ArrowDown",
|
|
291
|
+
"Enter",
|
|
292
|
+
"Tab",
|
|
293
|
+
"Escape",
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
297
|
+
if (showPopup && POPUP_NAV_KEYS.has(event.key)) return;
|
|
298
|
+
handleCaretMove();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const applyItem = (item: TemplateCompletionItem) => {
|
|
302
|
+
if (!result) return;
|
|
303
|
+
const newValue =
|
|
304
|
+
value.slice(0, result.replaceStart) +
|
|
305
|
+
item.insertText +
|
|
306
|
+
value.slice(result.replaceEnd);
|
|
307
|
+
onChange(newValue);
|
|
308
|
+
const caret =
|
|
309
|
+
result.replaceStart + item.insertText.length + (item.caretOffset ?? 0);
|
|
310
|
+
setShowPopup(false);
|
|
311
|
+
setTimeout(() => {
|
|
312
|
+
inputRef.current?.focus();
|
|
313
|
+
inputRef.current?.setSelectionRange(caret, caret);
|
|
314
|
+
// Re-open with the new context so the operator flows field →
|
|
315
|
+
// operator → value without retyping a trigger character.
|
|
316
|
+
refresh(newValue, caret);
|
|
317
|
+
}, 0);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const items = result?.items ?? [];
|
|
321
|
+
|
|
322
|
+
// Bug fix: keep the popup inside the viewport. Measure on open and on
|
|
323
|
+
// resize, then decide vertical flip / horizontal anchor / max height.
|
|
324
|
+
// `useLayoutEffect` runs before paint, so the user never sees the
|
|
325
|
+
// uncapped first frame. Height/width are placement-independent, so the
|
|
326
|
+
// guarded `setPlacement` can't oscillate.
|
|
327
|
+
React.useLayoutEffect(() => {
|
|
328
|
+
if (!showPopup) return;
|
|
329
|
+
const compute = () => {
|
|
330
|
+
const inputEl = inputRef.current;
|
|
331
|
+
const popupEl = popupRef.current;
|
|
332
|
+
if (!inputEl || !popupEl) return;
|
|
333
|
+
const inputRect = inputEl.getBoundingClientRect();
|
|
334
|
+
const popupRect = popupEl.getBoundingClientRect();
|
|
335
|
+
const next = computePopupPlacement({
|
|
336
|
+
inputTop: inputRect.top,
|
|
337
|
+
inputBottom: inputRect.bottom,
|
|
338
|
+
inputLeft: inputRect.left,
|
|
339
|
+
popupWidth: popupRect.width,
|
|
340
|
+
popupHeight: popupRect.height,
|
|
341
|
+
viewportWidth: window.innerWidth,
|
|
342
|
+
viewportHeight: window.innerHeight,
|
|
343
|
+
});
|
|
344
|
+
setPlacement((prev) =>
|
|
345
|
+
prev.openUp === next.openUp &&
|
|
346
|
+
prev.alignRight === next.alignRight &&
|
|
347
|
+
prev.maxHeight === next.maxHeight
|
|
348
|
+
? prev
|
|
349
|
+
: next,
|
|
350
|
+
);
|
|
351
|
+
};
|
|
352
|
+
compute();
|
|
353
|
+
window.addEventListener("resize", compute);
|
|
354
|
+
return () => window.removeEventListener("resize", compute);
|
|
355
|
+
}, [showPopup, items.length]);
|
|
356
|
+
|
|
357
|
+
// Bug fix: keep the highlighted row visible as the operator arrows
|
|
358
|
+
// through a list taller than the popup.
|
|
359
|
+
React.useEffect(() => {
|
|
360
|
+
if (!showPopup) return;
|
|
361
|
+
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
|
362
|
+
}, [selectedIndex, showPopup]);
|
|
363
|
+
|
|
364
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
365
|
+
if (!showPopup || items.length === 0) {
|
|
366
|
+
// Let arrow keys fall through to move the caret; the provider
|
|
367
|
+
// recomputes context on the resulting caret move.
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
switch (event.key) {
|
|
371
|
+
case "ArrowDown": {
|
|
372
|
+
event.preventDefault();
|
|
373
|
+
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "ArrowUp": {
|
|
377
|
+
event.preventDefault();
|
|
378
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "Enter":
|
|
382
|
+
case "Tab": {
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
if (items[selectedIndex]) applyItem(items[selectedIndex]);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case "Escape": {
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
setShowPopup(false);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const handleBlur = () => {
|
|
396
|
+
// Delay so a click on a popup row registers before unmounting.
|
|
397
|
+
setTimeout(() => setShowPopup(false), 150);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div className={`relative flex-1 ${className ?? ""}`.trim()}>
|
|
402
|
+
<Input
|
|
403
|
+
ref={inputRef}
|
|
404
|
+
id={id}
|
|
405
|
+
value={value}
|
|
406
|
+
onChange={handleChange}
|
|
407
|
+
onKeyDown={handleKeyDown}
|
|
408
|
+
onKeyUp={handleKeyUp}
|
|
409
|
+
onClick={handleCaretMove}
|
|
410
|
+
onFocus={handleCaretMove}
|
|
411
|
+
onBlur={handleBlur}
|
|
412
|
+
placeholder={placeholder}
|
|
413
|
+
disabled={disabled}
|
|
414
|
+
autoFocus={autoFocus}
|
|
415
|
+
className="font-mono text-sm"
|
|
416
|
+
/>
|
|
417
|
+
{showPopup && items.length > 0 && (
|
|
418
|
+
<div
|
|
419
|
+
ref={popupRef}
|
|
420
|
+
style={{ maxHeight: placement.maxHeight }}
|
|
421
|
+
className={`absolute z-50 min-w-full w-max max-w-[min(32rem,calc(100vw-2rem))] overflow-y-auto rounded-md border border-border bg-popover shadow-lg ${
|
|
422
|
+
placement.openUp ? "bottom-full mb-1" : "top-full mt-1"
|
|
423
|
+
} ${placement.alignRight ? "right-0" : "left-0"}`}
|
|
424
|
+
>
|
|
425
|
+
<div className="p-1">
|
|
426
|
+
{result?.heading && (
|
|
427
|
+
<p className="px-2 pt-1 text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
|
428
|
+
{result.heading}
|
|
429
|
+
</p>
|
|
430
|
+
)}
|
|
431
|
+
{items.map((item, index) => (
|
|
432
|
+
<button
|
|
433
|
+
key={`${item.label}-${index}`}
|
|
434
|
+
ref={(el) => {
|
|
435
|
+
itemRefs.current[index] = el;
|
|
436
|
+
}}
|
|
437
|
+
type="button"
|
|
438
|
+
title={[item.label, item.detail, item.description]
|
|
439
|
+
.filter(Boolean)
|
|
440
|
+
.join(" — ")}
|
|
441
|
+
onMouseDown={(event) => {
|
|
442
|
+
event.preventDefault();
|
|
443
|
+
applyItem(item);
|
|
444
|
+
}}
|
|
445
|
+
className={`w-full flex items-center justify-between gap-3 px-2 py-1.5 text-xs rounded-sm text-left hover:bg-accent hover:text-accent-foreground ${
|
|
446
|
+
index === selectedIndex
|
|
447
|
+
? "bg-accent text-accent-foreground"
|
|
448
|
+
: ""
|
|
449
|
+
}`}
|
|
450
|
+
>
|
|
451
|
+
{/* Path takes priority and truncates with an ellipsis;
|
|
452
|
+
the type/detail (which can be a long enum union) is
|
|
453
|
+
capped and truncated too, with the full text in the
|
|
454
|
+
row's title tooltip. */}
|
|
455
|
+
<code className="font-mono truncate flex-1 min-w-0">
|
|
456
|
+
{item.label}
|
|
457
|
+
</code>
|
|
458
|
+
{item.detail && (
|
|
459
|
+
<span className="shrink truncate max-w-[55%] text-right text-muted-foreground">
|
|
460
|
+
{item.detail}
|
|
461
|
+
</span>
|
|
462
|
+
)}
|
|
463
|
+
</button>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
};
|