@checkstack/ui 1.11.0 → 1.13.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/.storybook/main.ts +43 -0
- package/CHANGELOG.md +326 -0
- package/package.json +23 -18
- package/scripts/generate-stdlib-types.ts +23 -0
- package/src/components/Accordion.tsx +17 -9
- package/src/components/ActionCard.tsx +99 -11
- package/src/components/BrandIcon.tsx +57 -0
- package/src/components/CodeEditor/CodeEditor.tsx +159 -14
- package/src/components/CodeEditor/TypefoxEditor.tsx +537 -168
- package/src/components/CodeEditor/editorTheme.test.ts +41 -0
- package/src/components/CodeEditor/editorTheme.ts +26 -0
- package/src/components/CodeEditor/generated/builtin-modules.json +1 -0
- package/src/components/CodeEditor/importSpecifiers.test.ts +286 -0
- package/src/components/CodeEditor/importSpecifiers.ts +267 -0
- package/src/components/CodeEditor/index.ts +26 -0
- package/src/components/CodeEditor/monacoGuard.ts +76 -0
- package/src/components/CodeEditor/monacoTsService.ts +185 -0
- package/src/components/CodeEditor/popoutTitle.test.ts +37 -0
- package/src/components/CodeEditor/popoutTitle.ts +31 -0
- package/src/components/CodeEditor/scriptContext.test.ts +15 -7
- package/src/components/CodeEditor/scriptContext.ts +12 -18
- package/src/components/CodeEditor/scriptDiagnostics.test.ts +135 -0
- package/src/components/CodeEditor/scriptDiagnostics.ts +172 -0
- package/src/components/CodeEditor/types.ts +79 -0
- package/src/components/CodeEditor/validateScripts.ts +172 -0
- package/src/components/CodeEditor/vscodeServicesSignal.ts +72 -0
- package/src/components/ConfirmationModal.tsx +7 -1
- package/src/components/Dialog.tsx +32 -11
- package/src/components/DurationInput.tsx +121 -0
- package/src/components/DynamicForm/DynamicForm.tsx +119 -47
- package/src/components/DynamicForm/DynamicOptionsField.tsx +19 -14
- package/src/components/DynamicForm/FormField.tsx +183 -15
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +78 -2
- package/src/components/DynamicForm/SecretEnvEditor.tsx +315 -0
- package/src/components/DynamicForm/index.ts +20 -0
- package/src/components/DynamicForm/secretEnv.logic.test.ts +126 -0
- package/src/components/DynamicForm/secretEnv.logic.ts +87 -0
- package/src/components/DynamicForm/types.ts +134 -1
- package/src/components/DynamicForm/utils.test.ts +38 -0
- package/src/components/DynamicForm/utils.ts +54 -0
- package/src/components/DynamicForm/validation.logic.test.ts +255 -0
- package/src/components/DynamicForm/validation.logic.ts +210 -0
- package/src/components/DynamicIcon.tsx +39 -17
- package/src/components/Markdown.tsx +68 -2
- package/src/components/Popover.tsx +6 -1
- package/src/components/ScriptTestPanel.logic.test.ts +139 -0
- package/src/components/ScriptTestPanel.logic.ts +137 -0
- package/src/components/ScriptTestPanel.tsx +394 -0
- package/src/components/Sheet.tsx +21 -6
- package/src/components/Spinner.tsx +56 -0
- package/src/components/StatusBadge.tsx +78 -0
- package/src/components/StrategyConfigCard.tsx +3 -3
- package/src/components/Tabs.tsx +7 -1
- package/src/components/TimeOfDayInput.tsx +116 -0
- package/src/components/UserMenu.logic.test.ts +37 -0
- package/src/components/UserMenu.logic.ts +30 -0
- package/src/components/UserMenu.tsx +40 -12
- package/src/components/comboboxInteraction.ts +39 -0
- package/src/components/iconRegistry.tsx +27 -0
- package/src/components/portalContainer.ts +24 -0
- package/src/index.ts +7 -0
- package/stories/ActionCard.stories.tsx +60 -0
- package/stories/CodeEditor.stories.tsx +47 -2
- package/stories/DurationInput.stories.tsx +59 -0
- package/stories/Introduction.mdx +1 -1
- package/stories/Markdown.stories.tsx +56 -0
- package/stories/ScriptTestPanel.stories.tsx +106 -0
- package/stories/SecretEnvEditor.stories.tsx +80 -0
- package/stories/Spinner.stories.tsx +90 -0
- package/stories/TimeOfDayInput.stories.tsx +34 -0
- package/tsconfig.json +4 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
DESKTOP_POPOVER_CONTENT_CLASS,
|
|
4
|
+
shouldRenderProfileHeaderLink,
|
|
5
|
+
} from "./UserMenu.logic";
|
|
6
|
+
|
|
7
|
+
describe("UserMenu - desktop popover overflow fix", () => {
|
|
8
|
+
it("bounds the popover height to the available viewport space", () => {
|
|
9
|
+
// The core of issue #252: without a max-height the menu overflows past
|
|
10
|
+
// the viewport bottom on short screens and the lower items are unreachable.
|
|
11
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain(
|
|
12
|
+
"max-h-[var(--radix-popover-content-available-height)]",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("enables vertical scroll so clipped items stay reachable", () => {
|
|
17
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain("overflow-y-auto");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("retains the two-column grid layout", () => {
|
|
21
|
+
expect(DESKTOP_POPOVER_CONTENT_CLASS).toContain("sm:grid-cols-2");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("UserMenu - profile header link decision", () => {
|
|
26
|
+
it("renders a link when a non-empty profileHref is provided", () => {
|
|
27
|
+
expect(shouldRenderProfileHeaderLink("/profile")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders a non-interactive label when profileHref is omitted", () => {
|
|
31
|
+
expect(shouldRenderProfileHeaderLink(undefined)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats an empty string as no link (avoids an href-less anchor)", () => {
|
|
35
|
+
expect(shouldRenderProfileHeaderLink("")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free logic for {@link UserMenu}, separated so it can be unit
|
|
3
|
+
* tested without a browser environment.
|
|
4
|
+
*
|
|
5
|
+
* The repo's CI runs `bun test` from the repo root, which does NOT load the
|
|
6
|
+
* frontend happy-dom preload (that only applies when `bun test` runs with cwd
|
|
7
|
+
* inside `core/ui`). Component-render tests therefore cannot run in CI, so the
|
|
8
|
+
* testable behaviour lives here and is exercised by `UserMenu.logic.test.ts`
|
|
9
|
+
* (the same `*.logic.ts` + `*.logic.test.ts` split used by `ScriptTestPanel`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Class names for the desktop user-menu popover content.
|
|
14
|
+
*
|
|
15
|
+
* Bounds the popover to the space the trigger leaves below it
|
|
16
|
+
* (`--radix-popover-content-available-height`) and makes the body scroll, so
|
|
17
|
+
* on short viewports the lower menu items stay reachable. The two-column grid
|
|
18
|
+
* (`sm:grid-cols-2`) is retained.
|
|
19
|
+
*/
|
|
20
|
+
export const DESKTOP_POPOVER_CONTENT_CLASS =
|
|
21
|
+
"w-[400px] md:w-[460px] p-2 grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-[var(--radix-popover-content-available-height)] overflow-y-auto";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The name/email header renders as a clickable profile link only when a
|
|
25
|
+
* non-empty `profileHref` is provided; otherwise it is a non-interactive
|
|
26
|
+
* label. This is the single source of truth for that branch in `UserMenu`.
|
|
27
|
+
*/
|
|
28
|
+
export function shouldRenderProfileHeaderLink(profileHref?: string): boolean {
|
|
29
|
+
return typeof profileHref === "string" && profileHref.length > 0;
|
|
30
|
+
}
|
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
import { MenuCloseContext, DropdownMenuLabel, DropdownMenuSeparator } from "./Menu";
|
|
12
12
|
import { useIsMobile } from "../hooks/useIsMobile";
|
|
13
13
|
import { cn } from "../utils";
|
|
14
|
+
import {
|
|
15
|
+
DESKTOP_POPOVER_CONTENT_CLASS,
|
|
16
|
+
shouldRenderProfileHeaderLink,
|
|
17
|
+
} from "./UserMenu.logic";
|
|
14
18
|
|
|
15
19
|
interface UserMenuProps {
|
|
16
20
|
user: {
|
|
@@ -18,12 +22,21 @@ interface UserMenuProps {
|
|
|
18
22
|
name?: string;
|
|
19
23
|
image?: string;
|
|
20
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Optional href for the profile link rendered in the user info header.
|
|
27
|
+
* When provided, the name/email header becomes a focusable anchor that
|
|
28
|
+
* navigates to the profile page - supporting middle-click / open-in-new-tab.
|
|
29
|
+
* UserMenu must NOT import router hooks, so the caller is responsible for
|
|
30
|
+
* constructing this href (e.g. `resolveRoute(authRoutes.routes.profile)`).
|
|
31
|
+
*/
|
|
32
|
+
profileHref?: string;
|
|
21
33
|
children?: React.ReactNode;
|
|
22
34
|
className?: string;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
export const UserMenu: React.FC<UserMenuProps> = ({
|
|
26
38
|
user,
|
|
39
|
+
profileHref,
|
|
27
40
|
children,
|
|
28
41
|
className,
|
|
29
42
|
}) => {
|
|
@@ -68,17 +81,32 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
|
|
68
81
|
</button>
|
|
69
82
|
);
|
|
70
83
|
|
|
71
|
-
const
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const userInfoContent = (
|
|
85
|
+
<div className="flex flex-col">
|
|
86
|
+
<span className="text-sm font-bold text-foreground truncate">
|
|
87
|
+
{user.name || "User"}
|
|
88
|
+
</span>
|
|
89
|
+
<span className="text-xs font-normal text-muted-foreground truncate">
|
|
90
|
+
{user.email}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const userInfo = shouldRenderProfileHeaderLink(profileHref) ? (
|
|
96
|
+
<a
|
|
97
|
+
href={profileHref}
|
|
98
|
+
onClick={() => setIsOpen(false)}
|
|
99
|
+
className={cn(
|
|
100
|
+
"col-span-full block px-4 py-2 rounded-sm",
|
|
101
|
+
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
102
|
+
"transition-colors",
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{userInfoContent}
|
|
106
|
+
<span className="sr-only"> - Go to profile</span>
|
|
107
|
+
</a>
|
|
108
|
+
) : (
|
|
109
|
+
<DropdownMenuLabel>{userInfoContent}</DropdownMenuLabel>
|
|
82
110
|
);
|
|
83
111
|
|
|
84
112
|
if (isMobile) {
|
|
@@ -111,7 +139,7 @@ export const UserMenu: React.FC<UserMenuProps> = ({
|
|
|
111
139
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
112
140
|
<PopoverContent
|
|
113
141
|
align="end"
|
|
114
|
-
className=
|
|
142
|
+
className={DESKTOP_POPOVER_CONTENT_CLASS}
|
|
115
143
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
116
144
|
>
|
|
117
145
|
{userInfo}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker attribute placed on a combobox's anchor `<input>` so a Radix
|
|
3
|
+
* `PopoverContent`'s dismissable layer can recognize an interaction that
|
|
4
|
+
* originated on the anchor itself.
|
|
5
|
+
*
|
|
6
|
+
* Radix treats the anchor as "outside" the floating content, so the very
|
|
7
|
+
* click/focus that opens the list also fires `onPointerDownOutside` /
|
|
8
|
+
* `onFocusOutside` and dismisses it on the same frame (the "opens then
|
|
9
|
+
* immediately closes" flicker). Guarding those handlers against anchor-origin
|
|
10
|
+
* events keeps the list open.
|
|
11
|
+
*
|
|
12
|
+
* Usage (see `PackageNameCombobox` / `SecretEnvEditor`):
|
|
13
|
+
* 1. Spread {@link comboboxAnchorProps} onto the anchor `<input>`.
|
|
14
|
+
* 2. On `PopoverContent`, in both `onPointerDownOutside` and
|
|
15
|
+
* `onFocusOutside`, call `event.preventDefault()` when
|
|
16
|
+
* {@link isAnchorInteraction} matches the event target.
|
|
17
|
+
* 3. Prevent `onCloseAutoFocus` default.
|
|
18
|
+
* 4. Option buttons use `onMouseDown` + `preventDefault` so a click selects
|
|
19
|
+
* before the input blurs.
|
|
20
|
+
*
|
|
21
|
+
* Lives in `@checkstack/ui` so every combobox (across plugins) shares one
|
|
22
|
+
* implementation.
|
|
23
|
+
*/
|
|
24
|
+
export const COMBOBOX_ANCHOR_ATTR = "data-combobox-anchor";
|
|
25
|
+
|
|
26
|
+
/** Props to spread onto the anchor `<input>` so {@link isAnchorInteraction} can match it. */
|
|
27
|
+
export const comboboxAnchorProps: Record<string, string> = {
|
|
28
|
+
[COMBOBOX_ANCHOR_ATTR]: "",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether an event target is (or is inside) a combobox anchor input. Pure +
|
|
33
|
+
* DOM-only so it is unit-testable without a React render. Tolerates a null
|
|
34
|
+
* target and non-Element targets (returns false).
|
|
35
|
+
*/
|
|
36
|
+
export function isAnchorInteraction(target: EventTarget | null): boolean {
|
|
37
|
+
if (!target || !(target instanceof Element)) return false;
|
|
38
|
+
return target.closest(`[${COMBOBOX_ANCHOR_ATTR}]`) !== null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { icons } from "lucide-react";
|
|
2
|
+
import type { LucideIconName } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
// Lazy-loaded icon registry.
|
|
5
|
+
//
|
|
6
|
+
// Importing lucide's `icons` map pulls the ENTIRE ~1600-icon set into one
|
|
7
|
+
// module. This file is therefore imported ONLY via `React.lazy` from
|
|
8
|
+
// `DynamicIcon`, so that whole set lands in a single on-demand chunk that the
|
|
9
|
+
// initial load never fetches (DynamicIcon isn't rendered in the global nav -
|
|
10
|
+
// it's used in dialogs, cards, and specific pages). Statically named-imported
|
|
11
|
+
// icons elsewhere (`import { Plus } from "lucide-react"`) are unaffected and
|
|
12
|
+
// tree-shaken normally into their eager chunks.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a lucide icon by its PascalCase name. Falls back to the `Settings`
|
|
16
|
+
* icon for an unknown name (matching the previous DynamicIcon behaviour).
|
|
17
|
+
*/
|
|
18
|
+
export default function RegistryIcon({
|
|
19
|
+
name,
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
name: LucideIconName;
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const Icon = icons[name] ?? icons.Settings;
|
|
26
|
+
return <Icon className={className} />;
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The DOM element a floating layer (e.g. a {@link PopoverContent}) should
|
|
5
|
+
* portal INTO when it is rendered inside a modal Sheet/Dialog.
|
|
6
|
+
*
|
|
7
|
+
* Radix modal dialogs lock body scroll via `react-remove-scroll`, which
|
|
8
|
+
* prevents wheel/touch scrolling on anything portaled to `document.body`
|
|
9
|
+
* (i.e. outside the dialog's DOM subtree). A Popover whose list overflows
|
|
10
|
+
* therefore can't scroll while a Sheet/Dialog is open. Portaling the Popover
|
|
11
|
+
* INTO the dialog content keeps it inside the allowed-scroll subtree, so its
|
|
12
|
+
* inner `overflow-y-auto` works again. Radix positions popovers with
|
|
13
|
+
* `position: fixed`, so the container has no effect on placement or clipping.
|
|
14
|
+
*
|
|
15
|
+
* `null` (the default, outside any Sheet/Dialog) means portal to `body` as
|
|
16
|
+
* usual.
|
|
17
|
+
*/
|
|
18
|
+
export const PortalContainerContext = React.createContext<HTMLElement | null>(
|
|
19
|
+
null,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/** Read the nearest Sheet/Dialog content element to portal a popover into. */
|
|
23
|
+
export const usePortalContainer = (): HTMLElement | null =>
|
|
24
|
+
React.useContext(PortalContainerContext);
|
package/src/index.ts
CHANGED
|
@@ -10,11 +10,13 @@ export * from "./components/SectionHeader";
|
|
|
10
10
|
export * from "./components/StatusCard";
|
|
11
11
|
export * from "./components/EmptyState";
|
|
12
12
|
export * from "./components/LoadingSpinner";
|
|
13
|
+
export * from "./components/Spinner";
|
|
13
14
|
export * from "./components/Menu";
|
|
14
15
|
export * from "./components/UserMenu";
|
|
15
16
|
export * from "./components/EditableText";
|
|
16
17
|
export * from "./components/ConfirmationModal";
|
|
17
18
|
export * from "./components/HealthBadge";
|
|
19
|
+
export * from "./components/StatusBadge";
|
|
18
20
|
export * from "./components/Table";
|
|
19
21
|
export * from "./utils";
|
|
20
22
|
export * from "./components/Select";
|
|
@@ -41,12 +43,15 @@ export * from "./components/Pagination";
|
|
|
41
43
|
export * from "./components/PaginatedList";
|
|
42
44
|
export * from "./hooks/usePagination";
|
|
43
45
|
export * from "./components/DateTimePicker";
|
|
46
|
+
export * from "./components/DurationInput";
|
|
47
|
+
export * from "./components/TimeOfDayInput";
|
|
44
48
|
export * from "./components/DateRangeFilter";
|
|
45
49
|
export * from "./components/BackLink";
|
|
46
50
|
export * from "./components/StatusUpdateTimeline";
|
|
47
51
|
export * from "./components/LinksEditor";
|
|
48
52
|
export * from "./components/Slider";
|
|
49
53
|
export * from "./components/DynamicIcon";
|
|
54
|
+
export * from "./components/BrandIcon";
|
|
50
55
|
export * from "./components/StrategyConfigCard";
|
|
51
56
|
export * from "./components/Markdown";
|
|
52
57
|
export * from "./components/ColorPicker";
|
|
@@ -61,12 +66,14 @@ export * from "./components/VariablePicker";
|
|
|
61
66
|
export * from "./components/TemplateInput";
|
|
62
67
|
export * from "./components/TemplateInputToggle";
|
|
63
68
|
export * from "./components/ActionCard";
|
|
69
|
+
export * from "./components/ScriptTestPanel";
|
|
64
70
|
export * from "./components/AnimatedNumber";
|
|
65
71
|
export * from "./hooks/useAnimatedNumber";
|
|
66
72
|
export * from "./components/IDELayout";
|
|
67
73
|
export * from "./components/MetricTile";
|
|
68
74
|
export * from "./components/Sheet";
|
|
69
75
|
export * from "./components/Popover";
|
|
76
|
+
export * from "./components/comboboxInteraction";
|
|
70
77
|
export * from "./hooks/useIsMobile";
|
|
71
78
|
export * from "./hooks/useInitOnceForKey";
|
|
72
79
|
export * from "./components/ListEmptyState";
|
|
@@ -60,3 +60,63 @@ export const MinimalNoToggle: Story = {
|
|
|
60
60
|
</div>
|
|
61
61
|
),
|
|
62
62
|
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Home-Assistant-style collapsed summary row. Passing `onOpenSheet` makes the
|
|
66
|
+
* card a non-expanding summary: clicking the header opens the item's full
|
|
67
|
+
* config in a side sheet (wired up by the host) instead of expanding inline.
|
|
68
|
+
* `summary` renders a compact one-line hint under the title, and `actions`
|
|
69
|
+
* adds a three-dot overflow menu (Duplicate / Disable / Delete).
|
|
70
|
+
*/
|
|
71
|
+
const CollapsedSummaryDemo = () => {
|
|
72
|
+
const [enabled, setEnabled] = useState(true);
|
|
73
|
+
return (
|
|
74
|
+
<div className="w-[680px] space-y-2 p-4">
|
|
75
|
+
<ActionCard
|
|
76
|
+
id="notify-1"
|
|
77
|
+
title="Notify User"
|
|
78
|
+
category="Notification"
|
|
79
|
+
icon="Bell"
|
|
80
|
+
summary="notify_user → on-call engineer"
|
|
81
|
+
enabled={enabled}
|
|
82
|
+
onOpenSheet={() => alert("Open config sheet")}
|
|
83
|
+
actions={[
|
|
84
|
+
{
|
|
85
|
+
label: enabled ? "Disable" : "Enable",
|
|
86
|
+
icon: enabled ? "PowerOff" : "Power",
|
|
87
|
+
onClick: () => setEnabled((value) => !value),
|
|
88
|
+
},
|
|
89
|
+
{ label: "Duplicate", icon: "Copy", onClick: () => alert("Duplicate") },
|
|
90
|
+
{
|
|
91
|
+
label: "Delete",
|
|
92
|
+
icon: "Trash2",
|
|
93
|
+
onClick: () => alert("Delete"),
|
|
94
|
+
variant: "destructive",
|
|
95
|
+
},
|
|
96
|
+
]}
|
|
97
|
+
/>
|
|
98
|
+
<ActionCard
|
|
99
|
+
id="choose-1"
|
|
100
|
+
title="Choose (if / else)"
|
|
101
|
+
category="Choose"
|
|
102
|
+
icon="GitBranch"
|
|
103
|
+
summary="2 branches + else"
|
|
104
|
+
onOpenSheet={() => alert("Open config sheet")}
|
|
105
|
+
errors={["choose.0.when: condition is required"]}
|
|
106
|
+
actions={[
|
|
107
|
+
{ label: "Duplicate", icon: "Copy", onClick: () => alert("Duplicate") },
|
|
108
|
+
{
|
|
109
|
+
label: "Delete",
|
|
110
|
+
icon: "Trash2",
|
|
111
|
+
onClick: () => alert("Delete"),
|
|
112
|
+
variant: "destructive",
|
|
113
|
+
},
|
|
114
|
+
]}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const CollapsedSummary: Story = {
|
|
121
|
+
render: () => <CollapsedSummaryDemo />,
|
|
122
|
+
};
|
|
@@ -14,9 +14,11 @@ type Story = StoryObj<typeof CodeEditor>;
|
|
|
14
14
|
const Demo = ({
|
|
15
15
|
language,
|
|
16
16
|
initial,
|
|
17
|
+
minHeight = "280px",
|
|
17
18
|
}: {
|
|
18
|
-
language: "json" | "yaml" | "javascript";
|
|
19
|
+
language: "json" | "yaml" | "javascript" | "typescript" | "shell";
|
|
19
20
|
initial: string;
|
|
21
|
+
minHeight?: string;
|
|
20
22
|
}) => {
|
|
21
23
|
const [value, setValue] = useState(initial);
|
|
22
24
|
return (
|
|
@@ -24,7 +26,7 @@ const Demo = ({
|
|
|
24
26
|
value={value}
|
|
25
27
|
onChange={setValue}
|
|
26
28
|
language={language}
|
|
27
|
-
minHeight=
|
|
29
|
+
minHeight={minHeight}
|
|
28
30
|
/>
|
|
29
31
|
);
|
|
30
32
|
};
|
|
@@ -65,3 +67,46 @@ export default async function check({ fetch }) {
|
|
|
65
67
|
/>
|
|
66
68
|
),
|
|
67
69
|
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The top-right "Expand editor" affordance opens the same editor in a large
|
|
73
|
+
* full-screen overlay. The inline editor here is deliberately short so the
|
|
74
|
+
* value of expanding a long script is obvious; both editors share the same
|
|
75
|
+
* value, so edits made in the overlay persist after closing it.
|
|
76
|
+
*/
|
|
77
|
+
export const Popout: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<Demo
|
|
80
|
+
language="typescript"
|
|
81
|
+
minHeight="120px"
|
|
82
|
+
initial={`// A longer script - click the expand icon (top-right) to edit it in a
|
|
83
|
+
// comfortable full-screen overlay. Edits sync back to the inline editor.
|
|
84
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
85
|
+
|
|
86
|
+
export default async function run({ context }) {
|
|
87
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
88
|
+
const res = await fetch(context.url);
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
return { ok: true, attempt };
|
|
91
|
+
}
|
|
92
|
+
await sleep(1000 * attempt);
|
|
93
|
+
}
|
|
94
|
+
return { ok: false };
|
|
95
|
+
}`}
|
|
96
|
+
/>
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Shell scripts get a "Edit script - Shell" overlay title. */
|
|
101
|
+
export const Shell: Story = {
|
|
102
|
+
render: () => (
|
|
103
|
+
<Demo
|
|
104
|
+
language="shell"
|
|
105
|
+
minHeight="120px"
|
|
106
|
+
initial={`#!/usr/bin/env bash
|
|
107
|
+
set -euo pipefail
|
|
108
|
+
|
|
109
|
+
curl -fsS "$TARGET_URL" > /dev/null && echo "up" || echo "down"`}
|
|
110
|
+
/>
|
|
111
|
+
),
|
|
112
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
DurationInput,
|
|
5
|
+
type DurationValue,
|
|
6
|
+
} from "../src/components/DurationInput";
|
|
7
|
+
import { Label } from "../src/components/Label";
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof DurationInput> = {
|
|
10
|
+
title: "Components/Inputs/DurationInput",
|
|
11
|
+
component: DurationInput,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default meta;
|
|
16
|
+
type Story = StoryObj<typeof DurationInput>;
|
|
17
|
+
|
|
18
|
+
const Demo = ({
|
|
19
|
+
initial,
|
|
20
|
+
defaultUnit,
|
|
21
|
+
}: {
|
|
22
|
+
initial?: DurationValue;
|
|
23
|
+
defaultUnit?: "seconds" | "minutes" | "hours";
|
|
24
|
+
}) => {
|
|
25
|
+
const [value, setValue] = useState<DurationValue | undefined>(initial);
|
|
26
|
+
return (
|
|
27
|
+
<div className="max-w-sm space-y-2">
|
|
28
|
+
<Label>Fire only if state holds for</Label>
|
|
29
|
+
<DurationInput
|
|
30
|
+
value={value}
|
|
31
|
+
onChange={setValue}
|
|
32
|
+
defaultUnit={defaultUnit}
|
|
33
|
+
/>
|
|
34
|
+
<pre className="text-xs text-muted-foreground">
|
|
35
|
+
{JSON.stringify(value ?? null)}
|
|
36
|
+
</pre>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const Empty: Story = { render: () => <Demo /> };
|
|
42
|
+
|
|
43
|
+
export const Minutes: Story = {
|
|
44
|
+
render: () => <Demo initial={{ minutes: 30 }} />,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const Hours: Story = {
|
|
48
|
+
render: () => <Demo initial={{ hours: 2 }} />,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const SecondsDefault: Story = {
|
|
52
|
+
render: () => <Demo defaultUnit="seconds" />,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Disabled: Story = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<DurationInput value={{ minutes: 10 }} onChange={() => {}} disabled />
|
|
58
|
+
),
|
|
59
|
+
};
|
package/stories/Introduction.mdx
CHANGED
|
@@ -46,5 +46,5 @@ inherit light/dark mode. Use semantic Tailwind classes (`bg-primary`,
|
|
|
46
46
|
- Components are built on Radix primitives where keyboard / focus / ARIA
|
|
47
47
|
behavior is non-trivial.
|
|
48
48
|
- Animation-heavy components respect the `usePerformance` hook's `isLowPower`
|
|
49
|
-
flag — see [the performance rule](https://github.com/enyineer/checkstack/blob/main/.
|
|
49
|
+
flag — see [the performance rule](https://github.com/enyineer/checkstack/blob/main/.claude/rules/performance.md)
|
|
50
50
|
for the full contract plugins are expected to follow.
|
|
@@ -33,3 +33,59 @@ Read more in the [strategies guide](https://example.com).`}
|
|
|
33
33
|
</MarkdownBlock>
|
|
34
34
|
),
|
|
35
35
|
};
|
|
36
|
+
|
|
37
|
+
// GFM tables (and strikethrough / autolinks) render via remark-gfm. The
|
|
38
|
+
// assistant frequently summarizes a draft as a table, so this must render as a
|
|
39
|
+
// real table, not a collapsed line of pipes.
|
|
40
|
+
export const Table: Story = {
|
|
41
|
+
render: () => (
|
|
42
|
+
<MarkdownBlock>
|
|
43
|
+
{`Here's the draft:
|
|
44
|
+
|
|
45
|
+
| Field | Value |
|
|
46
|
+
|---|---|
|
|
47
|
+
| URL | https://example.com/status |
|
|
48
|
+
| Method | GET |
|
|
49
|
+
| Assertion | statusCode = 200 |
|
|
50
|
+
| Interval | 60s |
|
|
51
|
+
| Timeout | 10s |
|
|
52
|
+
|
|
53
|
+
~~old value~~ replaced.`}
|
|
54
|
+
</MarkdownBlock>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The assistant sometimes folds a long diff behind a native <details> disclosure.
|
|
59
|
+
// Raw HTML is parsed (rehype-raw) and sanitized (rehype-sanitize) so the
|
|
60
|
+
// collapsible renders as a click-to-expand widget instead of leaking the literal
|
|
61
|
+
// tags as text. Click the summary to expand.
|
|
62
|
+
export const Disclosure: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<MarkdownBlock>
|
|
65
|
+
{`Here are the planned changes - please confirm:
|
|
66
|
+
|
|
67
|
+
<details>
|
|
68
|
+
<summary>📝 View diff</summary>
|
|
69
|
+
|
|
70
|
+
**Inline-Script:**
|
|
71
|
+
|
|
72
|
+
\`\`\`diff
|
|
73
|
+
- success: load < 0.6
|
|
74
|
+
+ success: load < (cpus().length * 0.6)
|
|
75
|
+
\`\`\`
|
|
76
|
+
|
|
77
|
+
</details>`}
|
|
78
|
+
</MarkdownBlock>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Sanitization closes the XSS surface: a script tag / event handler in model
|
|
83
|
+
// output is stripped, so only the safe text survives. This must render the bold
|
|
84
|
+
// text WITHOUT executing or showing any script.
|
|
85
|
+
export const SanitizesUnsafeHtml: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<MarkdownBlock>
|
|
88
|
+
{`Safe **bold** text.<script>window.__pwned = true</script><img src=x onerror="window.__pwned = true">`}
|
|
89
|
+
</MarkdownBlock>
|
|
90
|
+
),
|
|
91
|
+
};
|