@checkstack/ui 0.2.4 → 0.3.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 (29) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +10 -3
  3. package/src/components/CodeEditor/CodeEditor.tsx +420 -0
  4. package/src/components/CodeEditor/index.ts +10 -0
  5. package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
  6. package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
  7. package/src/components/CodeEditor/languageSupport/index.ts +22 -0
  8. package/src/components/CodeEditor/languageSupport/json.test.ts +271 -0
  9. package/src/components/CodeEditor/languageSupport/json.ts +240 -0
  10. package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
  11. package/src/components/CodeEditor/languageSupport/markdown.ts +183 -0
  12. package/src/components/CodeEditor/languageSupport/types.ts +48 -0
  13. package/src/components/CodeEditor/languageSupport/xml.test.ts +236 -0
  14. package/src/components/CodeEditor/languageSupport/xml.ts +194 -0
  15. package/src/components/CodeEditor/languageSupport/yaml.test.ts +200 -0
  16. package/src/components/CodeEditor/languageSupport/yaml.ts +205 -0
  17. package/src/components/DynamicForm/DynamicForm.tsx +2 -24
  18. package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
  19. package/src/components/DynamicForm/FormField.tsx +38 -70
  20. package/src/components/DynamicForm/JsonField.tsx +19 -25
  21. package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
  22. package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
  23. package/src/components/DynamicForm/index.ts +13 -0
  24. package/src/components/DynamicForm/types.ts +14 -8
  25. package/src/components/DynamicForm/utils.test.ts +390 -0
  26. package/src/components/DynamicForm/utils.ts +142 -3
  27. package/src/index.ts +1 -1
  28. package/src/components/TemplateEditor.test.ts +0 -156
  29. package/src/components/TemplateEditor.tsx +0 -435
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @checkstack/ui
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 83557c7: ## CodeEditor Multi-Language Support
8
+
9
+ - **Refactored CodeEditor** into modular architecture with language-specific support
10
+ - **Added language modes**: JSON, YAML, XML, and Markdown with custom indentation and syntax highlighting
11
+ - **Smart Enter key behavior**: Bracket/tag splitting (e.g., `<div></div>` → proper split on Enter)
12
+ - **Autocomplete fix**: Enter key now correctly selects completions instead of inserting newlines
13
+ - **Click area fix**: Entire editor area is now clickable (per official CodeMirror minHeight docs)
14
+ - **Line numbers**: Now visible with proper gutter styling
15
+ - **185 comprehensive tests** for all language indentation and template position validation
16
+
17
+ - 6dbfab8: Replace react-simple-code-editor with @uiw/react-codemirror for better maintenance and features. Added new `CodeEditor` component as a reusable abstraction for code editing with syntax highlighting.
18
+
19
+ ### Patch Changes
20
+
21
+ - d316128: Add "None" option to optional Select fields in DynamicForm
22
+
23
+ **Bug Fix:**
24
+
25
+ - Optional select fields (using `x-options-resolver` or enums) now display a "None" option at the top of the dropdown
26
+ - Selecting "None" clears the field value, allowing users to unset previously selected values
27
+ - This fixes the issue where optional fields like `defaultRole` in authentication strategies could not be cleared after selection
28
+
29
+ - Updated dependencies [83557c7]
30
+ - @checkstack/common@0.4.0
31
+ - @checkstack/frontend-api@0.3.1
32
+
3
33
  ## 0.2.4
4
34
 
5
35
  ### Patch Changes
package/package.json CHANGED
@@ -1,26 +1,33 @@
1
1
  {
2
2
  "name": "@checkstack/ui",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "dependencies": {
7
7
  "@checkstack/common": "workspace:*",
8
8
  "@checkstack/frontend-api": "workspace:*",
9
+ "@codemirror/autocomplete": "^6.20.0",
10
+ "@codemirror/lang-json": "^6.0.2",
11
+ "@codemirror/lang-markdown": "^6.5.0",
12
+ "@codemirror/lang-xml": "^6.1.0",
13
+ "@codemirror/lang-yaml": "^6.1.2",
14
+ "@codemirror/language": "^6.12.1",
15
+ "@codemirror/state": "^6.5.4",
16
+ "@codemirror/view": "^6.39.11",
9
17
  "@radix-ui/react-accordion": "^1.2.12",
10
18
  "@radix-ui/react-dialog": "^1.1.15",
11
19
  "@radix-ui/react-select": "^2.2.6",
12
20
  "@radix-ui/react-slot": "^1.2.4",
21
+ "@uiw/react-codemirror": "^4.25.4",
13
22
  "ajv": "^8.17.1",
14
23
  "ajv-formats": "^3.0.1",
15
24
  "class-variance-authority": "^0.7.1",
16
25
  "clsx": "^2.1.0",
17
26
  "date-fns": "^4.1.0",
18
27
  "lucide-react": "0.562.0",
19
- "prismjs": "^1.29.0",
20
28
  "react": "^18.2.0",
21
29
  "react-markdown": "^10.1.0",
22
30
  "react-router-dom": "^6.20.0",
23
- "react-simple-code-editor": "^0.14.1",
24
31
  "recharts": "^3.6.0",
25
32
  "tailwind-merge": "^2.2.0"
26
33
  },
@@ -0,0 +1,420 @@
1
+ import React from "react";
2
+ import CodeMirror from "@uiw/react-codemirror";
3
+ import {
4
+ EditorView,
5
+ ViewPlugin,
6
+ Decoration,
7
+ keymap,
8
+ type ViewUpdate,
9
+ type DecorationSet,
10
+ } from "@codemirror/view";
11
+ import { RangeSetBuilder, Prec } from "@codemirror/state";
12
+ import {
13
+ autocompletion,
14
+ completionStatus,
15
+ type CompletionContext,
16
+ type CompletionResult,
17
+ } from "@codemirror/autocomplete";
18
+ import { indentUnit, getIndentUnit, indentString } from "@codemirror/language";
19
+ import {
20
+ jsonLanguageSupport,
21
+ yamlLanguageSupport,
22
+ xmlLanguageSupport,
23
+ markdownLanguageSupport,
24
+ isBetweenBrackets,
25
+ type LanguageSupport,
26
+ } from "./languageSupport";
27
+
28
+ export type CodeEditorLanguage = "json" | "yaml" | "xml" | "markdown";
29
+
30
+ /**
31
+ * A single payload property available for templating
32
+ */
33
+ export interface TemplateProperty {
34
+ /** Full path to the property, e.g., "payload.incident.title" */
35
+ path: string;
36
+ /** Type of the property, e.g., "string", "number", "boolean" */
37
+ type: string;
38
+ /** Optional description of the property */
39
+ description?: string;
40
+ }
41
+
42
+ export interface CodeEditorProps {
43
+ /** Unique identifier for the editor */
44
+ id?: string;
45
+ /** Current value of the editor */
46
+ value: string;
47
+ /** Callback when the value changes */
48
+ onChange: (value: string) => void;
49
+ /** Language for syntax highlighting */
50
+ language?: CodeEditorLanguage;
51
+ /** Minimum height of the editor */
52
+ minHeight?: string;
53
+ /** Whether the editor is read-only */
54
+ readOnly?: boolean;
55
+ /** Placeholder text when empty */
56
+ placeholder?: string;
57
+ /**
58
+ * Optional template properties for autocomplete.
59
+ * When provided, typing "{{" triggers autocomplete with available template variables.
60
+ */
61
+ templateProperties?: TemplateProperty[];
62
+ }
63
+
64
+ // Language support registry - add new languages here
65
+ const languageRegistry: Record<CodeEditorLanguage, LanguageSupport> = {
66
+ json: jsonLanguageSupport,
67
+ yaml: yamlLanguageSupport,
68
+ xml: xmlLanguageSupport,
69
+ markdown: markdownLanguageSupport,
70
+ };
71
+
72
+ /**
73
+ * Get display type with color info for autocomplete
74
+ */
75
+ function getTypeInfo(type: string): string {
76
+ return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();
77
+ }
78
+
79
+ /**
80
+ * Create a ViewPlugin for template-aware syntax highlighting.
81
+ * Uses the language's buildDecorations function to generate decorations.
82
+ */
83
+ function createTemplateHighlighter(languageSupport: LanguageSupport) {
84
+ return ViewPlugin.fromClass(
85
+ class {
86
+ decorations: DecorationSet;
87
+
88
+ constructor(view: EditorView) {
89
+ this.decorations = this.buildDecorations(view);
90
+ }
91
+
92
+ buildDecorations(view: EditorView): DecorationSet {
93
+ const builder = new RangeSetBuilder<Decoration>();
94
+ const doc = view.state.doc.toString();
95
+ const ranges = languageSupport.buildDecorations(doc);
96
+
97
+ for (const range of ranges) {
98
+ builder.add(range.from, range.to, range.decoration);
99
+ }
100
+
101
+ return builder.finish();
102
+ }
103
+
104
+ update(update: ViewUpdate) {
105
+ if (update.docChanged || update.viewportChanged) {
106
+ this.decorations = this.buildDecorations(update.view);
107
+ }
108
+ }
109
+ },
110
+ {
111
+ decorations: (v) => v.decorations,
112
+ },
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Create a CodeMirror autocomplete extension for template properties.
118
+ * Triggers when user types "{{" and offers completions from templateProperties.
119
+ */
120
+ function createTemplateAutocomplete(
121
+ templateProperties: TemplateProperty[],
122
+ languageSupport: LanguageSupport,
123
+ ): ReturnType<typeof autocompletion> {
124
+ return autocompletion({
125
+ override: [
126
+ (context: CompletionContext): CompletionResult | null => {
127
+ // Look for {{ pattern before cursor
128
+ const textBefore = context.state.sliceDoc(0, context.pos);
129
+ const recentText = context.state.sliceDoc(
130
+ Math.max(0, context.pos - 50),
131
+ context.pos,
132
+ );
133
+
134
+ // Find the last {{ that doesn't have a matching }}
135
+ const lastOpenBrace = recentText.lastIndexOf("{{");
136
+ const lastCloseBrace = recentText.lastIndexOf("}}");
137
+
138
+ if (lastOpenBrace === -1 || lastOpenBrace < lastCloseBrace) {
139
+ // eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
140
+ return null;
141
+ }
142
+
143
+ // Validate position based on text BEFORE the {{ started
144
+ // We exclude the {{ from validation because it confuses most parsers
145
+ const positionOfTemplateStart =
146
+ context.pos - recentText.length + lastOpenBrace;
147
+ const textBeforeTemplate = textBefore.slice(0, positionOfTemplateStart);
148
+ if (!languageSupport.isValidTemplatePosition(textBeforeTemplate)) {
149
+ // eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
150
+ return null;
151
+ }
152
+
153
+ // Calculate the position in the document where {{ starts
154
+ const startOffset = context.pos - recentText.length + lastOpenBrace;
155
+ const query = recentText.slice(lastOpenBrace + 2).toLowerCase();
156
+
157
+ // Check for auto-closed braces after cursor position
158
+ // When user types {{ with bracket auto-close, it becomes {{}}
159
+ // We need to consume any trailing }} when completing
160
+ const textAfter = context.state.sliceDoc(context.pos, context.pos + 4);
161
+ let endOffset = context.pos;
162
+ if (textAfter.startsWith("}}")) {
163
+ endOffset += 2;
164
+ } else if (textAfter.startsWith("}")) {
165
+ // Just one } from first auto-close
166
+ endOffset += 1;
167
+ }
168
+
169
+ // Filter properties based on query
170
+ const filtered = templateProperties.filter((prop) =>
171
+ prop.path.toLowerCase().includes(query),
172
+ );
173
+
174
+ if (filtered.length === 0 && query.length > 0) {
175
+ // eslint-disable-next-line unicorn/no-null -- CodeMirror API requires null
176
+ return null;
177
+ }
178
+
179
+ return {
180
+ from: startOffset,
181
+ to: endOffset,
182
+ options: filtered.map((prop) => ({
183
+ label: `{{${prop.path}}}`,
184
+ displayLabel: prop.path,
185
+ type: "variable",
186
+ detail: getTypeInfo(prop.type),
187
+ info: prop.description,
188
+ boost: prop.path.toLowerCase().startsWith(query) ? 1 : 0,
189
+ })),
190
+ validFor: /^\{\{[\w.]*$/,
191
+ };
192
+ },
193
+ ],
194
+ activateOnTyping: true,
195
+ icons: false,
196
+ });
197
+ }
198
+
199
+ /**
200
+ * A code editor component with syntax highlighting and optional template autocomplete.
201
+ * Wraps @uiw/react-codemirror for consistent styling and API across the platform.
202
+ */
203
+ export const CodeEditor: React.FC<CodeEditorProps> = ({
204
+ id,
205
+ value,
206
+ onChange,
207
+ language = "json",
208
+ minHeight = "100px",
209
+ readOnly = false,
210
+ placeholder,
211
+ templateProperties,
212
+ }) => {
213
+ const extensions = React.useMemo(() => {
214
+ const languageSupport = languageRegistry[language];
215
+ const hasTemplates = templateProperties && templateProperties.length > 0;
216
+
217
+ const exts = [
218
+ EditorView.lineWrapping,
219
+ EditorView.theme({
220
+ "&": {
221
+ fontSize: "14px",
222
+ fontFamily: "ui-monospace, monospace",
223
+ backgroundColor: "transparent",
224
+ },
225
+ ".cm-scroller": {
226
+ backgroundColor: "transparent",
227
+ overflow: "auto",
228
+ },
229
+ // minHeight must be on .cm-content and .cm-gutter, not the wrapper (per CM docs)
230
+ ".cm-content, .cm-gutter": {
231
+ minHeight: minHeight,
232
+ },
233
+ ".cm-content": {
234
+ padding: "10px",
235
+ },
236
+ // Cursor/caret styling
237
+ ".cm-cursor, .cm-dropCursor": {
238
+ borderLeftColor: "hsl(var(--foreground))",
239
+ borderLeftWidth: "2px",
240
+ },
241
+ ".cm-line": {
242
+ color: "hsl(var(--foreground))",
243
+ },
244
+ ".cm-gutters": {
245
+ backgroundColor: "transparent",
246
+ color: "hsl(var(--muted-foreground))",
247
+ border: "none",
248
+ },
249
+ "&.cm-focused": {
250
+ outline: "none",
251
+ },
252
+ // JSON syntax highlighting for dark/light mode
253
+ ".cm-string": {
254
+ color: "hsl(142.1, 76.2%, 36.3%)",
255
+ },
256
+ ".cm-number": {
257
+ color: "hsl(217.2, 91.2%, 59.8%)",
258
+ },
259
+ ".cm-propertyName": {
260
+ color: "hsl(280, 65%, 60%)",
261
+ },
262
+ ".cm-keyword": {
263
+ color: "hsl(280, 65%, 60%)",
264
+ },
265
+ ".cm-punctuation": {
266
+ color: "hsl(var(--muted-foreground))",
267
+ },
268
+ // Placeholder styling
269
+ ".cm-placeholder": {
270
+ color: "hsl(var(--muted-foreground))",
271
+ },
272
+ // JSON syntax highlighting via decorations (overrides Lezer parser)
273
+ ".cm-json-property": {
274
+ color: "hsl(280, 65%, 60%)",
275
+ },
276
+ ".cm-json-string": {
277
+ color: "hsl(142.1, 76.2%, 36.3%)",
278
+ },
279
+ ".cm-json-number": {
280
+ color: "hsl(217.2, 91.2%, 59.8%)",
281
+ },
282
+ ".cm-json-keyword": {
283
+ color: "hsl(280, 65%, 60%)",
284
+ },
285
+ // Template expression highlighting
286
+ ".cm-template-expression": {
287
+ color: "hsl(190, 70%, 50%)",
288
+ fontWeight: "500",
289
+ },
290
+ // Style for autocomplete popup
291
+ ".cm-tooltip.cm-tooltip-autocomplete": {
292
+ backgroundColor: "hsl(var(--popover))",
293
+ border: "1px solid hsl(var(--border))",
294
+ borderRadius: "0.375rem",
295
+ boxShadow:
296
+ "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
297
+ },
298
+ ".cm-tooltip-autocomplete ul": {
299
+ fontFamily: "ui-monospace, monospace",
300
+ fontSize: "0.75rem",
301
+ },
302
+ ".cm-tooltip-autocomplete ul li": {
303
+ padding: "0.25rem 0.5rem",
304
+ },
305
+ ".cm-tooltip-autocomplete ul li[aria-selected]": {
306
+ backgroundColor: "hsl(var(--accent))",
307
+ color: "hsl(var(--accent-foreground))",
308
+ },
309
+ ".cm-completionLabel": {
310
+ fontFamily: "ui-monospace, monospace",
311
+ },
312
+ ".cm-completionDetail": {
313
+ marginLeft: "0.5rem",
314
+ fontStyle: "normal",
315
+ color: "hsl(var(--muted-foreground))",
316
+ },
317
+ }),
318
+ ];
319
+
320
+ // Always add language extension for features (indentation, bracket matching, etc.)
321
+ if (languageSupport) {
322
+ exts.push(languageSupport.extension);
323
+
324
+ // Create custom Enter key handler that applies our indentation
325
+ // This is needed because the language parsers may get confused by templates
326
+ // or may not provide the indentation we want
327
+ const customEnterKeymap = keymap.of([
328
+ {
329
+ key: "Enter",
330
+ run: (view) => {
331
+ const state = view.state;
332
+
333
+ // If autocomplete is active, let it handle Enter for selection
334
+ if (completionStatus(state) === "active") {
335
+ return false;
336
+ }
337
+
338
+ const pos = state.selection.main.head;
339
+ const textBefore = state.sliceDoc(0, pos);
340
+ const textAfter = state.sliceDoc(pos);
341
+ const unit = getIndentUnit(state);
342
+ const indent = languageSupport.calculateIndentation(
343
+ textBefore + "\n",
344
+ unit,
345
+ );
346
+ const indentStr = indentString(state, indent);
347
+
348
+ // Check if we're between matching brackets/tags
349
+ // This handles cases like: {|}, [|], <tag>|</tag>
350
+ if (isBetweenBrackets(textBefore, textAfter)) {
351
+ // Split: add newline with indent, then newline with previous indent for closing
352
+ const prevIndent = Math.max(0, indent - unit);
353
+ const prevIndentStr = indentString(state, prevIndent);
354
+
355
+ view.dispatch({
356
+ changes: {
357
+ from: pos,
358
+ to: pos,
359
+ insert: "\n" + indentStr + "\n" + prevIndentStr,
360
+ },
361
+ selection: { anchor: pos + 1 + indentStr.length },
362
+ scrollIntoView: true,
363
+ userEvent: "input",
364
+ });
365
+ } else {
366
+ // Normal: insert newline with calculated indentation
367
+ view.dispatch({
368
+ changes: { from: pos, to: pos, insert: "\n" + indentStr },
369
+ selection: { anchor: pos + 1 + indentStr.length },
370
+ scrollIntoView: true,
371
+ userEvent: "input",
372
+ });
373
+ }
374
+ return true;
375
+ },
376
+ },
377
+ ]);
378
+
379
+ // Always add indentation support and custom highlighter for consistent behavior
380
+ // Prec.highest ensures our colors take precedence over language parser output
381
+ exts.push(
382
+ indentUnit.of(" "), // Configure 2-space indentation
383
+ Prec.highest(customEnterKeymap), // Override default Enter behavior
384
+ Prec.highest(createTemplateHighlighter(languageSupport)), // Consistent syntax colors
385
+ );
386
+
387
+ // Add template autocomplete if properties provided
388
+ if (hasTemplates) {
389
+ exts.push(
390
+ createTemplateAutocomplete(templateProperties, languageSupport),
391
+ );
392
+ }
393
+ }
394
+
395
+ return exts;
396
+ }, [language, templateProperties, minHeight]);
397
+
398
+ return (
399
+ <div
400
+ id={id}
401
+ className="w-full rounded-md border border-input bg-background font-mono text-sm focus-within:ring-2 focus-within:ring-ring focus-within:border-transparent transition-all box-border"
402
+ >
403
+ <CodeMirror
404
+ value={value}
405
+ onChange={onChange}
406
+ extensions={extensions}
407
+ editable={!readOnly}
408
+ placeholder={placeholder}
409
+ basicSetup={{
410
+ lineNumbers: true,
411
+ foldGutter: false,
412
+ highlightActiveLine: false,
413
+ highlightSelectionMatches: false,
414
+ autocompletion: false, // We use our own
415
+ }}
416
+ theme="none"
417
+ />
418
+ </div>
419
+ );
420
+ };
@@ -0,0 +1,10 @@
1
+ export {
2
+ CodeEditor,
3
+ type CodeEditorProps,
4
+ type CodeEditorLanguage,
5
+ type TemplateProperty,
6
+ } from "./CodeEditor";
7
+
8
+ // Re-export language support for testing and extensibility
9
+ export { isValidJsonTemplatePosition } from "./languageSupport";
10
+ export type { LanguageSupport, DecorationRange } from "./languageSupport";
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { isBetweenBrackets } from "./enterBehavior";
4
+
5
+ describe("isBetweenBrackets", () => {
6
+ // ============================================================================
7
+ // Curly braces
8
+ // ============================================================================
9
+ describe("curly braces", () => {
10
+ it("returns true for empty object {|}", () => {
11
+ expect(isBetweenBrackets("{", "}")).toBe(true);
12
+ });
13
+
14
+ it("returns false when content before closing brace", () => {
15
+ // Last char is "1", not "{"
16
+ expect(isBetweenBrackets('{"a": 1', "}")).toBe(false);
17
+ });
18
+
19
+ it("returns false when only opening brace before", () => {
20
+ expect(isBetweenBrackets("{", "x")).toBe(false);
21
+ });
22
+
23
+ it("returns false when only closing brace after", () => {
24
+ expect(isBetweenBrackets("x", "}")).toBe(false);
25
+ });
26
+
27
+ it("returns false for nested opening {|{", () => {
28
+ expect(isBetweenBrackets("{", "{")).toBe(false);
29
+ });
30
+ });
31
+
32
+ // ============================================================================
33
+ // Square brackets
34
+ // ============================================================================
35
+ describe("square brackets", () => {
36
+ it("returns true for empty array [|]", () => {
37
+ expect(isBetweenBrackets("[", "]")).toBe(true);
38
+ });
39
+
40
+ it("returns false when content before closing bracket", () => {
41
+ // Last char is "2", not "["
42
+ expect(isBetweenBrackets("[1, 2", "]")).toBe(false);
43
+ });
44
+
45
+ it("returns false when only opening bracket before", () => {
46
+ expect(isBetweenBrackets("[", "x")).toBe(false);
47
+ });
48
+
49
+ it("returns false when only closing bracket after", () => {
50
+ expect(isBetweenBrackets("x", "]")).toBe(false);
51
+ });
52
+
53
+ it("returns false for nested opening [|[", () => {
54
+ expect(isBetweenBrackets("[", "[")).toBe(false);
55
+ });
56
+ });
57
+
58
+ // ============================================================================
59
+ // XML/HTML tags
60
+ // ============================================================================
61
+ describe("XML/HTML tags", () => {
62
+ it("returns true for empty tag <div>|</div>", () => {
63
+ expect(isBetweenBrackets("<div>", "</div>")).toBe(true);
64
+ });
65
+
66
+ it('returns true for tag with attributes <div class="x">|</div>', () => {
67
+ expect(isBetweenBrackets('<div class="x">', "</div>")).toBe(true);
68
+ });
69
+
70
+ it("returns true for nested tags <outer><inner>|</inner>", () => {
71
+ expect(isBetweenBrackets("<outer><inner>", "</inner>")).toBe(true);
72
+ });
73
+
74
+ it("returns true for self-named closing tag <a>|</a>", () => {
75
+ expect(isBetweenBrackets("<a>", "</a>")).toBe(true);
76
+ });
77
+
78
+ it("returns false for adjacent opening tags <a>|<b>", () => {
79
+ expect(isBetweenBrackets("<a>", "<b>")).toBe(false);
80
+ });
81
+
82
+ it("returns false for text after tag <div>|text", () => {
83
+ expect(isBetweenBrackets("<div>", "text")).toBe(false);
84
+ });
85
+
86
+ it("returns false for self-closing tag <br/>|", () => {
87
+ expect(isBetweenBrackets("<br/>", "")).toBe(false);
88
+ });
89
+
90
+ it("returns false for comment after tag <div>|<!--", () => {
91
+ expect(isBetweenBrackets("<div>", "<!--")).toBe(false);
92
+ });
93
+ });
94
+
95
+ // ============================================================================
96
+ // Edge cases
97
+ // ============================================================================
98
+ describe("edge cases", () => {
99
+ it("returns false for empty strings", () => {
100
+ expect(isBetweenBrackets("", "")).toBe(false);
101
+ });
102
+
103
+ it("returns false for whitespace only", () => {
104
+ expect(isBetweenBrackets(" ", " ")).toBe(false);
105
+ });
106
+
107
+ it("returns false for newlines", () => {
108
+ expect(isBetweenBrackets("{\n", "\n}")).toBe(false);
109
+ });
110
+
111
+ it("returns true for template braces {{|}}", () => {
112
+ // { followed by } - pattern matches even though it's part of template
113
+ expect(isBetweenBrackets("{{", "}}")).toBe(true);
114
+ });
115
+
116
+ it("handles single character inputs", () => {
117
+ expect(isBetweenBrackets("a", "b")).toBe(false);
118
+ });
119
+ });
120
+
121
+ // ============================================================================
122
+ // Real-world scenarios
123
+ // ============================================================================
124
+ describe("real-world scenarios", () => {
125
+ it("JSON: empty object literal", () => {
126
+ expect(isBetweenBrackets('{"name": ', "}")).toBe(false); // Not at the boundary
127
+ });
128
+
129
+ it("JSON: cursor right after opening brace", () => {
130
+ expect(isBetweenBrackets("{", ' "name": "value"}')).toBe(false); // Has content
131
+ });
132
+
133
+ it("XML: HTML doctype before tag", () => {
134
+ expect(isBetweenBrackets("<!DOCTYPE html><html>", "</html>")).toBe(true);
135
+ });
136
+
137
+ it("XML: after text content in tag", () => {
138
+ expect(isBetweenBrackets("<p>Hello", "</p>")).toBe(false); // "o" != ">"
139
+ });
140
+
141
+ it("YAML: no brackets involved", () => {
142
+ expect(isBetweenBrackets("key:", "")).toBe(false);
143
+ });
144
+ });
145
+
146
+ // ============================================================================
147
+ // Regression tests
148
+ // ============================================================================
149
+ describe("regression: autocomplete interaction", () => {
150
+ /**
151
+ * REGRESSION TEST (documented, requires DOM testing)
152
+ *
153
+ * Issue: When autocomplete popup is showing, pressing Enter should select
154
+ * the completion item, NOT insert a newline.
155
+ *
156
+ * Fix: In CodeEditor.tsx, the custom Enter keymap checks `completionStatus(state)`
157
+ * and returns `false` when autocomplete is "active", allowing the autocomplete
158
+ * extension to handle the Enter key.
159
+ *
160
+ * This cannot be unit tested without a full CodeMirror DOM integration.
161
+ * Manual verification steps:
162
+ * 1. Type "{{" in the editor with template properties configured
163
+ * 2. Autocomplete popup should appear
164
+ * 3. Press Enter to select a template
165
+ * 4. Template should be inserted (NOT a newline)
166
+ */
167
+ it("documents the autocomplete Enter key requirement", () => {
168
+ // This test exists purely as documentation
169
+ // The actual fix is in CodeEditor.tsx: completionStatus(state) check
170
+ expect(true).toBe(true);
171
+ });
172
+ });
173
+ });