diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index 560aa625f..1a329b7d5 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -281,6 +281,7 @@ function useTriggerAction(): ( action?: { type?: string; params?: Record }, ) => void; function useIsStreaming(): boolean; +function useIsQueryLoading(): boolean; function useGetFieldValue(): (formName: string | undefined, name: string) => any; function useSetFieldValue(): ( formName: string | undefined, diff --git a/docs/content/docs/api-reference/react-ui.mdx b/docs/content/docs/api-reference/react-ui.mdx index d181ed509..86a960d7f 100644 --- a/docs/content/docs/api-reference/react-ui.mdx +++ b/docs/content/docs/api-reference/react-ui.mdx @@ -136,3 +136,33 @@ npx @openuidev/cli@latest generate ./src/library.ts --out src/generated/system-p // Standalone renderer ``` + +## Skeleton components + +`Skeleton` and `TableSkeleton` are loading-state placeholders that render while data-driven genui components wait for `Query()` results. They use theme-aware CSS variables and a pulsing opacity animation. + +```tsx +import { Skeleton, TableSkeleton } from "@openuidev/react-ui"; +``` + +### `Skeleton` + +A generic skeleton bar. Use standalone or with `count` for stacked bars. + +| Prop | Type | Default | Description | +|------------|----------|--------------|-------------| +| `count` | `number` | `1` | Number of bars to stack | +| `height` | `string` | `"16px"` | Bar height | +| `width` | `string` | `"100%"` | Bar width | +| `borderRadius` | `string` | `undefined` | Bar border radius (defaults to `--openui-radius-xs`) | + +### `TableSkeleton` + +A table-shaped placeholder used by the genui `Table` component while queries load. + +| Prop | Type | Default | Description | +|---------|----------|---------|-------------| +| `rows` | `number` | `5` | Number of skeleton rows | +| `columns` | `number` | `4` | Number of skeleton columns | + +Built-in genui components like `Table` automatically render `TableSkeleton` when `useIsQueryLoading()` is `true` and no data rows exist yet. diff --git a/docs/content/docs/openui-lang/renderer.mdx b/docs/content/docs/openui-lang/renderer.mdx index 2942b8cfe..51ee3aba2 100644 --- a/docs/content/docs/openui-lang/renderer.mdx +++ b/docs/content/docs/openui-lang/renderer.mdx @@ -133,6 +133,7 @@ Use these inside components defined with `defineComponent(...)`. See [Reactive S - `useStateField(name, value?)` - reactive `$variable` binding (preferred for inputs) - `useIsStreaming()` +- `useIsQueryLoading()` - `useTriggerAction()` - `useRenderNode()` - `useFormValidation()` diff --git a/examples/openui-dashboard/.env.example b/examples/openui-dashboard/.env.example new file mode 100644 index 000000000..0aa4768b4 --- /dev/null +++ b/examples/openui-dashboard/.env.example @@ -0,0 +1,2 @@ +# Simulated network delay for mock tool calls (ms). Increase to test loading states. +NEXT_PUBLIC_TOOL_DELAY_MS= diff --git a/examples/openui-dashboard/.gitignore b/examples/openui-dashboard/.gitignore index 5ef6a5207..7b8da95f5 100644 --- a/examples/openui-dashboard/.gitignore +++ b/examples/openui-dashboard/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/examples/openui-dashboard/src/app/api/chat/route.ts b/examples/openui-dashboard/src/app/api/chat/route.ts index b2bcaaeaf..9daf07cf8 100644 --- a/examples/openui-dashboard/src/app/api/chat/route.ts +++ b/examples/openui-dashboard/src/app/api/chat/route.ts @@ -4,9 +4,12 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio import { generatePrompt } from "@openuidev/lang-core"; import { promptSpec } from "@/prompt-config"; import { tools as toolDefs } from "@/tools"; -import { sseResponseFromRunner } from "@/lib/sse-stream"; -const tools = toolDefs.map((t) => t.toOpenAITool()); +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", +} as const; function buildSystemPrompt(): string { return generatePrompt({ @@ -30,13 +33,26 @@ export async function POST(req: NextRequest) { } const client = new OpenAI({ apiKey, baseURL }); - const runner = client.chat.completions.runTools({ + const stream = await client.chat.completions.create({ model, messages: [{ role: "system" as const, content: buildSystemPrompt() }, ...messages], - tools, stream: true, // reasoning: { effort: "low" }, }); - return sseResponseFromRunner(runner); + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of stream) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + } finally { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return new Response(readable, { headers: SSE_HEADERS }); } diff --git a/examples/openui-dashboard/src/lib/sse-stream.ts b/examples/openui-dashboard/src/lib/sse-stream.ts deleted file mode 100644 index aedfe4c50..000000000 --- a/examples/openui-dashboard/src/lib/sse-stream.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ChatCompletionStreamingRunner } from "openai/lib/ChatCompletionStreamingRunner"; - -const SSE_HEADERS = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", -} as const; - -function sseEvent(data: unknown): string { - return `data: ${JSON.stringify(data)}\n\n`; -} - -function toolCallStartChunk(id: string, name: string, index: number) { - return { - id: `chatcmpl-tc-${id}`, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { - tool_calls: [{ index, id, type: "function", function: { name, arguments: "" } }], - }, - finish_reason: null, - }, - ], - }; -} - -function toolCallArgsChunk(id: string, rawArgs: string, result: string, index: number) { - let enrichedArgs: string; - try { - enrichedArgs = JSON.stringify({ - _request: JSON.parse(rawArgs), - _response: JSON.parse(result), - }); - } catch { - enrichedArgs = rawArgs; - } - return { - id: `chatcmpl-tc-${id}-args`, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, - finish_reason: null, - }, - ], - }; -} - -/** - * Wraps a ChatCompletionStreamingRunner in an SSE Response that - * streams tool-call events and content chunks to the client. - */ -export function sseResponseFromRunner( - runner: ChatCompletionStreamingRunner, -): Response { - const encoder = new TextEncoder(); - let closed = false; - - const readable = new ReadableStream({ - start(controller) { - const send = (text: string) => { - if (closed) return; - try { controller.enqueue(encoder.encode(text)); } catch { /* closed */ } - }; - const finish = () => { - if (closed) return; - closed = true; - try { controller.close(); } catch { /* closed */ } - }; - - const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; - let callIdx = 0; - let resultIdx = 0; - - runner.on("functionToolCall", (fc) => { - const id = `tc-${callIdx}`; - pendingCalls.push({ id, name: fc.name, arguments: fc.arguments }); - send(sseEvent(toolCallStartChunk(id, fc.name, callIdx))); - callIdx++; - }); - - runner.on("functionToolCallResult", (result) => { - const tc = pendingCalls[resultIdx]; - if (tc) { - send(sseEvent(toolCallArgsChunk(tc.id, tc.arguments, result, resultIdx))); - } - resultIdx++; - }); - - runner.on("chunk", (chunk) => { - const choice = chunk.choices?.[0]; - if (!choice?.delta) return; - if (choice.delta.content || choice.finish_reason === "stop") { - send(sseEvent(chunk)); - } - }); - - runner.on("end", () => { - send("data: [DONE]\n\n"); - finish(); - }); - - runner.on("error", (err) => { - console.error("[chat] Error:", err); - send(sseEvent({ error: err.message })); - finish(); - }); - }, - }); - - return new Response(readable, { headers: SSE_HEADERS }); -} diff --git a/examples/openui-dashboard/src/tools.ts b/examples/openui-dashboard/src/tools.ts index 6b1871536..096fad538 100644 --- a/examples/openui-dashboard/src/tools.ts +++ b/examples/openui-dashboard/src/tools.ts @@ -10,6 +10,13 @@ import { ToolDef } from "./lib/tool-def"; // ── Helpers ────────────────────────────────────────────────────────────────── +/** Simulated network delay to surface intermediate loading states (ms). */ +const TOOL_DELAY_MS = Number(process.env.NEXT_PUBLIC_TOOL_DELAY_MS) || 0; + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + function dateOffset(daysAgo: number): string { const d = new Date(); d.setDate(d.getDate() + daysAgo); @@ -152,7 +159,7 @@ export const tools: ToolDef[] = [ }), ), }), - execute: async (args) => getUsageMetrics(args), + execute: async (args) => { await delay(TOOL_DELAY_MS); return getUsageMetrics(args); }, }), new ToolDef({ name: "get_top_endpoints", @@ -168,7 +175,7 @@ export const tools: ToolDef[] = [ }), ), }), - execute: async (args) => getTopEndpoints(args), + execute: async (args) => { await delay(TOOL_DELAY_MS); return getTopEndpoints(args); }, }), new ToolDef({ name: "get_resource_breakdown", @@ -179,7 +186,7 @@ export const tools: ToolDef[] = [ z.object({ name: z.string(), events: z.number(), users: z.number(), cost: z.number() }), ), }), - execute: async () => getResourceBreakdown(), + execute: async () => { await delay(TOOL_DELAY_MS); return getResourceBreakdown(); }, }), new ToolDef({ name: "get_error_breakdown", @@ -188,7 +195,7 @@ export const tools: ToolDef[] = [ outputSchema: z.object({ errors: z.array(z.object({ category: z.string(), count: z.number() })), }), - execute: async () => getErrorBreakdown(), + execute: async () => { await delay(TOOL_DELAY_MS * 2); return getErrorBreakdown(); }, }), new ToolDef({ name: "get_server_health", @@ -203,7 +210,7 @@ export const tools: ToolDef[] = [ z.object({ time: z.string(), cpu: z.number(), memory: z.number(), latencyP95: z.number() }), ), }), - execute: async () => getServerHealth(), + execute: async () => { await delay(TOOL_DELAY_MS); return getServerHealth(); }, }), new ToolDef({ name: "get_experiment_results", @@ -214,7 +221,7 @@ export const tools: ToolDef[] = [ z.object({ variant: z.string(), conversionRate: z.number(), users: z.number() }), ), }), - execute: async () => getExperimentResults(), + execute: async () => { await delay(TOOL_DELAY_MS); return getExperimentResults(); }, }), new ToolDef({ name: "get_geo_usage", @@ -223,13 +230,13 @@ export const tools: ToolDef[] = [ outputSchema: z.object({ regions: z.array(z.object({ region: z.string(), users: z.number(), events: z.number() })), }), - execute: async () => getGeoUsage(), + execute: async () => { await delay(TOOL_DELAY_MS); return getGeoUsage(); }, }), new ToolDef({ name: "get_funnel_metrics", description: "Get conversion funnel metrics", inputSchema: z.object({}), outputSchema: z.object({ steps: z.array(z.object({ step: z.string(), users: z.number() })) }), - execute: async () => getFunnelMetrics(), + execute: async () => { await delay(TOOL_DELAY_MS); return getFunnelMetrics(); }, }), ]; diff --git a/packages/react-lang/src/context.ts b/packages/react-lang/src/context.ts index f2dcf6cf6..e30b4adda 100644 --- a/packages/react-lang/src/context.ts +++ b/packages/react-lang/src/context.ts @@ -27,6 +27,9 @@ export interface OpenUIContextValue { /** Whether the LLM is currently streaming content. */ isStreaming: boolean; + /** Whether any Query is currently fetching data (e.g., MCP tool calls in-flight). */ + isQueryLoading: boolean; + /** Get a field value. Top-level for $bindings, nested under formName for form fields. */ getFieldValue: (formName: string | undefined, name: string) => any; @@ -99,6 +102,14 @@ export function useIsStreaming() { return useOpenUI().isStreaming; } +/** + * Whether any Query is currently fetching data. + * Useful for showing skeleton/loading states in data-driven components. + */ +export function useIsQueryLoading() { + return useOpenUI().isQueryLoading; +} + /** * Get a form field value from the form state context. * diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index d81694386..987008fe0 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -381,6 +381,8 @@ export function useOpenUIState( renderErrorsRef.current.push(error); }, []); + const isQueryLoading = querySnapshot.__openui_loading.length > 0; + // ─── Context value ─── const contextValue = useMemo( () => ({ @@ -393,11 +395,13 @@ export function useOpenUIState( store, evaluationContext, reportError, + isQueryLoading, }), [ library, renderDeep, isStreaming, + isQueryLoading, triggerAction, getFieldValue, setFieldValue, @@ -432,8 +436,6 @@ export function useOpenUIState( } }, [result, evaluationContext, library, store, storeSnapshot, querySnapshot]); - const isQueryLoading = querySnapshot.__openui_loading.length > 0; - // ─── Collect and fire onError ─── const lastErrorKeyRef = useRef(""); useEffect(() => { diff --git a/packages/react-lang/src/index.ts b/packages/react-lang/src/index.ts index b2b8ce947..c6ecf8c29 100644 --- a/packages/react-lang/src/index.ts +++ b/packages/react-lang/src/index.ts @@ -43,6 +43,7 @@ export { FormNameContext, useFormName, useGetFieldValue, + useIsQueryLoading, useIsStreaming, useRenderNode, useSetDefaultValue, diff --git a/packages/react-ui/src/components/Skeleton/Skeleton.tsx b/packages/react-ui/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 000000000..ac6e427c2 --- /dev/null +++ b/packages/react-ui/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,119 @@ +import clsx from "clsx"; + +interface SkeletonBarProps { + height: string; + width: string; + borderRadius?: string; +} + +function SkeletonBar({ height, width, borderRadius }: SkeletonBarProps) { + return ( +
+ ); +} + +export function Skeleton({ + count = 1, + height = "16px", + width = "100%", + borderRadius, + className, +}: { + count?: number; + height?: string; + width?: string; + borderRadius?: string; + className?: string; +}) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+ ); +} + +export function PieChartSkeleton({ + size = 200, + legendItems = 4, + variant = "pie", + appearance = "circular", +}: { + size?: number; + legendItems?: number; + variant?: "pie" | "donut"; + appearance?: "circular" | "semiCircular"; +}) { + const isSemi = appearance === "semiCircular"; + const isDonut = variant === "donut"; + + const chartClass = clsx( + "openui-skeleton-pie-chart-shape", + isSemi && isDonut + ? "openui-skeleton-pie-chart-semi-donut" + : isSemi + ? "openui-skeleton-pie-chart-semi" + : isDonut + ? "openui-skeleton-pie-chart-donut" + : undefined, + ); + + const chartHeight = isSemi ? size / 2 : size; + + return ( +
+
+
+
+
+ {Array.from({ length: legendItems }, (_, i) => ( +
+
+ +
+ ))} +
+
+ ); +} + +export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) { + return ( +
+
+ {Array.from({ length: columns }, (_, i) => ( +
+ +
+ ))} +
+ {Array.from({ length: rows }, (_, ri) => ( +
+ {Array.from({ length: columns }, (_, ci) => ( +
+ +
+ ))} +
+ ))} +
+ ); +} diff --git a/packages/react-ui/src/components/Skeleton/dependencies.ts b/packages/react-ui/src/components/Skeleton/dependencies.ts new file mode 100644 index 000000000..0ff8ff926 --- /dev/null +++ b/packages/react-ui/src/components/Skeleton/dependencies.ts @@ -0,0 +1,2 @@ +const dependencies = ["Skeleton"]; +export default dependencies; diff --git a/packages/react-ui/src/components/Skeleton/index.ts b/packages/react-ui/src/components/Skeleton/index.ts new file mode 100644 index 000000000..55879b843 --- /dev/null +++ b/packages/react-ui/src/components/Skeleton/index.ts @@ -0,0 +1 @@ +export * from "./Skeleton"; diff --git a/packages/react-ui/src/components/Skeleton/skeleton.scss b/packages/react-ui/src/components/Skeleton/skeleton.scss new file mode 100644 index 000000000..4d4f0cef6 --- /dev/null +++ b/packages/react-ui/src/components/Skeleton/skeleton.scss @@ -0,0 +1,112 @@ +@use "../../cssUtils" as cssUtils; + +@keyframes openui-skeleton-pulse { + 0% { + opacity: 0.3; + } + 50% { + opacity: 0.85; + } + 100% { + opacity: 0.3; + } +} + +.openui-skeleton-bar { + background-color: cssUtils.$elevated-strong; + border-radius: cssUtils.$radius-xs; + animation: openui-skeleton-pulse 1.2s ease-in-out infinite; +} + +.openui-skeleton-stack { + display: flex; + flex-direction: column; + gap: cssUtils.$space-s; +} + +.openui-skeleton-table { + padding: cssUtils.$space-s 0; +} + +.openui-skeleton-table-row { + display: grid; + gap: cssUtils.$space-m; + padding: cssUtils.$space-s-m cssUtils.$space-m; + border-bottom: 1px solid cssUtils.$border-default; + + &:last-child { + border-bottom: 0; + } +} + +.openui-skeleton-table-cell { + height: 14px; +} + +.openui-skeleton-table-cell-short { + width: 60%; +} + +// ─── PieChartSkeleton ─── + +.openui-skeleton-pie-chart-wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: cssUtils.$space-m; + padding: cssUtils.$space-m; +} + +.openui-skeleton-pie-chart-container { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.openui-skeleton-pie-chart-shape { + border-radius: 50%; + background-color: cssUtils.$elevated-strong; + animation: openui-skeleton-pulse 1.2s ease-in-out infinite; +} + +// Donut variant — thick border ring, transparent center +.openui-skeleton-pie-chart-donut { + background-color: transparent; + border: cssUtils.$space-xl solid cssUtils.$elevated-strong; +} + +// Semi-circular pie — top half only +.openui-skeleton-pie-chart-semi { + border-radius: cssUtils.$radius-full cssUtils.$radius-full 0 0; +} + +// Semi-circular donut — top half ring +.openui-skeleton-pie-chart-semi-donut { + background-color: transparent; + border: cssUtils.$space-xl solid cssUtils.$elevated-strong; + border-bottom: 0; + border-radius: cssUtils.$radius-full cssUtils.$radius-full 0 0; +} + +.openui-skeleton-pie-chart-legend { + display: flex; + flex-direction: column; + gap: cssUtils.$space-s; + min-width: 120px; +} + +.openui-skeleton-pie-chart-legend-item { + display: flex; + align-items: center; + gap: cssUtils.$space-s; +} + +.openui-skeleton-pie-chart-legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: cssUtils.$elevated-strong; + animation: openui-skeleton-pulse 1.2s ease-in-out infinite; + flex-shrink: 0; +} diff --git a/packages/react-ui/src/components/Skeleton/stories/Skeleton.stories.tsx b/packages/react-ui/src/components/Skeleton/stories/Skeleton.stories.tsx new file mode 100644 index 000000000..32de0edad --- /dev/null +++ b/packages/react-ui/src/components/Skeleton/stories/Skeleton.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { PieChartSkeleton, Skeleton, TableSkeleton } from "../Skeleton"; + +const meta: Meta = { + title: "Components/Skeleton", + component: Skeleton, + parameters: { + layout: "centered", + docs: { + description: { + component: + "```tsx\nimport { Skeleton, TableSkeleton, PieChartSkeleton } from '@openuidev/react-ui';\n```", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ["!dev", "autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "A stack of generic skeleton bars with a pulsing opacity animation.", + }, + }, + }, +}; + +export const Table: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "A table-shaped skeleton placeholder used while query data is loading.", + }, + }, + }, +}; + +export const PieChartCircular: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Skeleton for a full circular pie chart while data is loading.", + }, + }, + }, +}; + +export const PieChartSemiCircular: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Skeleton for a semicircular pie chart while data is loading.", + }, + }, + }, +}; + +export const PieChartDonut: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Skeleton for a full circular donut chart while data is loading.", + }, + }, + }, +}; + +export const PieChartSemiDonut: Story = { + render: () => , + parameters: { + docs: { + description: { + story: "Skeleton for a semicircular donut chart while data is loading.", + }, + }, + }, +}; diff --git a/packages/react-ui/src/components/index.scss b/packages/react-ui/src/components/index.scss index 661dc1456..84471fec1 100644 --- a/packages/react-ui/src/components/index.scss +++ b/packages/react-ui/src/components/index.scss @@ -33,6 +33,7 @@ @forward "./ListItem/listItem.scss"; @forward "./MarkDownRenderer/markDownRenderer.scss"; @forward "./MessageLoading/messageLoading.scss"; +@forward "./Modal/Modal.scss"; @forward "./OpenUIChat/openUIChat.scss"; @forward "./RadioGroup/radioGroup.scss"; @forward "./RadioItem/radioItem.scss"; @@ -40,6 +41,7 @@ @forward "./Select/select.scss"; @forward "./Separator/separator.scss"; @forward "./Shell/shell.scss"; +@forward "./Skeleton/skeleton.scss"; @forward "./Slider/slider.scss"; @forward "./Steps/steps.scss"; @forward "./SwitchGroup/switchGroup.scss"; @@ -56,4 +58,3 @@ @forward "./ToolCall/toolCall.scss"; @forward "./ToolResult/toolResult.scss"; @forward "./_shared/shared.scss"; -@forward "./Modal/Modal.scss"; diff --git a/packages/react-ui/src/genui-lib/Charts/PieChart.ts b/packages/react-ui/src/genui-lib/Charts/PieChart.ts index f513554b1..403d578c8 100644 --- a/packages/react-ui/src/genui-lib/Charts/PieChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/PieChart.ts @@ -1,15 +1,17 @@ "use client"; -import { defineComponent } from "@openuidev/react-lang"; +import { defineComponent, useIsQueryLoading } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod/v4"; import { PieChart as PieChartComponent } from "../../components/Charts"; +import { PieChartSkeleton } from "../../components/Skeleton"; import { asArray, buildSliceData } from "../helpers"; export const PieChartSchema = z.object({ labels: z.array(z.string()), values: z.array(z.number()), variant: z.enum(["pie", "donut"]).optional(), + appearance: z.enum(["circular", "semiCircular"]).optional(), }); export const PieChart = defineComponent({ @@ -17,8 +19,11 @@ export const PieChart = defineComponent({ props: PieChartSchema, description: "Circular slices; use plucked arrays: PieChart(data.categories, data.values)", component: ({ props }) => { + const isQueryLoading = useIsQueryLoading(); const labels = asArray(props.labels) as string[]; const values = asArray(props.values) as number[]; + const variant = (props.variant as "pie" | "donut") ?? "pie"; + const appearance = (props.appearance as "circular" | "semiCircular") ?? "circular"; // New format: labels[] + values[] if (labels.length > 0 && values.length > 0) { @@ -26,12 +31,12 @@ export const PieChart = defineComponent({ category: cat, value: typeof values[i] === "number" ? values[i] : 0, })); - if (!data.length) return null; return React.createElement(PieChartComponent, { data, categoryKey: "category", dataKey: "value", - variant: props.variant as "pie" | "donut" | undefined, + variant, + appearance, isAnimationActive: false, }); } @@ -43,11 +48,19 @@ export const PieChart = defineComponent({ data: sliceData, categoryKey: "category", dataKey: "value", - variant: props.variant as "pie" | "donut" | undefined, + variant, + appearance, isAnimationActive: false, }); } + if (isQueryLoading) { + return React.createElement(PieChartSkeleton, { + variant, + appearance, + }); + } + return null; }, }); diff --git a/packages/react-ui/src/genui-lib/Table/index.tsx b/packages/react-ui/src/genui-lib/Table/index.tsx index 25717fc47..07c0bd2c1 100644 --- a/packages/react-ui/src/genui-lib/Table/index.tsx +++ b/packages/react-ui/src/genui-lib/Table/index.tsx @@ -1,10 +1,11 @@ "use client"; -import { defineComponent } from "@openuidev/react-lang"; +import { defineComponent, useIsQueryLoading } from "@openuidev/react-lang"; import { ChevronLeft, ChevronRight } from "lucide-react"; import React from "react"; import { z } from "zod/v4"; import { IconButton } from "../../components/IconButton"; +import { TableSkeleton } from "../../components/Skeleton"; import { ScrollableTable as OpenUITable, TableBody as OpenUITableBody, @@ -34,12 +35,12 @@ export const Table = defineComponent({ }), description: "Data table — column-oriented. Each Col holds its own data array.", component: ({ props, renderNode }) => { + const isQueryLoading = useIsQueryLoading(); const effectivePageSize = DEFAULT_PAGE_SIZE; const [currentPage, setCurrentPage] = React.useState(0); const columns = props.columns ?? []; - if (!columns.length) return null; const colDefs = columns .filter((c: any) => c != null && c.props) @@ -47,9 +48,15 @@ export const Table = defineComponent({ label: c.props?.label ?? "", data: asArray(c.props?.data ?? []), })); - if (!colDefs.length) return null; - const rowCount = Math.max(...colDefs.map((c) => c.data.length), 0); + const rowCount = colDefs.length > 0 ? Math.max(...colDefs.map((c) => c.data.length), 0) : 0; + + if (isQueryLoading && rowCount === 0) { + const skeletonCols = Math.max(colDefs.length || columns.length, 3); + return ; + } + + if (!colDefs.length) return null; const totalPages = Math.ceil(rowCount / effectivePageSize); const safePage = Math.min(currentPage, Math.max(0, totalPages - 1)); diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 3b5acf053..0d01180fa 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -53,6 +53,7 @@ export * from "./components/SectionBlock"; export * from "./components/Select"; export * from "./components/Separator"; export * as Shell from "./components/Shell"; +export * from "./components/Skeleton"; export * from "./components/Slider"; export * from "./components/Steps"; export * from "./components/SwitchGroup";