@checkstack/ui 0.2.4 → 0.3.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/CHANGELOG.md +38 -0
- package/package.json +10 -3
- package/src/components/CodeEditor/CodeEditor.tsx +420 -0
- package/src/components/CodeEditor/index.ts +10 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
- package/src/components/CodeEditor/languageSupport/index.ts +22 -0
- package/src/components/CodeEditor/languageSupport/json-utils.ts +117 -0
- package/src/components/CodeEditor/languageSupport/json.test.ts +274 -0
- package/src/components/CodeEditor/languageSupport/json.ts +139 -0
- package/src/components/CodeEditor/languageSupport/markdown-utils.ts +65 -0
- package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
- package/src/components/CodeEditor/languageSupport/markdown.ts +134 -0
- package/src/components/CodeEditor/languageSupport/types.ts +48 -0
- package/src/components/CodeEditor/languageSupport/xml-utils.ts +94 -0
- package/src/components/CodeEditor/languageSupport/xml.test.ts +239 -0
- package/src/components/CodeEditor/languageSupport/xml.ts +116 -0
- package/src/components/CodeEditor/languageSupport/yaml-utils.ts +101 -0
- package/src/components/CodeEditor/languageSupport/yaml.test.ts +203 -0
- package/src/components/CodeEditor/languageSupport/yaml.ts +120 -0
- package/src/components/DynamicForm/DynamicForm.tsx +2 -24
- package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
- package/src/components/DynamicForm/FormField.tsx +38 -70
- package/src/components/DynamicForm/JsonField.tsx +19 -25
- package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
- package/src/components/DynamicForm/index.ts +13 -0
- package/src/components/DynamicForm/types.ts +14 -8
- package/src/components/DynamicForm/utils.test.ts +390 -0
- package/src/components/DynamicForm/utils.ts +142 -3
- package/src/index.ts +1 -1
- package/src/components/TemplateEditor.test.ts +0 -156
- package/src/components/TemplateEditor.tsx +0 -435
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { detectTemplateContext } from "./TemplateEditor";
|
|
3
|
-
|
|
4
|
-
describe("detectTemplateContext", () => {
|
|
5
|
-
describe("mustache syntax ({{}})", () => {
|
|
6
|
-
it("should detect context when cursor is immediately after {{", () => {
|
|
7
|
-
const result = detectTemplateContext("Hello {{", 8, "mustache");
|
|
8
|
-
expect(result).toEqual({
|
|
9
|
-
isInTemplate: true,
|
|
10
|
-
query: "",
|
|
11
|
-
startPos: 6,
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("should detect context with partial query", () => {
|
|
16
|
-
const result = detectTemplateContext("Hello {{pay", 11, "mustache");
|
|
17
|
-
expect(result).toEqual({
|
|
18
|
-
isInTemplate: true,
|
|
19
|
-
query: "pay",
|
|
20
|
-
startPos: 6,
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("should detect context with full path being typed", () => {
|
|
25
|
-
const result = detectTemplateContext(
|
|
26
|
-
"Hello {{payload.title",
|
|
27
|
-
21,
|
|
28
|
-
"mustache"
|
|
29
|
-
);
|
|
30
|
-
expect(result).toEqual({
|
|
31
|
-
isInTemplate: true,
|
|
32
|
-
query: "payload.title",
|
|
33
|
-
startPos: 6,
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("should not detect context when template is closed", () => {
|
|
38
|
-
const result = detectTemplateContext(
|
|
39
|
-
"Hello {{name}} world",
|
|
40
|
-
20,
|
|
41
|
-
"mustache"
|
|
42
|
-
);
|
|
43
|
-
expect(result).toEqual({
|
|
44
|
-
isInTemplate: false,
|
|
45
|
-
query: "",
|
|
46
|
-
startPos: -1,
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("should not detect context when cursor is before {{", () => {
|
|
51
|
-
const result = detectTemplateContext("Hello {{name}}", 3, "mustache");
|
|
52
|
-
expect(result).toEqual({
|
|
53
|
-
isInTemplate: false,
|
|
54
|
-
query: "",
|
|
55
|
-
startPos: -1,
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("should detect context in second template when first is closed", () => {
|
|
60
|
-
const result = detectTemplateContext(
|
|
61
|
-
"{{first}} and {{second",
|
|
62
|
-
22,
|
|
63
|
-
"mustache"
|
|
64
|
-
);
|
|
65
|
-
expect(result).toEqual({
|
|
66
|
-
isInTemplate: true,
|
|
67
|
-
query: "second",
|
|
68
|
-
startPos: 14,
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("should not detect context when query contains newline", () => {
|
|
73
|
-
const result = detectTemplateContext("Hello {{\nworld", 14, "mustache");
|
|
74
|
-
expect(result).toEqual({
|
|
75
|
-
isInTemplate: false,
|
|
76
|
-
query: "",
|
|
77
|
-
startPos: -1,
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("should handle empty string", () => {
|
|
82
|
-
const result = detectTemplateContext("", 0, "mustache");
|
|
83
|
-
expect(result).toEqual({
|
|
84
|
-
isInTemplate: false,
|
|
85
|
-
query: "",
|
|
86
|
-
startPos: -1,
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("should handle text without any templates", () => {
|
|
91
|
-
const result = detectTemplateContext("Hello world", 11, "mustache");
|
|
92
|
-
expect(result).toEqual({
|
|
93
|
-
isInTemplate: false,
|
|
94
|
-
query: "",
|
|
95
|
-
startPos: -1,
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("dollar syntax (${})", () => {
|
|
101
|
-
it("should detect context when cursor is immediately after ${", () => {
|
|
102
|
-
const result = detectTemplateContext("Hello ${", 8, "dollar");
|
|
103
|
-
expect(result).toEqual({
|
|
104
|
-
isInTemplate: true,
|
|
105
|
-
query: "",
|
|
106
|
-
startPos: 6,
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("should detect context with partial query", () => {
|
|
111
|
-
const result = detectTemplateContext("Hello ${user", 12, "dollar");
|
|
112
|
-
expect(result).toEqual({
|
|
113
|
-
isInTemplate: true,
|
|
114
|
-
query: "user",
|
|
115
|
-
startPos: 6,
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("should not detect context when template is closed", () => {
|
|
120
|
-
const result = detectTemplateContext("Hello ${name} world", 19, "dollar");
|
|
121
|
-
expect(result).toEqual({
|
|
122
|
-
isInTemplate: false,
|
|
123
|
-
query: "",
|
|
124
|
-
startPos: -1,
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("edge cases", () => {
|
|
130
|
-
it("should handle nested braces correctly", () => {
|
|
131
|
-
const result = detectTemplateContext("{{outer.{{inner", 15, "mustache");
|
|
132
|
-
// Should find the last {{ which is at position 8
|
|
133
|
-
expect(result.isInTemplate).toBe(true);
|
|
134
|
-
expect(result.startPos).toBe(8);
|
|
135
|
-
expect(result.query).toBe("inner");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("should handle cursor at start of template", () => {
|
|
139
|
-
const result = detectTemplateContext("{{", 2, "mustache");
|
|
140
|
-
expect(result).toEqual({
|
|
141
|
-
isInTemplate: true,
|
|
142
|
-
query: "",
|
|
143
|
-
startPos: 0,
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("should detect context after multiple closed templates", () => {
|
|
148
|
-
const result = detectTemplateContext("{{a}} {{b}} {{c", 15, "mustache");
|
|
149
|
-
expect(result).toEqual({
|
|
150
|
-
isInTemplate: true,
|
|
151
|
-
query: "c",
|
|
152
|
-
startPos: 12,
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
});
|
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import { cn } from "../utils";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A single payload property available for templating
|
|
6
|
-
*/
|
|
7
|
-
export interface TemplateProperty {
|
|
8
|
-
/** Full path to the property, e.g., "payload.incident.title" */
|
|
9
|
-
path: string;
|
|
10
|
-
/** Type of the property, e.g., "string", "number", "boolean" */
|
|
11
|
-
type: string;
|
|
12
|
-
/** Optional description of the property */
|
|
13
|
-
description?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface TemplateEditorProps {
|
|
17
|
-
/** Current template value */
|
|
18
|
-
value: string;
|
|
19
|
-
/** Change handler */
|
|
20
|
-
onChange: (value: string) => void;
|
|
21
|
-
/** Available properties for hints */
|
|
22
|
-
availableProperties?: TemplateProperty[];
|
|
23
|
-
/** Placeholder text */
|
|
24
|
-
placeholder?: string;
|
|
25
|
-
/** Number of rows */
|
|
26
|
-
rows?: number;
|
|
27
|
-
/** Additional class name */
|
|
28
|
-
className?: string;
|
|
29
|
-
/** Disabled state */
|
|
30
|
-
disabled?: boolean;
|
|
31
|
-
/** Template syntax hint, e.g., "{{" */
|
|
32
|
-
templateSyntax?: "mustache" | "dollar";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Get display type color based on property type
|
|
37
|
-
*/
|
|
38
|
-
const getTypeColor = (type: string) => {
|
|
39
|
-
switch (type.toLowerCase()) {
|
|
40
|
-
case "string": {
|
|
41
|
-
return "text-green-600 dark:text-green-400";
|
|
42
|
-
}
|
|
43
|
-
case "number":
|
|
44
|
-
case "integer": {
|
|
45
|
-
return "text-blue-600 dark:text-blue-400";
|
|
46
|
-
}
|
|
47
|
-
case "boolean": {
|
|
48
|
-
return "text-purple-600 dark:text-purple-400";
|
|
49
|
-
}
|
|
50
|
-
case "array": {
|
|
51
|
-
return "text-orange-600 dark:text-orange-400";
|
|
52
|
-
}
|
|
53
|
-
case "object": {
|
|
54
|
-
return "text-yellow-600 dark:text-yellow-400";
|
|
55
|
-
}
|
|
56
|
-
default: {
|
|
57
|
-
return "text-muted-foreground";
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Detects if the cursor is inside an unclosed template context (after {{ without matching }})
|
|
64
|
-
* Returns the query text being typed and the position where {{ starts
|
|
65
|
-
*/
|
|
66
|
-
export const detectTemplateContext = (
|
|
67
|
-
value: string,
|
|
68
|
-
cursorPos: number,
|
|
69
|
-
syntax: "mustache" | "dollar" = "mustache"
|
|
70
|
-
): { isInTemplate: boolean; query: string; startPos: number } => {
|
|
71
|
-
const openToken = syntax === "mustache" ? "{{" : "${";
|
|
72
|
-
const closeToken = syntax === "mustache" ? "}}" : "}";
|
|
73
|
-
|
|
74
|
-
const textBeforeCursor = value.slice(0, cursorPos);
|
|
75
|
-
const lastOpenBrace = textBeforeCursor.lastIndexOf(openToken);
|
|
76
|
-
const lastCloseBrace = textBeforeCursor.lastIndexOf(closeToken);
|
|
77
|
-
|
|
78
|
-
// If we're after {{ and no }} follows before cursor, we're in a template context
|
|
79
|
-
if (lastOpenBrace !== -1 && lastOpenBrace > lastCloseBrace) {
|
|
80
|
-
const query = textBeforeCursor.slice(lastOpenBrace + openToken.length);
|
|
81
|
-
// Only show popup if query doesn't contain newlines (single line context)
|
|
82
|
-
if (!query.includes("\n")) {
|
|
83
|
-
return { isInTemplate: true, query, startPos: lastOpenBrace };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return { isInTemplate: false, query: "", startPos: -1 };
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Filter properties based on search query (fuzzy match on path)
|
|
92
|
-
*/
|
|
93
|
-
const filterProperties = (
|
|
94
|
-
properties: TemplateProperty[],
|
|
95
|
-
query: string
|
|
96
|
-
): TemplateProperty[] => {
|
|
97
|
-
if (!query.trim()) return properties;
|
|
98
|
-
const lowerQuery = query.toLowerCase();
|
|
99
|
-
return properties.filter((prop) =>
|
|
100
|
-
prop.path.toLowerCase().includes(lowerQuery)
|
|
101
|
-
);
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* TemplateEditor - A textarea with autocomplete for template properties.
|
|
106
|
-
*
|
|
107
|
-
* Shows an autocomplete popup when the user types "{{" with available
|
|
108
|
-
* template properties. Supports keyboard navigation and filtering.
|
|
109
|
-
*/
|
|
110
|
-
export const TemplateEditor = React.forwardRef<
|
|
111
|
-
HTMLTextAreaElement,
|
|
112
|
-
TemplateEditorProps
|
|
113
|
-
>(
|
|
114
|
-
(
|
|
115
|
-
{
|
|
116
|
-
value,
|
|
117
|
-
onChange,
|
|
118
|
-
availableProperties = [],
|
|
119
|
-
placeholder = "Enter template...",
|
|
120
|
-
rows = 4,
|
|
121
|
-
className,
|
|
122
|
-
disabled = false,
|
|
123
|
-
templateSyntax = "mustache",
|
|
124
|
-
},
|
|
125
|
-
ref
|
|
126
|
-
) => {
|
|
127
|
-
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
128
|
-
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
129
|
-
const popupRef = React.useRef<HTMLDivElement | null>(null);
|
|
130
|
-
|
|
131
|
-
// Autocomplete state
|
|
132
|
-
const [showPopup, setShowPopup] = React.useState(false);
|
|
133
|
-
const [popupPosition, setPopupPosition] = React.useState({
|
|
134
|
-
top: 0,
|
|
135
|
-
left: 0,
|
|
136
|
-
});
|
|
137
|
-
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
138
|
-
const [templateContext, setTemplateContext] = React.useState<{
|
|
139
|
-
query: string;
|
|
140
|
-
startPos: number;
|
|
141
|
-
}>({ query: "", startPos: -1 });
|
|
142
|
-
|
|
143
|
-
// Filtered properties based on query
|
|
144
|
-
const filteredProperties = React.useMemo(
|
|
145
|
-
() => filterProperties(availableProperties, templateContext.query),
|
|
146
|
-
[availableProperties, templateContext.query]
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
// Merge refs
|
|
150
|
-
React.useImperativeHandle(ref, () => textareaRef.current!);
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Calculate popup position based on cursor location in textarea
|
|
154
|
-
*/
|
|
155
|
-
const calculatePopupPosition = React.useCallback(() => {
|
|
156
|
-
const textarea = textareaRef.current;
|
|
157
|
-
const container = containerRef.current;
|
|
158
|
-
if (!textarea || !container) return;
|
|
159
|
-
|
|
160
|
-
// Get textarea position relative to container
|
|
161
|
-
const textareaRect = textarea.getBoundingClientRect();
|
|
162
|
-
const containerRect = container.getBoundingClientRect();
|
|
163
|
-
|
|
164
|
-
// Simple positioning: below the textarea with fixed offset
|
|
165
|
-
// For more accurate cursor-based positioning, we'd need to measure text
|
|
166
|
-
const lineHeight =
|
|
167
|
-
Number.parseInt(getComputedStyle(textarea).lineHeight) || 20;
|
|
168
|
-
const paddingTop =
|
|
169
|
-
Number.parseInt(getComputedStyle(textarea).paddingTop) || 8;
|
|
170
|
-
|
|
171
|
-
// Count newlines before cursor to estimate vertical position
|
|
172
|
-
const cursorPos = textarea.selectionStart ?? 0;
|
|
173
|
-
const textBeforeCursor = value.slice(0, cursorPos);
|
|
174
|
-
const lines = textBeforeCursor.split("\n");
|
|
175
|
-
const currentLineIndex = lines.length - 1;
|
|
176
|
-
|
|
177
|
-
// Position popup below current line
|
|
178
|
-
const top =
|
|
179
|
-
textareaRect.top -
|
|
180
|
-
containerRect.top +
|
|
181
|
-
paddingTop +
|
|
182
|
-
(currentLineIndex + 1) * lineHeight;
|
|
183
|
-
|
|
184
|
-
// Estimate horizontal position based on current line length
|
|
185
|
-
const currentLine = lines[currentLineIndex] ?? "";
|
|
186
|
-
const charWidth = 8; // Approximate monospace char width
|
|
187
|
-
const paddingLeft =
|
|
188
|
-
Number.parseInt(getComputedStyle(textarea).paddingLeft) || 12;
|
|
189
|
-
const left = Math.min(
|
|
190
|
-
paddingLeft + currentLine.length * charWidth,
|
|
191
|
-
textareaRect.width - 200 // Keep popup within textarea bounds
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
setPopupPosition({
|
|
195
|
-
top: Math.max(top, lineHeight),
|
|
196
|
-
left: Math.max(left, 0),
|
|
197
|
-
});
|
|
198
|
-
}, [value]);
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Handle text input and detect template context
|
|
202
|
-
*/
|
|
203
|
-
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
204
|
-
const newValue = e.target.value;
|
|
205
|
-
onChange(newValue);
|
|
206
|
-
|
|
207
|
-
// Check template context after state update
|
|
208
|
-
setTimeout(() => {
|
|
209
|
-
const textarea = textareaRef.current;
|
|
210
|
-
if (!textarea) return;
|
|
211
|
-
|
|
212
|
-
const cursorPos = textarea.selectionStart ?? 0;
|
|
213
|
-
const context = detectTemplateContext(
|
|
214
|
-
newValue,
|
|
215
|
-
cursorPos,
|
|
216
|
-
templateSyntax
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
if (context.isInTemplate && availableProperties.length > 0) {
|
|
220
|
-
setTemplateContext({
|
|
221
|
-
query: context.query,
|
|
222
|
-
startPos: context.startPos,
|
|
223
|
-
});
|
|
224
|
-
setShowPopup(true);
|
|
225
|
-
setSelectedIndex(0);
|
|
226
|
-
calculatePopupPosition();
|
|
227
|
-
} else {
|
|
228
|
-
setShowPopup(false);
|
|
229
|
-
}
|
|
230
|
-
}, 0);
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Handle cursor position changes (click, arrow keys in text)
|
|
235
|
-
*/
|
|
236
|
-
const handleSelect = () => {
|
|
237
|
-
const textarea = textareaRef.current;
|
|
238
|
-
if (!textarea || disabled) return;
|
|
239
|
-
|
|
240
|
-
const cursorPos = textarea.selectionStart ?? 0;
|
|
241
|
-
const context = detectTemplateContext(value, cursorPos, templateSyntax);
|
|
242
|
-
|
|
243
|
-
if (context.isInTemplate && availableProperties.length > 0) {
|
|
244
|
-
setTemplateContext({
|
|
245
|
-
query: context.query,
|
|
246
|
-
startPos: context.startPos,
|
|
247
|
-
});
|
|
248
|
-
setShowPopup(true);
|
|
249
|
-
calculatePopupPosition();
|
|
250
|
-
} else {
|
|
251
|
-
setShowPopup(false);
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Insert selected property at the template context position
|
|
257
|
-
*/
|
|
258
|
-
const insertProperty = React.useCallback(
|
|
259
|
-
(prop: TemplateProperty) => {
|
|
260
|
-
const textarea = textareaRef.current;
|
|
261
|
-
if (!textarea || disabled || templateContext.startPos === -1) return;
|
|
262
|
-
|
|
263
|
-
const openToken = templateSyntax === "mustache" ? "{{" : "${";
|
|
264
|
-
const closeToken = templateSyntax === "mustache" ? "}}" : "}";
|
|
265
|
-
const template = `${openToken}${prop.path}${closeToken}`;
|
|
266
|
-
|
|
267
|
-
// Replace from {{ to cursor with the complete template
|
|
268
|
-
const cursorPos = textarea.selectionStart ?? 0;
|
|
269
|
-
const newValue =
|
|
270
|
-
value.slice(0, templateContext.startPos) +
|
|
271
|
-
template +
|
|
272
|
-
value.slice(cursorPos);
|
|
273
|
-
|
|
274
|
-
onChange(newValue);
|
|
275
|
-
setShowPopup(false);
|
|
276
|
-
|
|
277
|
-
// Restore focus and move cursor after inserted text
|
|
278
|
-
const newPosition = templateContext.startPos + template.length;
|
|
279
|
-
setTimeout(() => {
|
|
280
|
-
textarea.focus();
|
|
281
|
-
textarea.setSelectionRange(newPosition, newPosition);
|
|
282
|
-
}, 0);
|
|
283
|
-
},
|
|
284
|
-
[value, onChange, templateContext.startPos, templateSyntax, disabled]
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Handle keyboard navigation in popup
|
|
289
|
-
*/
|
|
290
|
-
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
291
|
-
if (!showPopup || filteredProperties.length === 0) return;
|
|
292
|
-
|
|
293
|
-
switch (e.key) {
|
|
294
|
-
case "ArrowDown": {
|
|
295
|
-
e.preventDefault();
|
|
296
|
-
setSelectedIndex((prev) =>
|
|
297
|
-
prev < filteredProperties.length - 1 ? prev + 1 : 0
|
|
298
|
-
);
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
case "ArrowUp": {
|
|
302
|
-
e.preventDefault();
|
|
303
|
-
setSelectedIndex((prev) =>
|
|
304
|
-
prev > 0 ? prev - 1 : filteredProperties.length - 1
|
|
305
|
-
);
|
|
306
|
-
break;
|
|
307
|
-
}
|
|
308
|
-
case "Enter":
|
|
309
|
-
case "Tab": {
|
|
310
|
-
e.preventDefault();
|
|
311
|
-
const selected = filteredProperties[selectedIndex];
|
|
312
|
-
if (selected) {
|
|
313
|
-
insertProperty(selected);
|
|
314
|
-
}
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
case "Escape": {
|
|
318
|
-
e.preventDefault();
|
|
319
|
-
setShowPopup(false);
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Close popup when clicking outside
|
|
327
|
-
*/
|
|
328
|
-
React.useEffect(() => {
|
|
329
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
330
|
-
if (
|
|
331
|
-
popupRef.current &&
|
|
332
|
-
!popupRef.current.contains(event.target as Node) &&
|
|
333
|
-
textareaRef.current &&
|
|
334
|
-
!textareaRef.current.contains(event.target as Node)
|
|
335
|
-
) {
|
|
336
|
-
setShowPopup(false);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
if (showPopup) {
|
|
341
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
342
|
-
return () =>
|
|
343
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
344
|
-
}
|
|
345
|
-
}, [showPopup]);
|
|
346
|
-
|
|
347
|
-
// Reset selected index when filtered properties change
|
|
348
|
-
React.useEffect(() => {
|
|
349
|
-
setSelectedIndex(0);
|
|
350
|
-
}, [filteredProperties.length]);
|
|
351
|
-
|
|
352
|
-
// Scroll selected item into view
|
|
353
|
-
React.useEffect(() => {
|
|
354
|
-
if (!showPopup || !popupRef.current) return;
|
|
355
|
-
const selectedElement = popupRef.current.querySelector(
|
|
356
|
-
`[data-index="${selectedIndex}"]`
|
|
357
|
-
);
|
|
358
|
-
selectedElement?.scrollIntoView({ block: "nearest" });
|
|
359
|
-
}, [selectedIndex, showPopup]);
|
|
360
|
-
|
|
361
|
-
return (
|
|
362
|
-
<div ref={containerRef} className={cn("relative", className)}>
|
|
363
|
-
{/* Textarea */}
|
|
364
|
-
<textarea
|
|
365
|
-
ref={textareaRef}
|
|
366
|
-
value={value}
|
|
367
|
-
onChange={handleChange}
|
|
368
|
-
onSelect={handleSelect}
|
|
369
|
-
onKeyDown={handleKeyDown}
|
|
370
|
-
placeholder={placeholder}
|
|
371
|
-
rows={rows}
|
|
372
|
-
disabled={disabled}
|
|
373
|
-
className={cn(
|
|
374
|
-
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono",
|
|
375
|
-
"placeholder:text-muted-foreground",
|
|
376
|
-
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
|
|
377
|
-
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
378
|
-
"resize-y min-h-[80px]"
|
|
379
|
-
)}
|
|
380
|
-
/>
|
|
381
|
-
|
|
382
|
-
{/* Autocomplete popup */}
|
|
383
|
-
{showPopup && filteredProperties.length > 0 && (
|
|
384
|
-
<div
|
|
385
|
-
ref={popupRef}
|
|
386
|
-
className={cn(
|
|
387
|
-
"absolute z-50 w-72 max-h-48 overflow-y-auto",
|
|
388
|
-
"rounded-md border border-border bg-popover shadow-lg"
|
|
389
|
-
)}
|
|
390
|
-
style={{ top: popupPosition.top, left: popupPosition.left }}
|
|
391
|
-
>
|
|
392
|
-
<div className="p-1">
|
|
393
|
-
{filteredProperties.map((prop, index) => (
|
|
394
|
-
<button
|
|
395
|
-
key={prop.path}
|
|
396
|
-
type="button"
|
|
397
|
-
data-index={index}
|
|
398
|
-
onClick={() => insertProperty(prop)}
|
|
399
|
-
className={cn(
|
|
400
|
-
"w-full flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-sm text-left",
|
|
401
|
-
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
|
402
|
-
index === selectedIndex &&
|
|
403
|
-
"bg-accent text-accent-foreground"
|
|
404
|
-
)}
|
|
405
|
-
>
|
|
406
|
-
<code className="font-mono truncate">{prop.path}</code>
|
|
407
|
-
<span
|
|
408
|
-
className={cn(
|
|
409
|
-
"font-normal shrink-0",
|
|
410
|
-
getTypeColor(prop.type)
|
|
411
|
-
)}
|
|
412
|
-
>
|
|
413
|
-
{prop.type}
|
|
414
|
-
</span>
|
|
415
|
-
</button>
|
|
416
|
-
))}
|
|
417
|
-
</div>
|
|
418
|
-
</div>
|
|
419
|
-
)}
|
|
420
|
-
|
|
421
|
-
{/* Hint text when no popup shown */}
|
|
422
|
-
{availableProperties.length > 0 && !showPopup && (
|
|
423
|
-
<p className="text-xs text-muted-foreground mt-1.5">
|
|
424
|
-
Type{" "}
|
|
425
|
-
<code className="px-1 py-0.5 bg-muted rounded text-[10px]">
|
|
426
|
-
{templateSyntax === "mustache" ? "{{" : "${"}
|
|
427
|
-
</code>{" "}
|
|
428
|
-
for template suggestions
|
|
429
|
-
</p>
|
|
430
|
-
)}
|
|
431
|
-
</div>
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
);
|
|
435
|
-
TemplateEditor.displayName = "TemplateEditor";
|