@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,64 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AlertCircle } from "lucide-react";
|
|
3
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
4
|
+
import {
|
|
5
|
+
Alert,
|
|
6
|
+
AlertContent,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
AlertIcon,
|
|
9
|
+
AlertTitle,
|
|
10
|
+
} from "./Alert";
|
|
11
|
+
import { Button } from "./Button";
|
|
12
|
+
|
|
13
|
+
interface QueryErrorStateProps {
|
|
14
|
+
/**
|
|
15
|
+
* The error captured from a failed query (e.g. TanStack Query's `error`).
|
|
16
|
+
* Funnelled through {@link extractErrorMessage} so callers don't have to
|
|
17
|
+
* narrow the type at every call site.
|
|
18
|
+
*/
|
|
19
|
+
error: unknown;
|
|
20
|
+
/**
|
|
21
|
+
* Invoked when the user clicks the "Retry" button. Wire this to the
|
|
22
|
+
* underlying `refetch()` of the failing query.
|
|
23
|
+
*/
|
|
24
|
+
onRetry: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Optional resource name to personalise the headline, e.g.
|
|
27
|
+
* `resource="checks"` -> "Could not load checks".
|
|
28
|
+
*/
|
|
29
|
+
resource?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* QueryErrorState - canonical inline error UI for failed list / detail
|
|
34
|
+
* queries. Renders an `error`-variant {@link Alert} with the extracted
|
|
35
|
+
* error message and a Retry button.
|
|
36
|
+
*/
|
|
37
|
+
export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
|
|
38
|
+
error,
|
|
39
|
+
onRetry,
|
|
40
|
+
resource,
|
|
41
|
+
}) => {
|
|
42
|
+
const message = extractErrorMessage(error);
|
|
43
|
+
const title = resource ? `Could not load ${resource}` : "Something went wrong";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Alert variant="error">
|
|
47
|
+
<AlertIcon>
|
|
48
|
+
<AlertCircle className="h-4 w-4" />
|
|
49
|
+
</AlertIcon>
|
|
50
|
+
<AlertContent>
|
|
51
|
+
<AlertTitle>{title}</AlertTitle>
|
|
52
|
+
<AlertDescription>{message}</AlertDescription>
|
|
53
|
+
</AlertContent>
|
|
54
|
+
<Button
|
|
55
|
+
variant="outline"
|
|
56
|
+
size="sm"
|
|
57
|
+
onClick={onRetry}
|
|
58
|
+
className="shrink-0"
|
|
59
|
+
>
|
|
60
|
+
Retry
|
|
61
|
+
</Button>
|
|
62
|
+
</Alert>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResponsiveTable - dual-layout primitive for tabular data that must
|
|
3
|
+
* degrade gracefully on narrow viewports.
|
|
4
|
+
*
|
|
5
|
+
* # API decision
|
|
6
|
+
*
|
|
7
|
+
* The original plan considered a context-driven `priority` prop on a
|
|
8
|
+
* special `ResponsiveTableHead` so cells could declare which columns
|
|
9
|
+
* disappear on mobile. Implementing that without `any` requires either
|
|
10
|
+
* (a) cloning every `TableCell` child to inject a context-derived
|
|
11
|
+
* `data-priority` attribute, or (b) maintaining a parallel index of
|
|
12
|
+
* `TableHead` children to wire their priorities into the cells by
|
|
13
|
+
* position. Both shapes leak the matching responsibility into the
|
|
14
|
+
* primitive and produce gnarly typings around the `Table*` re-exports.
|
|
15
|
+
*
|
|
16
|
+
* Instead this file ships the simpler, fully type-safe fallback:
|
|
17
|
+
*
|
|
18
|
+
* - `<ResponsiveTable>` - a wrapper that renders its children inside
|
|
19
|
+
* the standard {@link Table} layout on `sm` viewports and up, and
|
|
20
|
+
* hides them on smaller screens.
|
|
21
|
+
* - `<MobileCardList>` - a sibling wrapper consumers render alongside
|
|
22
|
+
* the table. It is only visible below `sm`, so callers compose the
|
|
23
|
+
* two side-by-side and decide per-row what the mobile presentation
|
|
24
|
+
* looks like (typically a stacked card with high-priority fields).
|
|
25
|
+
*
|
|
26
|
+
* The two wrappers use Tailwind's `hidden sm:block` / `sm:hidden`
|
|
27
|
+
* utilities, so they swap purely in CSS - no JS media-query gating, no
|
|
28
|
+
* SSR/CSR mismatch risk, and consumers keep full control over which
|
|
29
|
+
* fields surface on mobile.
|
|
30
|
+
*
|
|
31
|
+
* Re-export the standard `Table*` primitives from `@checkstack/ui` for
|
|
32
|
+
* the desktop branch; do NOT use `<table>` markup inside
|
|
33
|
+
* `<MobileCardList>`.
|
|
34
|
+
*/
|
|
35
|
+
import React from "react";
|
|
36
|
+
import { cn } from "../utils";
|
|
37
|
+
|
|
38
|
+
interface ResponsiveTableProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
39
|
+
/**
|
|
40
|
+
* The desktop tabular layout. Compose with the existing `Table`,
|
|
41
|
+
* `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`
|
|
42
|
+
* primitives.
|
|
43
|
+
*/
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Desktop branch of the dual-layout pattern. Hidden below the `sm`
|
|
49
|
+
* breakpoint; render a {@link MobileCardList} alongside it to cover
|
|
50
|
+
* narrow viewports.
|
|
51
|
+
*/
|
|
52
|
+
export const ResponsiveTable = React.forwardRef<
|
|
53
|
+
HTMLDivElement,
|
|
54
|
+
ResponsiveTableProps
|
|
55
|
+
>(({ children, className, ...props }, ref) => (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
className={cn("hidden sm:block", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
ResponsiveTable.displayName = "ResponsiveTable";
|
|
66
|
+
|
|
67
|
+
interface MobileCardListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
68
|
+
/**
|
|
69
|
+
* The stacked, card-shaped layout for narrow viewports. One item per
|
|
70
|
+
* row; consumers decide which fields are surfaced.
|
|
71
|
+
*/
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mobile branch of the dual-layout pattern. Visible only below the `sm`
|
|
77
|
+
* breakpoint. Pairs with {@link ResponsiveTable}.
|
|
78
|
+
*/
|
|
79
|
+
export const MobileCardList = React.forwardRef<
|
|
80
|
+
HTMLDivElement,
|
|
81
|
+
MobileCardListProps
|
|
82
|
+
>(({ children, className, ...props }, ref) => (
|
|
83
|
+
<div
|
|
84
|
+
ref={ref}
|
|
85
|
+
className={cn("flex flex-col gap-2 sm:hidden", className)}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
));
|
|
91
|
+
|
|
92
|
+
MobileCardList.displayName = "MobileCardList";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils";
|
|
3
|
+
import { usePerformance } from "./PerformanceProvider";
|
|
4
|
+
|
|
5
|
+
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/**
|
|
7
|
+
* Override the default sizing / shape. The component already renders a
|
|
8
|
+
* muted background; pass dimensions via classes like `h-4 w-32 rounded`.
|
|
9
|
+
*/
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Skeleton - a pulsing placeholder block for loading states.
|
|
15
|
+
*
|
|
16
|
+
* Honours {@link usePerformance}: when `isLowPower` is true the pulse
|
|
17
|
+
* animation is dropped, leaving a static `bg-muted` block so low-power
|
|
18
|
+
* devices aren't forced through an infinite animation loop.
|
|
19
|
+
*/
|
|
20
|
+
export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
|
21
|
+
({ className, ...props }, ref) => {
|
|
22
|
+
const { isLowPower } = usePerformance();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={ref}
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
className={cn(
|
|
29
|
+
"rounded-md bg-muted",
|
|
30
|
+
!isLowPower && "animate-pulse",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
Skeleton.displayName = "Skeleton";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { CodeEditor, type TemplateProperty } from "./CodeEditor";
|
|
3
|
+
import { TemplateValueInput } from "./TemplateValueInput";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Edit mode for a `TemplateInput`. Drives both the rendered widget and
|
|
7
|
+
* the language passed to the underlying Monaco editor in code modes.
|
|
8
|
+
*
|
|
9
|
+
* - `text` — single-line `<Input>` with `{{` autocomplete
|
|
10
|
+
* (`TemplateValueInput`). Cheapest, no Monaco bundle.
|
|
11
|
+
* - `code` — full Monaco TypeScript editor. Use for inline scripts.
|
|
12
|
+
* - `bash` — Monaco shell editor with optional `shellEnvVars`.
|
|
13
|
+
* - `json` / `yaml` — Monaco editor in the matching language. Use for
|
|
14
|
+
* structured config bodies that may interpolate `{{ }}`.
|
|
15
|
+
*/
|
|
16
|
+
export type TemplateInputMode = "text" | "code" | "bash" | "json" | "yaml";
|
|
17
|
+
|
|
18
|
+
export interface TemplateInputProps {
|
|
19
|
+
id?: string;
|
|
20
|
+
value: string;
|
|
21
|
+
onChange: (value: string) => void;
|
|
22
|
+
mode?: TemplateInputMode;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Template properties surfaced after the user types `{{`. Required for
|
|
26
|
+
* the picker to do anything; omit to disable autocomplete entirely.
|
|
27
|
+
*/
|
|
28
|
+
templateProperties?: TemplateProperty[];
|
|
29
|
+
/** Optional TS declarations injected into Monaco (code mode only). */
|
|
30
|
+
typeDefinitions?: string;
|
|
31
|
+
/** Optional shell env-var hints (bash mode only). */
|
|
32
|
+
shellEnvVars?: Array<{ name: string; description?: string; example?: string }>;
|
|
33
|
+
/** Min height for code-mode editors. */
|
|
34
|
+
minHeight?: string;
|
|
35
|
+
/** Forwarded to the input/editor. */
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* High-level template editor. Picks the right underlying widget for the
|
|
42
|
+
* given `mode` and forwards the standard template-property + type-decl
|
|
43
|
+
* props through to it.
|
|
44
|
+
*
|
|
45
|
+
* - `mode === "text"` (the default) renders a single-line
|
|
46
|
+
* `TemplateValueInput` — the same picker the key/value editor uses.
|
|
47
|
+
* - Any code mode delegates to `CodeEditor`, which already runs the
|
|
48
|
+
* same `{{` autocomplete inside Monaco plus the shell `$` env-var
|
|
49
|
+
* completion provider.
|
|
50
|
+
*
|
|
51
|
+
* Wrapping both in one component lets the automation editor render a
|
|
52
|
+
* single field that switches editor flavour as the user changes the
|
|
53
|
+
* action's `x-editor-types` annotation, without each action having to
|
|
54
|
+
* choose its own widget.
|
|
55
|
+
*/
|
|
56
|
+
export const TemplateInput: React.FC<TemplateInputProps> = ({
|
|
57
|
+
id,
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
mode = "text",
|
|
61
|
+
placeholder,
|
|
62
|
+
templateProperties,
|
|
63
|
+
typeDefinitions,
|
|
64
|
+
shellEnvVars,
|
|
65
|
+
minHeight,
|
|
66
|
+
disabled,
|
|
67
|
+
className,
|
|
68
|
+
}) => {
|
|
69
|
+
if (mode === "text") {
|
|
70
|
+
return (
|
|
71
|
+
<TemplateValueInput
|
|
72
|
+
id={id}
|
|
73
|
+
value={value}
|
|
74
|
+
onChange={onChange}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
templateProperties={templateProperties}
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
className={className}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const language =
|
|
84
|
+
mode === "code"
|
|
85
|
+
? "typescript"
|
|
86
|
+
: mode === "bash"
|
|
87
|
+
? "shell"
|
|
88
|
+
: mode;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<CodeEditor
|
|
92
|
+
id={id}
|
|
93
|
+
value={value}
|
|
94
|
+
onChange={onChange}
|
|
95
|
+
language={language}
|
|
96
|
+
templateProperties={templateProperties}
|
|
97
|
+
typeDefinitions={typeDefinitions}
|
|
98
|
+
shellEnvVars={shellEnvVars}
|
|
99
|
+
minHeight={minHeight}
|
|
100
|
+
placeholder={placeholder}
|
|
101
|
+
readOnly={disabled}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Code2, X } from "lucide-react";
|
|
3
|
+
import { TemplateValueInput } from "./TemplateValueInput";
|
|
4
|
+
import type { TemplateProperty } from "./CodeEditor";
|
|
5
|
+
|
|
6
|
+
export interface TemplateInputToggleProps {
|
|
7
|
+
/**
|
|
8
|
+
* The typed editor to render when the user has NOT switched to
|
|
9
|
+
* template mode — typically a `<Input type="number">`, `<Select>`,
|
|
10
|
+
* `<DateTimePicker>`, etc.
|
|
11
|
+
*
|
|
12
|
+
* Render-prop instead of a `ReactNode` so the toggle can wire focus /
|
|
13
|
+
* disabled state through without imposing a shape on the caller.
|
|
14
|
+
*/
|
|
15
|
+
renderTyped: (props: { disabled: boolean }) => React.ReactNode;
|
|
16
|
+
/** Current value (always a string — typed editors serialise to one). */
|
|
17
|
+
value: string;
|
|
18
|
+
/** Called whenever the value changes, regardless of mode. */
|
|
19
|
+
onChange: (value: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the toggle starts in template mode. The toggle is
|
|
22
|
+
* uncontrolled by default; pass `templateMode` + `onTemplateModeChange`
|
|
23
|
+
* to control it externally.
|
|
24
|
+
*/
|
|
25
|
+
defaultTemplateMode?: boolean;
|
|
26
|
+
templateMode?: boolean;
|
|
27
|
+
onTemplateModeChange?: (templateMode: boolean) => void;
|
|
28
|
+
/** Template properties for the picker (template mode only). */
|
|
29
|
+
templateProperties?: TemplateProperty[];
|
|
30
|
+
/** Placeholder shown in template mode. */
|
|
31
|
+
templatePlaceholder?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a typed input with a small "fx" button that flips the field
|
|
38
|
+
* into template-input mode. Used in the automation editor wherever an
|
|
39
|
+
* action config field accepts either a typed literal _or_ a template
|
|
40
|
+
* — number-of-seconds, dropdown choices, dates — so the operator can
|
|
41
|
+
* say "this is dynamic at runtime" without changing the schema.
|
|
42
|
+
*
|
|
43
|
+
* Behaviour:
|
|
44
|
+
*
|
|
45
|
+
* - Default mode renders `renderTyped()`.
|
|
46
|
+
* - Clicking the "fx" pill switches to a `TemplateValueInput` and
|
|
47
|
+
* focuses it. The pill turns into an "X" button.
|
|
48
|
+
* - Clicking the "X" switches back. The value is preserved across
|
|
49
|
+
* toggles — the operator only loses it if they explicitly clear
|
|
50
|
+
* the field.
|
|
51
|
+
*
|
|
52
|
+
* If the current value starts with `{{`, the toggle infers template
|
|
53
|
+
* mode automatically when uncontrolled. This handles round-tripping
|
|
54
|
+
* a previously-saved automation that interpolated this field.
|
|
55
|
+
*/
|
|
56
|
+
export const TemplateInputToggle: React.FC<TemplateInputToggleProps> = ({
|
|
57
|
+
renderTyped,
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
defaultTemplateMode,
|
|
61
|
+
templateMode,
|
|
62
|
+
onTemplateModeChange,
|
|
63
|
+
templateProperties,
|
|
64
|
+
templatePlaceholder,
|
|
65
|
+
disabled,
|
|
66
|
+
className,
|
|
67
|
+
}) => {
|
|
68
|
+
const inferredTemplate = value.trim().startsWith("{{");
|
|
69
|
+
const [internalTemplateMode, setInternalTemplateMode] = React.useState(
|
|
70
|
+
defaultTemplateMode ?? inferredTemplate,
|
|
71
|
+
);
|
|
72
|
+
const isTemplate = templateMode ?? internalTemplateMode;
|
|
73
|
+
const setIsTemplate = (next: boolean) => {
|
|
74
|
+
if (templateMode === undefined) setInternalTemplateMode(next);
|
|
75
|
+
onTemplateModeChange?.(next);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className={`flex items-center gap-1 ${className ?? ""}`.trim()}>
|
|
80
|
+
<div className="flex-1">
|
|
81
|
+
{isTemplate ? (
|
|
82
|
+
<TemplateValueInput
|
|
83
|
+
value={value}
|
|
84
|
+
onChange={onChange}
|
|
85
|
+
placeholder={templatePlaceholder ?? "{{ trigger.payload.value }}"}
|
|
86
|
+
templateProperties={templateProperties}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
autoFocus
|
|
89
|
+
/>
|
|
90
|
+
) : (
|
|
91
|
+
renderTyped({ disabled: Boolean(disabled) })
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
onClick={() => setIsTemplate(!isTemplate)}
|
|
98
|
+
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded border border-border bg-card text-xs font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
99
|
+
aria-label={isTemplate ? "Switch back to typed input" : "Switch to template"}
|
|
100
|
+
title={isTemplate ? "Switch back to typed input" : "Switch to template"}
|
|
101
|
+
>
|
|
102
|
+
{isTemplate ? <X className="h-3 w-3" /> : (
|
|
103
|
+
<span className="flex items-center gap-0.5">
|
|
104
|
+
<Code2 className="h-3 w-3" />
|
|
105
|
+
<span>fx</span>
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
@@ -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
|
+
});
|