@checkstack/ui 1.8.3 → 1.10.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 (36) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +6 -2
  3. package/scripts/generate-stdlib-types.ts +90 -0
  4. package/src/components/CodeEditor/CodeEditor.tsx +7 -0
  5. package/src/components/CodeEditor/MonacoEditor.tsx +203 -117
  6. package/src/components/CodeEditor/generateTypeDefinitions.ts +19 -26
  7. package/src/components/CodeEditor/generated/stdlib-types.json +1 -0
  8. package/src/components/CodeEditor/index.ts +7 -0
  9. package/src/components/CodeEditor/monacoStdlib.ts +62 -0
  10. package/src/components/CodeEditor/monacoWorkers.ts +118 -0
  11. package/src/components/CodeEditor/scriptContext.test.ts +280 -0
  12. package/src/components/CodeEditor/scriptContext.ts +467 -0
  13. package/src/components/CodeEditor/shellEnvVarMatcher.test.ts +95 -0
  14. package/src/components/CodeEditor/shellEnvVarMatcher.ts +70 -0
  15. package/src/components/DynamicForm/DynamicForm.tsx +6 -0
  16. package/src/components/DynamicForm/FormField.tsx +15 -0
  17. package/src/components/DynamicForm/MultiTypeEditorField.tsx +111 -6
  18. package/src/components/DynamicForm/index.ts +2 -0
  19. package/src/components/DynamicForm/starterTemplateSelector.test.ts +96 -0
  20. package/src/components/DynamicForm/starterTemplateSelector.ts +32 -0
  21. package/src/components/DynamicForm/types.ts +34 -1
  22. package/src/components/ListEmptyState.tsx +51 -0
  23. package/src/components/QueryErrorState.tsx +64 -0
  24. package/src/components/ResponsiveTable.tsx +92 -0
  25. package/src/components/Skeleton.tsx +39 -0
  26. package/src/hooks/useInitOnceForKey.test.ts +127 -0
  27. package/src/hooks/useInitOnceForKey.ts +87 -0
  28. package/src/index.ts +6 -0
  29. package/src/utils/toastTemplates.test.ts +82 -0
  30. package/src/utils/toastTemplates.ts +47 -0
  31. package/stories/ListEmptyState.stories.tsx +48 -0
  32. package/stories/QueryErrorState.stories.tsx +40 -0
  33. package/stories/ResponsiveTable.stories.tsx +93 -0
  34. package/stories/Skeleton.stories.tsx +53 -0
  35. package/stories/toastTemplates.stories.tsx +60 -0
  36. package/tsconfig.json +3 -1
@@ -33,6 +33,9 @@ export const FormField: React.FC<FormFieldProps> = ({
33
33
  formValues,
34
34
  optionsResolvers,
35
35
  templateProperties,
36
+ typeDefinitions,
37
+ shellEnvVars,
38
+ starterTemplates,
36
39
  onChange,
37
40
  }) => {
38
41
  const description = propSchema.description || "";
@@ -129,6 +132,9 @@ export const FormField: React.FC<FormFieldProps> = ({
129
132
  isRequired={isRequired}
130
133
  editorTypes={editorTypes}
131
134
  templateProperties={templateProperties}
135
+ typeDefinitions={typeDefinitions}
136
+ shellEnvVars={shellEnvVars}
137
+ starterTemplates={starterTemplates}
132
138
  onChange={onChange as (val: string | undefined) => void}
133
139
  />
134
140
  );
@@ -346,6 +352,9 @@ export const FormField: React.FC<FormFieldProps> = ({
346
352
  formValues={formValues}
347
353
  optionsResolvers={optionsResolvers}
348
354
  templateProperties={templateProperties}
355
+ typeDefinitions={typeDefinitions}
356
+ shellEnvVars={shellEnvVars}
357
+ starterTemplates={starterTemplates}
349
358
  onChange={(val) =>
350
359
  onChange({ ...(value as Record<string, unknown>), [key]: val })
351
360
  }
@@ -454,6 +463,9 @@ export const FormField: React.FC<FormFieldProps> = ({
454
463
  formValues={formValues}
455
464
  optionsResolvers={optionsResolvers}
456
465
  templateProperties={templateProperties}
466
+ typeDefinitions={typeDefinitions}
467
+ shellEnvVars={shellEnvVars}
468
+ starterTemplates={starterTemplates}
457
469
  onChange={(val) => {
458
470
  const next = [...(items as unknown[])];
459
471
  next[index] = val;
@@ -582,6 +594,9 @@ export const FormField: React.FC<FormFieldProps> = ({
582
594
  formValues={formValues}
583
595
  optionsResolvers={optionsResolvers}
584
596
  templateProperties={templateProperties}
597
+ typeDefinitions={typeDefinitions}
598
+ shellEnvVars={shellEnvVars}
599
+ starterTemplates={starterTemplates}
585
600
  onChange={(val) => onChange({ ...currentValue, [key]: val })}
586
601
  />
587
602
  ))}
@@ -17,6 +17,8 @@ import {
17
17
  parseFormData,
18
18
  EDITOR_TYPE_LABELS,
19
19
  } from "./utils";
20
+ import type { EditorStarterTemplates, ShellEnvVar } from "./types";
21
+ import { pickStarterForEmptyField } from "./starterTemplateSelector";
20
22
 
21
23
  export interface MultiTypeEditorFieldProps {
22
24
  /** Unique identifier for the field */
@@ -33,6 +35,25 @@ export interface MultiTypeEditorFieldProps {
33
35
  editorTypes: EditorType[];
34
36
  /** Optional template properties for autocomplete */
35
37
  templateProperties?: TemplateProperty[];
38
+ /**
39
+ * Optional TypeScript declarations injected into Monaco for TS/JS modes —
40
+ * powers IntelliSense + return-shape error reporting against the runtime
41
+ * context the user will see (`context.config`, `context.event`, ...).
42
+ */
43
+ typeDefinitions?: string;
44
+ /**
45
+ * Optional shell env-var hints surfaced as completion items after `$`
46
+ * (and `${`) in shell mode. Typically populated by the platform with
47
+ * names like `EVENT_ID` / `PAYLOAD_*` so users don't have to memorise.
48
+ */
49
+ shellEnvVars?: ShellEnvVar[];
50
+ /**
51
+ * Optional starter templates per editor language. When the field is
52
+ * empty (and the user hasn't typed yet), switching to an editor with a
53
+ * starter for that language pre-populates the editor so users see a
54
+ * working example instead of a blank canvas.
55
+ */
56
+ starterTemplates?: EditorStarterTemplates;
36
57
  /** Callback when value changes */
37
58
  onChange: (value: string | undefined) => void;
38
59
  }
@@ -50,6 +71,9 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
50
71
  isRequired,
51
72
  editorTypes,
52
73
  templateProperties,
74
+ typeDefinitions,
75
+ shellEnvVars,
76
+ starterTemplates,
53
77
  onChange,
54
78
  }) => {
55
79
  // Detect initial editor type from value
@@ -57,16 +81,81 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
57
81
  detectEditorType(value, editorTypes),
58
82
  );
59
83
 
60
- // Track if this is the initial mount to avoid re-detecting on every value change
61
- const isInitialMount = React.useRef(true);
84
+ // Latched once the field has had non-empty content at least once for
85
+ // this mount. Used by the seed effects below to decide whether to
86
+ // (re-)install the starter template.
87
+ const hasSeededRef = React.useRef(false);
88
+
89
+ // React reuses this `MultiTypeEditorField` instance across collector
90
+ // switches when its parent `FormField` keeps the same key (e.g. both
91
+ // collectors have a property called `script`). The `useState`
92
+ // initializer above only runs on the first mount, so without this
93
+ // effect a switch from a shell-only collector to a typescript-only
94
+ // collector leaves `selectedType` stuck on "shell" — Monaco then
95
+ // tokenises the new collector's TS content as shell (no
96
+ // highlighting), and the reverse direction tokenises shell content as
97
+ // TS (nonsense "Cannot find name" errors).
98
+ //
99
+ // Whenever `editorTypes` changes and the current selection is no
100
+ // longer offered, re-detect from the new value. We deliberately do
101
+ // NOT reset on every `value` change — that would clobber the user's
102
+ // manual dropdown choice mid-edit.
62
103
  React.useEffect(() => {
63
- if (isInitialMount.current) {
64
- isInitialMount.current = false;
65
- return;
104
+ if (!editorTypes.includes(selectedType)) {
105
+ const next = detectEditorType(value, editorTypes);
106
+ setSelectedType(next);
107
+ // Reset the seed latch too — this is effectively a fresh field
108
+ // for a different collector, and the new language's starter
109
+ // template should be eligible again.
110
+ hasSeededRef.current = false;
111
+ }
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only re-derive when the offered types change
113
+ }, [editorTypes]);
114
+
115
+ // Seed an empty field with the starter template for its detected
116
+ // language so users see a working example instead of a blank canvas.
117
+ //
118
+ // Why this isn't just `useEffect(..., [])`:
119
+ // React fires CHILD effects before PARENT effects. `DynamicForm`'s
120
+ // own init effect (which applies schema defaults) runs AFTER this one
121
+ // and, on initial mount, calls `onChange(defaults)` — which clobbers
122
+ // a freshly-seeded `script: starter` back to `script: ""` because the
123
+ // defaults effect's closure-captured `value` was the original `{}`.
124
+ // A naive `[]`-dep seed would therefore appear to do nothing.
125
+ //
126
+ // Two-effect design fixes this without depending on cross-component
127
+ // effect ordering:
128
+ // 1. The "observed" effect (declared FIRST) flips `hasSeededRef` to
129
+ // true the moment we see non-empty content in `value` — whether
130
+ // that came from our seed surviving, a parent default landing
131
+ // with content, or the user typing.
132
+ // 2. The "seed" effect re-runs on every value/starter change and
133
+ // installs the starter while `hasSeededRef` is still false. If
134
+ // the parent clobbers us back to "", we just re-seed on the
135
+ // next pass — until the value sticks and effect (1) latches.
136
+ //
137
+ // The latch also means we DON'T re-seed if the user deliberately
138
+ // deletes their content later, or on any subsequent refetch / parent
139
+ // re-render: once non-empty has been observed at least once, the
140
+ // field is the user's territory.
141
+ React.useEffect(() => {
142
+ if (!hasSeededRef.current && (value ?? "").trim().length > 0) {
143
+ hasSeededRef.current = true;
66
144
  }
67
- // Don't re-detect type when value changes after initial mount
68
145
  }, [value]);
69
146
 
147
+ React.useEffect(() => {
148
+ if (hasSeededRef.current) return;
149
+ const starter = pickStarterForEmptyField({
150
+ value,
151
+ selectedType,
152
+ starterTemplates,
153
+ });
154
+ if (starter !== undefined) {
155
+ onChange(starter);
156
+ }
157
+ }, [value, selectedType, starterTemplates, onChange]);
158
+
70
159
  const handleTypeChange = (newType: EditorType) => {
71
160
  setSelectedType(newType);
72
161
 
@@ -76,6 +165,19 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
76
165
  return;
77
166
  }
78
167
 
168
+ // If the field is empty after the switch and we have a starter template
169
+ // for the new language, seed it. (Switching languages on a non-empty
170
+ // field preserves the existing content, just like before.)
171
+ const starter = pickStarterForEmptyField({
172
+ value,
173
+ selectedType: newType,
174
+ starterTemplates,
175
+ });
176
+ if (starter !== undefined) {
177
+ onChange(starter);
178
+ return;
179
+ }
180
+
79
181
  // When switching to formdata
80
182
  if (newType === "formdata") {
81
183
  if (!value || value.trim() === "") {
@@ -242,6 +344,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
242
344
  language="javascript"
243
345
  minHeight="150px"
244
346
  templateProperties={templateProperties}
347
+ typeDefinitions={typeDefinitions}
245
348
  />
246
349
  )}
247
350
 
@@ -253,6 +356,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
253
356
  language="typescript"
254
357
  minHeight="150px"
255
358
  templateProperties={templateProperties}
359
+ typeDefinitions={typeDefinitions}
256
360
  />
257
361
  )}
258
362
 
@@ -264,6 +368,7 @@ export const MultiTypeEditorField: React.FC<MultiTypeEditorFieldProps> = ({
264
368
  language="shell"
265
369
  minHeight="150px"
266
370
  templateProperties={templateProperties}
371
+ shellEnvVars={shellEnvVars}
267
372
  />
268
373
  )}
269
374
  </div>
@@ -13,6 +13,8 @@ export type {
13
13
  OptionsResolver,
14
14
  ResolverOption,
15
15
  EditorType,
16
+ ShellEnvVar,
17
+ EditorStarterTemplates,
16
18
  } from "./types";
17
19
 
18
20
  // Utility functions
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { pickStarterForEmptyField } from "./starterTemplateSelector";
3
+ import type { EditorStarterTemplates } from "./types";
4
+
5
+ /**
6
+ * Regression suite for the starter-template seeding behaviour in
7
+ * `MultiTypeEditorField`.
8
+ *
9
+ * The previous implementation gated seeding behind an `isInitialMount`
10
+ * ref that a sibling effect with `[value]` deps flipped to false BEFORE
11
+ * the seed effect ran — so seeding silently no-op'd on every mount and
12
+ * users always saw a blank canvas. The refactored seed effect delegates
13
+ * to this pure function; testing it here proves the decision is correct
14
+ * regardless of the React effect ordering trap.
15
+ */
16
+
17
+ const STARTERS: EditorStarterTemplates = {
18
+ typescript: "// starter for TypeScript",
19
+ shell: "#!/bin/sh\necho starter for shell",
20
+ };
21
+
22
+ describe("pickStarterForEmptyField", () => {
23
+ it("returns the starter for the selected language when the field is empty", () => {
24
+ expect(
25
+ pickStarterForEmptyField({
26
+ value: "",
27
+ selectedType: "typescript",
28
+ starterTemplates: STARTERS,
29
+ }),
30
+ ).toBe(STARTERS.typescript);
31
+ });
32
+
33
+ it("returns the starter when the value is whitespace-only", () => {
34
+ // The original bug let trailing whitespace block the seed; explicit
35
+ // coverage so we don't regress.
36
+ expect(
37
+ pickStarterForEmptyField({
38
+ value: " \n\t ",
39
+ selectedType: "typescript",
40
+ starterTemplates: STARTERS,
41
+ }),
42
+ ).toBe(STARTERS.typescript);
43
+ });
44
+
45
+ it("returns undefined when the field already has content (don't clobber)", () => {
46
+ expect(
47
+ pickStarterForEmptyField({
48
+ value: "// user's existing code",
49
+ selectedType: "typescript",
50
+ starterTemplates: STARTERS,
51
+ }),
52
+ ).toBeUndefined();
53
+ });
54
+
55
+ it("returns undefined when value is undefined and no starter is defined for the selected language", () => {
56
+ expect(
57
+ pickStarterForEmptyField({
58
+ value: undefined,
59
+ selectedType: "json",
60
+ starterTemplates: STARTERS,
61
+ }),
62
+ ).toBeUndefined();
63
+ });
64
+
65
+ it("returns undefined when no `starterTemplates` are provided at all", () => {
66
+ expect(
67
+ pickStarterForEmptyField({
68
+ value: "",
69
+ selectedType: "typescript",
70
+ }),
71
+ ).toBeUndefined();
72
+ });
73
+
74
+ it("returns the shell starter when shell is selected", () => {
75
+ expect(
76
+ pickStarterForEmptyField({
77
+ value: undefined,
78
+ selectedType: "shell",
79
+ starterTemplates: STARTERS,
80
+ }),
81
+ ).toBe(STARTERS.shell);
82
+ });
83
+
84
+ it("treats an empty-string starter as 'no starter available'", () => {
85
+ // We never want to install a literal empty string on top of an
86
+ // already-empty field; that would just look like the seed silently
87
+ // did nothing.
88
+ expect(
89
+ pickStarterForEmptyField({
90
+ value: "",
91
+ selectedType: "typescript",
92
+ starterTemplates: { typescript: "" },
93
+ }),
94
+ ).toBeUndefined();
95
+ });
96
+ });
@@ -0,0 +1,32 @@
1
+ import type { EditorStarterTemplates, EditorType } from "./types";
2
+
3
+ /**
4
+ * Pure decision function powering the "seed empty editor with a starter
5
+ * template" behaviour in `MultiTypeEditorField`. Returns the starter
6
+ * content to install, or `undefined` if the field shouldn't be touched.
7
+ *
8
+ * Used both on first mount and when the user switches editor language on
9
+ * a still-empty field, so a single source of truth governs both paths
10
+ * (and a single test suite covers them).
11
+ *
12
+ * Returns `undefined` when:
13
+ * - the field already has non-whitespace content (don't clobber user input)
14
+ * - no `starterTemplates` were provided
15
+ * - no starter exists for the currently-selected language
16
+ * - the matching starter is the empty string
17
+ */
18
+ export function pickStarterForEmptyField({
19
+ value,
20
+ selectedType,
21
+ starterTemplates,
22
+ }: {
23
+ value: string | undefined;
24
+ selectedType: EditorType;
25
+ starterTemplates?: EditorStarterTemplates;
26
+ }): string | undefined {
27
+ const trimmed = (value ?? "").trim();
28
+ if (trimmed.length > 0) return undefined;
29
+ const starter = starterTemplates?.[selectedType];
30
+ if (!starter || starter.length === 0) return undefined;
31
+ return starter;
32
+ }
@@ -1,8 +1,11 @@
1
- import type { TemplateProperty } from "../CodeEditor";
1
+ import type { TemplateProperty, ShellEnvVar } from "../CodeEditor";
2
2
  import type { EditorType } from "@checkstack/common";
3
3
 
4
4
  // Re-export types used by multi-type editor
5
5
  export type { EditorType } from "./utils";
6
+ // Re-export `ShellEnvVar` so DynamicForm consumers don't have to import
7
+ // from two paths. The canonical definition lives in `../CodeEditor`.
8
+ export type { ShellEnvVar } from "../CodeEditor";
6
9
  import type {
7
10
  JsonSchemaPropertyCore,
8
11
  JsonSchemaBase,
@@ -40,6 +43,13 @@ export type OptionsResolver = (
40
43
  */
41
44
  export type JsonSchema = JsonSchemaBase<JsonSchemaProperty>;
42
45
 
46
+ /**
47
+ * Default starter templates per editor language. Used to populate empty
48
+ * multi-type editor fields so users see a working example instead of a
49
+ * blank canvas. Keyed by `EditorType` (e.g. "typescript", "shell").
50
+ */
51
+ export type EditorStarterTemplates = Partial<Record<EditorType, string>>;
52
+
43
53
  export interface DynamicFormProps {
44
54
  schema: JsonSchema;
45
55
  value: Record<string, unknown>;
@@ -59,6 +69,25 @@ export interface DynamicFormProps {
59
69
  * When provided, fields with x-editor-types get {{ autocomplete suggestions.
60
70
  */
61
71
  templateProperties?: TemplateProperty[];
72
+ /**
73
+ * Optional TypeScript declarations to inject into Monaco for `typescript`
74
+ * or `javascript` editor-type fields. Typically built from a schema via
75
+ * `generateTypeDefinitions()` so users get autocomplete + type errors
76
+ * against the runtime context they'll see.
77
+ */
78
+ typeDefinitions?: string;
79
+ /**
80
+ * Optional list of environment-variable names that are available to
81
+ * `shell` editor-type fields. When provided, Monaco autocompletes them
82
+ * after `$` and `${`. Use this to surface platform-injected vars like
83
+ * `EVENT_ID`, `PAYLOAD_*` etc. so users don't have to remember the names.
84
+ */
85
+ shellEnvVars?: ShellEnvVar[];
86
+ /**
87
+ * Optional initial content per editor language, used to populate empty
88
+ * fields with a working example. Keyed by `EditorType`.
89
+ */
90
+ starterTemplates?: EditorStarterTemplates;
62
91
  }
63
92
 
64
93
  /** Props for the FormField component */
@@ -71,10 +100,14 @@ export interface FormFieldProps {
71
100
  formValues: Record<string, unknown>;
72
101
  optionsResolvers?: Record<string, OptionsResolver>;
73
102
  templateProperties?: TemplateProperty[];
103
+ typeDefinitions?: string;
104
+ shellEnvVars?: ShellEnvVar[];
105
+ starterTemplates?: EditorStarterTemplates;
74
106
  /** Callback when value changes. Omit val to clear the field. */
75
107
  onChange: (val?: unknown) => void;
76
108
  }
77
109
 
110
+
78
111
  /** Props for the DynamicOptionsField component */
79
112
  export interface DynamicOptionsFieldProps {
80
113
  id: string;
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { Inbox } from "lucide-react";
3
+ import { EmptyState } from "./EmptyState";
4
+
5
+ interface ListEmptyStateProps {
6
+ /**
7
+ * The name of the resource type the list would display (e.g. `"checks"`,
8
+ * `"incidents"`). Drives the default `"No {resource} yet"` headline.
9
+ */
10
+ resource: string;
11
+ /**
12
+ * Optional supplemental description rendered beneath the headline.
13
+ */
14
+ description?: React.ReactNode;
15
+ /**
16
+ * Optional action area, typically a primary CTA button such as
17
+ * "Create your first check".
18
+ */
19
+ actions?: React.ReactNode;
20
+ /**
21
+ * Optional icon override. Defaults to the lucide `Inbox` glyph so callers
22
+ * don't have to pick one for every list.
23
+ */
24
+ icon?: React.ReactNode;
25
+ }
26
+
27
+ /**
28
+ * ListEmptyState - the canonical empty state for a list-shaped resource.
29
+ *
30
+ * Thin wrapper around {@link EmptyState} that supplies a consistent
31
+ * "No {resource} yet" headline and a sensible default icon. Use this on
32
+ * any page that renders a list and may have zero items so the UX stays
33
+ * uniform across plugins.
34
+ */
35
+ export const ListEmptyState: React.FC<ListEmptyStateProps> = ({
36
+ resource,
37
+ description,
38
+ actions,
39
+ icon,
40
+ }) => {
41
+ const resolvedIcon = icon ?? <Inbox className="h-10 w-10" />;
42
+
43
+ return (
44
+ <EmptyState
45
+ title={`No ${resource} yet`}
46
+ description={description}
47
+ icon={resolvedIcon}
48
+ actions={actions}
49
+ />
50
+ );
51
+ };
@@ -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";