@checkstack/ui 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.storybook/main.ts +43 -0
  2. package/CHANGELOG.md +565 -0
  3. package/package.json +15 -7
  4. package/scripts/generate-stdlib-types.ts +25 -2
  5. package/src/components/ActionCard.tsx +309 -0
  6. package/src/components/CodeEditor/CodeEditor.tsx +132 -9
  7. package/src/components/CodeEditor/TypefoxEditor.tsx +1024 -0
  8. package/src/components/CodeEditor/bracketKeyGroups.test.ts +120 -0
  9. package/src/components/CodeEditor/bracketKeyGroups.ts +205 -0
  10. package/src/components/CodeEditor/generateTypeDefinitions.ts +4 -4
  11. package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
  12. package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
  13. package/src/components/CodeEditor/importSpecifiers.ts +267 -0
  14. package/src/components/CodeEditor/index.ts +26 -0
  15. package/src/components/CodeEditor/monacoTsService.ts +217 -0
  16. package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
  17. package/src/components/CodeEditor/popoutTitle.ts +31 -0
  18. package/src/components/CodeEditor/scriptContext.test.ts +41 -0
  19. package/src/components/CodeEditor/scriptContext.ts +76 -1
  20. package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
  21. package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
  22. package/src/components/CodeEditor/templateValidation.ts +51 -0
  23. package/src/components/CodeEditor/types.ts +168 -0
  24. package/src/components/CodeEditor/validateJsonTemplate.test.ts +61 -0
  25. package/src/components/CodeEditor/validateJsonTemplate.ts +26 -0
  26. package/src/components/CodeEditor/validateScripts.ts +132 -0
  27. package/src/components/CodeEditor/validateXmlTemplate.test.ts +34 -0
  28. package/src/components/CodeEditor/validateXmlTemplate.ts +35 -0
  29. package/src/components/CodeEditor/validateYamlTemplate.test.ts +39 -0
  30. package/src/components/CodeEditor/validateYamlTemplate.ts +28 -0
  31. package/src/components/Dialog.tsx +32 -11
  32. package/src/components/DurationInput.tsx +121 -0
  33. package/src/components/DynamicForm/DynamicForm.tsx +27 -1
  34. package/src/components/DynamicForm/FormField.tsx +138 -10
  35. package/src/components/DynamicForm/KeyValueEditor.tsx +2 -169
  36. package/src/components/DynamicForm/MultiTypeEditorField.tsx +83 -9
  37. package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
  38. package/src/components/DynamicForm/index.ts +6 -0
  39. package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
  40. package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
  41. package/src/components/DynamicForm/types.ts +83 -1
  42. package/src/components/DynamicForm/utils.ts +32 -0
  43. package/src/components/Popover.tsx +6 -1
  44. package/src/components/ScriptTestPanel.logic.test.ts +139 -0
  45. package/src/components/ScriptTestPanel.logic.ts +137 -0
  46. package/src/components/ScriptTestPanel.tsx +394 -0
  47. package/src/components/Sheet.tsx +21 -6
  48. package/src/components/TemplateInput.tsx +104 -0
  49. package/src/components/TemplateInputToggle.tsx +111 -0
  50. package/src/components/TemplateValueInput.test.ts +98 -0
  51. package/src/components/TemplateValueInput.tsx +470 -0
  52. package/src/components/TimeOfDayInput.tsx +116 -0
  53. package/src/components/VariablePicker.tsx +271 -0
  54. package/src/components/comboboxInteraction.ts +39 -0
  55. package/src/components/portalContainer.ts +24 -0
  56. package/src/hooks/useInitOnceForKey.test.ts +27 -0
  57. package/src/hooks/useInitOnceForKey.ts +21 -18
  58. package/src/index.ts +9 -0
  59. package/stories/ActionCard.stories.tsx +122 -0
  60. package/stories/Alert.stories.tsx +5 -5
  61. package/stories/CodeEditor.stories.tsx +47 -2
  62. package/stories/DurationInput.stories.tsx +59 -0
  63. package/stories/ScriptTestPanel.stories.tsx +106 -0
  64. package/stories/SecretEnvEditor.stories.tsx +80 -0
  65. package/stories/TemplateInputToggle.stories.tsx +77 -0
  66. package/stories/TemplateValueInput.stories.tsx +65 -0
  67. package/stories/TimeOfDayInput.stories.tsx +34 -0
  68. package/stories/VariablePicker.stories.tsx +109 -0
  69. package/tsconfig.json +1 -0
  70. package/src/components/CodeEditor/MonacoEditor.tsx +0 -616
  71. package/src/components/CodeEditor/monacoStdlib.ts +0 -62
  72. package/src/components/CodeEditor/monacoWorkers.ts +0 -118
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { computePopupPlacement } from "./TemplateValueInput";
3
+
4
+ /**
5
+ * Edge-aware placement for the template autocomplete popup. The popup is
6
+ * absolutely positioned under the input by default; these guard the
7
+ * flips that keep it inside the viewport (regression for the "dropdown
8
+ * overflows the window edge" bug).
9
+ */
10
+ describe("computePopupPlacement", () => {
11
+ // A roomy 1000x800 viewport with the input near the top-left.
12
+ const base = {
13
+ inputTop: 100,
14
+ inputBottom: 130,
15
+ inputLeft: 50,
16
+ popupWidth: 300,
17
+ popupHeight: 200,
18
+ viewportWidth: 1000,
19
+ viewportHeight: 800,
20
+ };
21
+
22
+ it("opens down + left when there's ample room", () => {
23
+ const placement = computePopupPlacement(base);
24
+ expect(placement.openUp).toBe(false);
25
+ expect(placement.alignRight).toBe(false);
26
+ });
27
+
28
+ it("caps the height to the space below when below is tight", () => {
29
+ // 220px below, but the popup wants 200 — fits, so stays down and the
30
+ // ceiling is the available space (220 - 8 margin = 212).
31
+ const placement = computePopupPlacement({
32
+ ...base,
33
+ inputBottom: 580,
34
+ popupHeight: 200,
35
+ });
36
+ expect(placement.openUp).toBe(false);
37
+ expect(placement.maxHeight).toBe(212);
38
+ });
39
+
40
+ it("flips up when there's not enough room below and more above", () => {
41
+ // Input low in the viewport: only ~120px below, ~660px above.
42
+ const placement = computePopupPlacement({
43
+ ...base,
44
+ inputTop: 660,
45
+ inputBottom: 690,
46
+ popupHeight: 300,
47
+ });
48
+ expect(placement.openUp).toBe(true);
49
+ // Height capped to the 288 ceiling since there's plenty of room above.
50
+ expect(placement.maxHeight).toBe(288);
51
+ });
52
+
53
+ it("stays down when below is tight but above is even tighter", () => {
54
+ // Tiny viewport where neither side fits the popup; below (larger)
55
+ // wins, so it must not flip up.
56
+ const placement = computePopupPlacement({
57
+ ...base,
58
+ inputTop: 40,
59
+ inputBottom: 70,
60
+ viewportHeight: 200,
61
+ popupHeight: 300,
62
+ });
63
+ // spaceBelow = 200-70-8 = 122, spaceAbove = 40-8 = 32 → stay down.
64
+ expect(placement.openUp).toBe(false);
65
+ expect(placement.maxHeight).toBe(122);
66
+ });
67
+
68
+ it("anchors right when a left-anchored popup would overflow", () => {
69
+ // Input near the right edge: left(800) + width(300) = 1100 > 1000.
70
+ const placement = computePopupPlacement({
71
+ ...base,
72
+ inputLeft: 800,
73
+ });
74
+ expect(placement.alignRight).toBe(true);
75
+ });
76
+
77
+ it("keeps left anchor when the popup fits horizontally", () => {
78
+ const placement = computePopupPlacement({
79
+ ...base,
80
+ inputLeft: 600,
81
+ popupWidth: 300,
82
+ });
83
+ // 600 + 300 = 900 < 1000 - 8 → fits.
84
+ expect(placement.alignRight).toBe(false);
85
+ });
86
+
87
+ it("never reports a height below the usable floor", () => {
88
+ // Sandwiched input with almost no room either side.
89
+ const placement = computePopupPlacement({
90
+ ...base,
91
+ inputTop: 90,
92
+ inputBottom: 110,
93
+ viewportHeight: 150,
94
+ popupHeight: 300,
95
+ });
96
+ expect(placement.maxHeight).toBe(120);
97
+ });
98
+ });
@@ -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
+ };
@@ -0,0 +1,116 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Clock } from "lucide-react";
3
+
4
+ const padZero = (value: number): string => String(value).padStart(2, "0");
5
+ const filterNumeric = (value: string): string => value.replaceAll(/[^0-9]/g, "");
6
+
7
+ export interface TimeOfDayInputProps {
8
+ /** `"HH:mm"` (24h) or undefined when unset. */
9
+ value: string | undefined;
10
+ onChange: (next: string | undefined) => void;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ className?: string;
14
+ }
15
+
16
+ /** Split a `"HH:mm"` string into its fields, tolerating partial input. */
17
+ function split(value: string | undefined): { hour: string; minute: string } {
18
+ if (!value) return { hour: "", minute: "" };
19
+ const [h = "", m = ""] = value.split(":");
20
+ return { hour: h, minute: m };
21
+ }
22
+
23
+ /**
24
+ * 24h time-of-day (HH:MM) input. Two numeric fields mirroring the time
25
+ * section of {@link DateTimePicker}. Emits a `"HH:mm"` string (or
26
+ * undefined when either field is empty), the exact shape the automation
27
+ * `time` condition's `after` / `before` accept - so it round-trips
28
+ * losslessly through YAML.
29
+ *
30
+ * Plain inputs, no animations - no `usePerformance` gating needed.
31
+ */
32
+ export const TimeOfDayInput: React.FC<TimeOfDayInputProps> = ({
33
+ value,
34
+ onChange,
35
+ disabled,
36
+ id,
37
+ className,
38
+ }) => {
39
+ const [fields, setFields] = useState(() => split(value));
40
+ const isInternalChange = React.useRef(false);
41
+
42
+ // Sync from external value changes (not our own edits).
43
+ useEffect(() => {
44
+ if (isInternalChange.current) {
45
+ isInternalChange.current = false;
46
+ return;
47
+ }
48
+ setFields(split(value));
49
+ }, [value]);
50
+
51
+ const emit = (next: { hour: string; minute: string }) => {
52
+ isInternalChange.current = true;
53
+ const cleared: string | undefined = undefined;
54
+ if (next.hour === "" || next.minute === "") {
55
+ onChange(cleared);
56
+ return;
57
+ }
58
+ onChange(`${padZero(Number(next.hour))}:${padZero(Number(next.minute))}`);
59
+ };
60
+
61
+ const handleChange = (field: "hour" | "minute", raw: string) => {
62
+ const filtered = filterNumeric(raw).slice(0, 2);
63
+ const next = { ...fields, [field]: filtered };
64
+ setFields(next);
65
+ emit(next);
66
+ };
67
+
68
+ const handleBlur = (field: "hour" | "minute") => {
69
+ const raw = fields[field];
70
+ if (raw === "") return;
71
+ const n = Number(raw);
72
+ const max = field === "hour" ? 23 : 59;
73
+ const clamped = padZero(Math.min(Math.max(n, 0), max));
74
+ if (clamped !== raw) {
75
+ const next = { ...fields, [field]: clamped };
76
+ setFields(next);
77
+ emit(next);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div
83
+ className={`inline-flex items-center border rounded-lg bg-background px-2 py-1 ${
84
+ className ?? ""
85
+ }`}
86
+ >
87
+ <Clock className="h-4 w-4 text-muted-foreground mr-2" />
88
+ <input
89
+ id={id}
90
+ type="text"
91
+ inputMode="numeric"
92
+ value={fields.hour}
93
+ onChange={(event) => handleChange("hour", event.target.value)}
94
+ onBlur={() => handleBlur("hour")}
95
+ placeholder="HH"
96
+ aria-label="Hour"
97
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
98
+ maxLength={2}
99
+ disabled={disabled}
100
+ />
101
+ <span className="text-muted-foreground">:</span>
102
+ <input
103
+ type="text"
104
+ inputMode="numeric"
105
+ value={fields.minute}
106
+ onChange={(event) => handleChange("minute", event.target.value)}
107
+ onBlur={() => handleBlur("minute")}
108
+ placeholder="MM"
109
+ aria-label="Minute"
110
+ className="w-7 text-center bg-transparent border-none outline-none text-sm font-mono disabled:opacity-50"
111
+ maxLength={2}
112
+ disabled={disabled}
113
+ />
114
+ </div>
115
+ );
116
+ };