@savvifi/meridian-web-react 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@savvifi/meridian-web-react",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "React adapter for the meridian WebRenderer seam: a kit-agnostic core (MeridianProvider, PanelRenderer, ComponentKit, reactWebRenderer) that renders meridian.ui.v1 PanelDescriptors through a swappable ComponentKit (MUI, shadcn, ). The premium web renderer tier of the meridian renderer family.",
5
+ "description": "React adapter for the meridian WebRenderer seam: a kit-agnostic core (MeridianProvider, PanelRenderer, ComponentKit, reactWebRenderer) that renders meridian.ui.v1 PanelDescriptors through a swappable ComponentKit (MUI, shadcn, \u2026). The premium web renderer tier of the meridian renderer family.",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -23,8 +23,8 @@
23
23
  "src/**/*.d.ts"
24
24
  ],
25
25
  "dependencies": {
26
- "@savvifi/meridian-proto-ts": "^0.1.0",
27
- "@savvifi/meridian-schemas": "^0.1.0",
26
+ "@savvifi/meridian-proto-ts": "^0.3.0",
27
+ "@savvifi/meridian-schemas": "^0.3.0",
28
28
  "@bufbuild/protobuf": "2.12.1"
29
29
  },
30
30
  "peerDependencies": {
@@ -40,6 +40,8 @@
40
40
  "vitest": "2.1.4"
41
41
  },
42
42
  "pnpm": {
43
- "onlyBuiltDependencies": ["esbuild"]
43
+ "onlyBuiltDependencies": [
44
+ "esbuild"
45
+ ]
44
46
  }
45
47
  }
@@ -2,10 +2,11 @@ import type { ComponentType, CSSProperties, ReactNode } from "react";
2
2
  import type { GalleryPanel } from "@savvifi/meridian-proto-ts/proto/gallery_pb.js";
3
3
  import type { LlmPromptPanel } from "@savvifi/meridian-proto-ts/proto/llm_prompt_pb.js";
4
4
  import type { LroPanel } from "@savvifi/meridian-proto-ts/proto/lro_pb.js";
5
- import type { PanelDescriptor } from "@savvifi/meridian-proto-ts/proto/panel_pb.js";
5
+ import type { FormPanel, PanelDescriptor } from "@savvifi/meridian-proto-ts/proto/panel_pb.js";
6
6
  import type { PromptPanel } from "@savvifi/meridian-proto-ts/proto/prompt_pb.js";
7
7
  import type { TablePanel } from "@savvifi/meridian-proto-ts/proto/table_pb.js";
8
8
  import type { Theme } from "@savvifi/meridian-proto-ts/proto/theme_pb.js";
9
+ import type { Action } from "@savvifi/meridian-proto-ts/proto/view_pb.js";
9
10
  import type { RpcInvoker } from "@savvifi/meridian-schemas/uiview";
10
11
  /** Props every shape component receives: its panel, the parent descriptor, transport. */
11
12
  export interface ShapeProps<P> {
@@ -18,6 +19,12 @@ export type PromptPanelProps = ShapeProps<PromptPanel>;
18
19
  export type LroPanelProps = ShapeProps<LroPanel>;
19
20
  export type GalleryPanelProps = ShapeProps<GalleryPanel>;
20
21
  export type LlmPromptPanelProps = ShapeProps<LlmPromptPanel>;
22
+ export type FormPanelProps = ShapeProps<FormPanel>;
23
+ /** Props for a kit's action bar: the actions to render + transport to fire them. */
24
+ export interface ActionBarProps {
25
+ actions: Action[];
26
+ invoker: RpcInvoker;
27
+ }
21
28
  /** A set of components + a theme binding that paints meridian panels. */
22
29
  export interface ComponentKit {
23
30
  /** Stable id, e.g. "mui" / "shadcn" / "html". Used in the renderer id. */
@@ -36,6 +43,8 @@ export interface ComponentKit {
36
43
  Table: ComponentType<TablePanelProps>;
37
44
  Prompt: ComponentType<PromptPanelProps>;
38
45
  Lro: ComponentType<LroPanelProps>;
46
+ /** Entity "detail section" / CRUD form (FormPanel: READONLY | EDIT). */
47
+ Form: ComponentType<FormPanelProps>;
39
48
  /** Optional richer shapes; PanelRenderer falls back when a kit omits them. */
40
49
  Gallery?: ComponentType<GalleryPanelProps>;
41
50
  LlmPrompt?: ComponentType<LlmPromptPanelProps>;
@@ -43,4 +52,9 @@ export interface ComponentKit {
43
52
  Fallback: ComponentType<{
44
53
  descriptor: PanelDescriptor;
45
54
  }>;
55
+ /**
56
+ * Renders a view/slot's actions (ViewDescriptor.actions / Slot.actions).
57
+ * Optional — ViewRenderer falls back to plain buttons when a kit omits it.
58
+ */
59
+ ActionBar?: ComponentType<ActionBarProps>;
46
60
  }
package/src/html_kit.js CHANGED
@@ -24,6 +24,9 @@ export const htmlKit = {
24
24
  Table: ({ panel }) => (_jsxs("table", { className: "mer-table", children: [_jsx("thead", { children: _jsx("tr", { children: panel.columns.map((col, i) => (_jsx("th", { children: col.header }, i))) }) }), _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { className: "mer-empty", colSpan: panel.columns.length || 1, children: panel.placeholder || "(load to populate)" }) }) })] })),
25
25
  Prompt: ({ panel }) => (_jsx("form", { className: "mer-prompt", children: panel.fields.map((field) => (_jsx("label", { className: "mer-field", children: _jsx("span", { children: field.label }) }, field.fieldId))) })),
26
26
  Lro: ({ panel }) => (_jsx("div", { className: "mer-lro", children: _jsx("button", { type: "button", children: panel.runButtonLabel || "Run" }) })),
27
+ Form: ({ panel }) => (
28
+ // FORM_MODE_EDIT = 2; anything else renders read-only.
29
+ _jsx("form", { className: "mer-form", "data-mode": panel.mode, children: panel.fields.map((field) => (_jsxs("label", { className: "mer-field", children: [_jsx("span", { className: "mer-field-label", children: field.label }), panel.mode === 2 ? (_jsx("input", { className: "mer-field-input", name: field.fieldId })) : (_jsx("span", { className: "mer-field-value", "data-field": field.fieldId }))] }, field.fieldId))) })),
27
30
  Fallback: ({ descriptor }) => (_jsx("pre", { className: "mer-fallback", children: descriptor.body.case
28
31
  ? `unsupported panel shape: ${descriptor.body.case}`
29
32
  : "(empty panel)" })),
package/src/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- export type { ComponentKit, ShapeProps, TablePanelProps, PromptPanelProps, LroPanelProps, GalleryPanelProps, LlmPromptPanelProps, } from "./component_kit.js";
1
+ export type { ComponentKit, ShapeProps, TablePanelProps, PromptPanelProps, LroPanelProps, GalleryPanelProps, LlmPromptPanelProps, FormPanelProps, ActionBarProps, } from "./component_kit.js";
2
2
  export { MeridianProvider, useMeridian, useMeridianTheme, useRpcInvoker, useComponentKit, useAdhocHandler, type MeridianContextValue, type MeridianProviderProps, type ReactAdhocFactory, } from "./provider.js";
3
3
  export { PanelRenderer } from "./panel_renderer.js";
4
+ export { ViewRenderer } from "./view_renderer.js";
5
+ export { usePagedRows, resolvePagination, buildPageRequest, readPage, PaginationMode, type PagedTable, } from "./pagination.js";
4
6
  export { reactWebRenderer } from "./react_web_renderer.js";
5
7
  export { htmlKit } from "./html_kit.js";
8
+ export { shadcnKit } from "./shadcn_kit.js";
package/src/index.js CHANGED
@@ -6,5 +6,8 @@
6
6
  // can mount, a peer of the web-components / TUI / native renderers.
7
7
  export { MeridianProvider, useMeridian, useMeridianTheme, useRpcInvoker, useComponentKit, useAdhocHandler, } from "./provider.js";
8
8
  export { PanelRenderer } from "./panel_renderer.js";
9
+ export { ViewRenderer } from "./view_renderer.js";
10
+ export { usePagedRows, resolvePagination, buildPageRequest, readPage, PaginationMode, } from "./pagination.js";
9
11
  export { reactWebRenderer } from "./react_web_renderer.js";
10
12
  export { htmlKit } from "./html_kit.js";
13
+ export { shadcnKit } from "./shadcn_kit.js";
@@ -0,0 +1,42 @@
1
+ import { PaginationMode, type Pagination, type TablePanel } from "@savvifi/meridian-proto-ts/proto/table_pb.js";
2
+ import type { RpcInvoker } from "@savvifi/meridian-schemas/uiview";
3
+ export { PaginationMode } from "@savvifi/meridian-proto-ts/proto/table_pb.js";
4
+ type Row = Record<string, unknown>;
5
+ /** Effective mode (UNSPECIFIED ⇒ CLIENT) + page size (0 ⇒ default) for a panel. */
6
+ export declare function resolvePagination(panel: TablePanel): {
7
+ pagination?: Pagination;
8
+ mode: PaginationMode;
9
+ pageSize: number;
10
+ };
11
+ /** Build the `populate` request for a page. CLIENT ⇒ {}. Pure. */
12
+ export declare function buildPageRequest(pagination: Pagination | undefined, opts: {
13
+ page?: number;
14
+ cursor?: string;
15
+ }): Row;
16
+ /** Read a page (rows + total + next cursor) out of a `populate` response. Pure. */
17
+ export declare function readPage(pagination: Pagination | undefined, response: unknown, rowsField: string): {
18
+ rows: Row[];
19
+ total?: number;
20
+ nextCursor?: string;
21
+ };
22
+ export interface PagedTable {
23
+ mode: PaginationMode;
24
+ pageSize: number;
25
+ rows: Row[];
26
+ loading: boolean;
27
+ /** 0-based page index (OFFSET / CURSOR). */
28
+ page: number;
29
+ /** Total rows (OFFSET; CLIENT reports the fetched count). */
30
+ total?: number;
31
+ hasPrev: boolean;
32
+ hasNext: boolean;
33
+ goPrev: () => void;
34
+ goNext: () => void;
35
+ /** Jump to a page (OFFSET only; no-op otherwise). */
36
+ setPage: (page: number) => void;
37
+ }
38
+ /**
39
+ * Fetch + page a TablePanel's rows through the invoker. CLIENT returns all rows
40
+ * (the kit's table paginates locally); OFFSET / CURSOR fetch one page at a time.
41
+ */
42
+ export declare function usePagedRows(panel: TablePanel, invoker: RpcInvoker): PagedTable;
@@ -0,0 +1,169 @@
1
+ // Pagination — the kit-agnostic brain that turns a meridian.ui.v1 TablePanel's
2
+ // `Pagination` into fetched rows + page controls, via the RpcInvoker.
3
+ //
4
+ // Every kit's Table component can drive its own pager off `usePagedRows`, so the
5
+ // three modes behave identically regardless of the component library:
6
+ // - CLIENT fetch once (no page params); return ALL rows — the kit's table
7
+ // paginates them locally (e.g. MUI TablePagination). Good for small lists.
8
+ // - OFFSET re-invoke `populate` per page with offset/limit request fields; read
9
+ // the total-count response for page count.
10
+ // - CURSOR re-invoke `populate` with a cursor request field; read the next-cursor
11
+ // response (empty ⇒ end). Page-at-a-time (a visited-cursor stack backs prev).
12
+ //
13
+ // The request/response field wiring is pure + separately testable
14
+ // (`buildPageRequest` / `readPage`); the hook is thin glue over them.
15
+ import { useEffect, useState } from "react";
16
+ import { PaginationMode, } from "@savvifi/meridian-proto-ts/proto/table_pb.js";
17
+ export { PaginationMode } from "@savvifi/meridian-proto-ts/proto/table_pb.js";
18
+ const DEFAULT_PAGE_SIZE = 20;
19
+ /** Follow a dotted path (e.g. "page.offset") into a value. */
20
+ function getNested(source, path) {
21
+ if (!path)
22
+ return undefined;
23
+ return path.split(".").reduce((acc, key) => {
24
+ if (acc && typeof acc === "object")
25
+ return acc[key];
26
+ return undefined;
27
+ }, source);
28
+ }
29
+ /** Set a dotted path (e.g. "page.offset") on a request object, creating parents. */
30
+ function setNested(target, path, value) {
31
+ if (!path)
32
+ return;
33
+ const keys = path.split(".");
34
+ let cursor = target;
35
+ for (let i = 0; i < keys.length - 1; i++) {
36
+ const key = keys[i];
37
+ if (typeof cursor[key] !== "object" || cursor[key] === null)
38
+ cursor[key] = {};
39
+ cursor = cursor[key];
40
+ }
41
+ cursor[keys[keys.length - 1]] = value;
42
+ }
43
+ /** Effective mode (UNSPECIFIED ⇒ CLIENT) + page size (0 ⇒ default) for a panel. */
44
+ export function resolvePagination(panel) {
45
+ const pagination = panel.pagination;
46
+ let mode = pagination?.mode ?? PaginationMode.CLIENT;
47
+ if (mode === PaginationMode.UNSPECIFIED)
48
+ mode = PaginationMode.CLIENT;
49
+ const pageSize = pagination?.pageSize && pagination.pageSize > 0
50
+ ? pagination.pageSize
51
+ : DEFAULT_PAGE_SIZE;
52
+ return { pagination, mode, pageSize };
53
+ }
54
+ /** Build the `populate` request for a page. CLIENT ⇒ {}. Pure. */
55
+ export function buildPageRequest(pagination, opts) {
56
+ const request = {};
57
+ if (!pagination)
58
+ return request;
59
+ if (pagination.mode === PaginationMode.OFFSET) {
60
+ const page = opts.page ?? 0;
61
+ const pageSize = pagination.pageSize > 0 ? pagination.pageSize : DEFAULT_PAGE_SIZE;
62
+ if (pagination.offsetRequestField)
63
+ setNested(request, pagination.offsetRequestField, page * pageSize);
64
+ if (pagination.limitRequestField)
65
+ setNested(request, pagination.limitRequestField, pageSize);
66
+ }
67
+ else if (pagination.mode === PaginationMode.CURSOR) {
68
+ if (pagination.cursorRequestField && opts.cursor)
69
+ setNested(request, pagination.cursorRequestField, opts.cursor);
70
+ }
71
+ return request;
72
+ }
73
+ /** Read a page (rows + total + next cursor) out of a `populate` response. Pure. */
74
+ export function readPage(pagination, response, rowsField) {
75
+ const list = getNested(response, rowsField);
76
+ const rows = Array.isArray(list) ? list : [];
77
+ let total;
78
+ let nextCursor;
79
+ if (pagination?.totalField) {
80
+ const value = getNested(response, pagination.totalField);
81
+ total = typeof value === "number" ? value : Number(value) || undefined;
82
+ }
83
+ if (pagination?.nextCursorField) {
84
+ const value = getNested(response, pagination.nextCursorField);
85
+ nextCursor = typeof value === "string" ? value : undefined;
86
+ }
87
+ return { rows, total, nextCursor };
88
+ }
89
+ /**
90
+ * Fetch + page a TablePanel's rows through the invoker. CLIENT returns all rows
91
+ * (the kit's table paginates locally); OFFSET / CURSOR fetch one page at a time.
92
+ */
93
+ export function usePagedRows(panel, invoker) {
94
+ const { pagination, mode, pageSize } = resolvePagination(panel);
95
+ const [page, setPageState] = useState(0);
96
+ // Cursor visited for each page index (CURSOR mode); index 0 = "" (first page).
97
+ const [cursorStack, setCursorStack] = useState([""]);
98
+ const [rows, setRows] = useState([]);
99
+ const [total, setTotal] = useState(undefined);
100
+ const [nextCursor, setNextCursor] = useState(undefined);
101
+ const [loading, setLoading] = useState(Boolean(panel.populate));
102
+ // Reset paging when the panel identity changes.
103
+ useEffect(() => {
104
+ setPageState(0);
105
+ setCursorStack([""]);
106
+ }, [panel]);
107
+ useEffect(() => {
108
+ if (!panel.populate) {
109
+ setLoading(false);
110
+ return;
111
+ }
112
+ let cancelled = false;
113
+ setLoading(true);
114
+ const cursor = mode === PaginationMode.CURSOR ? cursorStack[page] ?? "" : undefined;
115
+ const request = mode === PaginationMode.CLIENT
116
+ ? {}
117
+ : buildPageRequest(pagination, { page, cursor });
118
+ invoker
119
+ .invoke(panel.populate.service, panel.populate.method, request)
120
+ .then((response) => {
121
+ if (cancelled)
122
+ return;
123
+ const read = readPage(pagination, response, panel.rowsField);
124
+ setRows(read.rows);
125
+ setTotal(mode === PaginationMode.CLIENT ? read.rows.length : read.total);
126
+ setNextCursor(read.nextCursor);
127
+ })
128
+ .catch(() => {
129
+ if (!cancelled)
130
+ setRows([]);
131
+ })
132
+ .finally(() => {
133
+ if (!cancelled)
134
+ setLoading(false);
135
+ });
136
+ return () => {
137
+ cancelled = true;
138
+ };
139
+ // cursorStack is intentionally omitted: goNext updates it together with page,
140
+ // so the page change drives the refetch.
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [panel, invoker, page, mode]);
143
+ const hasPrev = page > 0;
144
+ const hasNext = mode === PaginationMode.OFFSET
145
+ ? total !== undefined
146
+ ? (page + 1) * pageSize < total
147
+ : rows.length === pageSize
148
+ : mode === PaginationMode.CURSOR
149
+ ? Boolean(nextCursor)
150
+ : false; // CLIENT: the kit's own pager decides
151
+ const goNext = () => {
152
+ if (mode === PaginationMode.CURSOR) {
153
+ if (!nextCursor)
154
+ return;
155
+ setCursorStack((stack) => {
156
+ const next = [...stack];
157
+ next[page + 1] = nextCursor;
158
+ return next;
159
+ });
160
+ }
161
+ setPageState((current) => current + 1);
162
+ };
163
+ const goPrev = () => setPageState((current) => (current > 0 ? current - 1 : current));
164
+ const setPage = (target) => {
165
+ if (mode === PaginationMode.OFFSET)
166
+ setPageState(Math.max(0, target));
167
+ };
168
+ return { mode, pageSize, rows, loading, page, total, hasPrev, hasNext, goNext, goPrev, setPage };
169
+ }
@@ -14,6 +14,9 @@ export function PanelRenderer({ descriptor, }) {
14
14
  case "lro":
15
15
  inner = (_jsx(kit.Lro, { panel: body.value, descriptor: descriptor, invoker: invoker }));
16
16
  break;
17
+ case "form":
18
+ inner = (_jsx(kit.Form, { panel: body.value, descriptor: descriptor, invoker: invoker }));
19
+ break;
17
20
  case "gallery":
18
21
  inner = kit.Gallery ? (_jsx(kit.Gallery, { panel: body.value, descriptor: descriptor, invoker: invoker })) : (_jsx(kit.Fallback, { descriptor: descriptor }));
19
22
  break;
@@ -0,0 +1,2 @@
1
+ import type { ComponentKit } from "./component_kit.js";
2
+ export declare const shadcnKit: ComponentKit;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Bind the meridian palette to shadcn/ui's CSS custom properties so shadcn
3
+ // Tailwind classes (bg-card, text-muted-foreground, border, …) paint the skin.
4
+ // (Hex here for the reference; a production shadcn-kit would emit hsl triplets
5
+ // for the `hsl(var(--token))` convention.)
6
+ function themeToStyle(theme) {
7
+ if (!theme)
8
+ return {};
9
+ const pal = theme.dark ?? theme.light;
10
+ if (!pal)
11
+ return {};
12
+ return {
13
+ ["--background"]: pal.bg,
14
+ ["--card"]: pal.surface,
15
+ ["--foreground"]: pal.fg,
16
+ ["--muted-foreground"]: pal.muted,
17
+ ["--border"]: pal.border,
18
+ ["--primary"]: pal.accent,
19
+ ["--primary-foreground"]: pal.onAccent,
20
+ ["--destructive"]: pal.danger,
21
+ background: "var(--background)",
22
+ color: "var(--foreground)",
23
+ };
24
+ }
25
+ export const shadcnKit = {
26
+ id: "shadcn",
27
+ themeToStyle,
28
+ Chrome: ({ descriptor, children }) => (_jsxs("section", { className: "rounded-lg border bg-card text-card-foreground shadow-sm", children: [_jsx("header", { className: "border-b px-4 py-3", children: _jsx("h3", { className: "text-sm font-semibold leading-none tracking-tight", children: descriptor.title || descriptor.panelId }) }), _jsx("div", { className: "p-4", children: children })] })),
29
+ Table: ({ panel }) => (_jsx("div", { className: "relative w-full overflow-auto", children: _jsxs("table", { className: "w-full caption-bottom text-sm", children: [_jsx("thead", { className: "[&_tr]:border-b", children: _jsx("tr", { className: "border-b transition-colors", children: panel.columns.map((col, i) => (_jsx("th", { className: "h-10 px-2 text-left align-middle font-medium text-muted-foreground", children: col.header }, i))) }) }), _jsx("tbody", { className: "[&_tr:last-child]:border-0", children: _jsx("tr", { className: "border-b transition-colors hover:bg-muted/50", children: _jsx("td", { className: "p-2 align-middle text-muted-foreground", colSpan: panel.columns.length || 1, children: panel.placeholder || "(load to populate)" }) }) })] }) })),
30
+ Prompt: ({ panel }) => (_jsx("form", { className: "grid gap-4", children: panel.fields.map((field) => (_jsx("div", { className: "grid gap-2", children: _jsx("label", { className: "text-sm font-medium leading-none", children: field.label }) }, field.fieldId))) })),
31
+ Lro: ({ panel }) => (_jsx("div", { className: "flex items-center gap-2", children: _jsx("button", { type: "button", className: "inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground", children: panel.runButtonLabel || "Run" }) })),
32
+ Form: ({ panel }) => (
33
+ // FORM_MODE_EDIT = 2; anything else renders read-only.
34
+ _jsx("form", { className: "grid gap-4", "data-mode": panel.mode, children: panel.fields.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-sm font-medium leading-none", children: field.label }), panel.mode === 2 ? (_jsx("input", { name: field.fieldId, className: "flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm" })) : (_jsx("span", { className: "text-sm text-muted-foreground", "data-field": field.fieldId }))] }, field.fieldId))) })),
35
+ Fallback: ({ descriptor }) => (_jsx("div", { className: "rounded-md border border-dashed p-4 text-sm text-muted-foreground", children: descriptor.body.case
36
+ ? `unsupported panel shape: ${descriptor.body.case}`
37
+ : "(empty panel)" })),
38
+ };
@@ -0,0 +1,6 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ViewDescriptor } from "@savvifi/meridian-proto-ts/proto/view_pb.js";
3
+ /** Renders a ViewDescriptor. The layout mode selects the arrangement of slots. */
4
+ export declare function ViewRenderer({ view }: {
5
+ view: ViewDescriptor;
6
+ }): ReactNode;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // ViewRenderer — the composition/layout tier of the React renderer.
3
+ //
4
+ // Renders a meridian.ui.v1 ViewDescriptor: applies the Layout (list / stacked /
5
+ // tabbed / two-column), delegates each Slot's panel to PanelRenderer (so the
6
+ // kit-agnostic panel dispatch is reused), and renders view- and slot-level
7
+ // Actions via the invoker. The layout STRUCTURE is kit-agnostic; the panels and
8
+ // the action affordances come from the active ComponentKit.
9
+ import { useState } from "react";
10
+ import { PanelRenderer } from "./panel_renderer.js";
11
+ import { useMeridian } from "./provider.js";
12
+ // Fire an action's RpcCall. Binding resolution (row/form context → request
13
+ // fields) is a later increment; the first cut fires with an empty request and
14
+ // lets the invoker/backend supply defaults.
15
+ function fireAction(invoker, action) {
16
+ if (action.call) {
17
+ void invoker.invoke(action.call.service, action.call.method, {});
18
+ }
19
+ }
20
+ function ActionsView({ actions }) {
21
+ const { kit, invoker } = useMeridian();
22
+ if (!actions || actions.length === 0)
23
+ return null;
24
+ if (kit.ActionBar) {
25
+ return _jsx(kit.ActionBar, { actions: actions, invoker: invoker });
26
+ }
27
+ return (_jsx("div", { className: "mer-actions", children: actions.map((a) => (_jsx("button", { type: "button", onClick: () => fireAction(invoker, a), children: a.label }, a.id))) }));
28
+ }
29
+ // One slot: an optional title, the panel, and any slot (row) actions.
30
+ function SlotView({ slot }) {
31
+ const panel = slot.panel;
32
+ if (!panel)
33
+ return null;
34
+ const title = slot.title || panel.title;
35
+ return (_jsxs("section", { className: "mer-slot", "data-slot": slot.id, "data-role": slot.role, children: [title ? _jsx("h3", { className: "mer-slot-title", children: title }) : null, _jsx(PanelRenderer, { descriptor: panel }), _jsx(ActionsView, { actions: slot.actions })] }));
36
+ }
37
+ function byPosition(a, b) {
38
+ return (a.position || 0) - (b.position || 0);
39
+ }
40
+ /** Renders a ViewDescriptor. The layout mode selects the arrangement of slots. */
41
+ export function ViewRenderer({ view }) {
42
+ const slots = [...view.slots].sort(byPosition);
43
+ const layout = view.layout?.mode;
44
+ let body;
45
+ switch (layout?.case) {
46
+ case "tabbed":
47
+ body = _jsx(TabbedSlots, { slots: slots });
48
+ break;
49
+ case "twoColumn":
50
+ body = _jsx(TwoColumnSlots, { slots: slots });
51
+ break;
52
+ case "list":
53
+ case "stacked":
54
+ default:
55
+ // list + stacked both render slots in order; a list view is just a
56
+ // single-content-slot stack.
57
+ body = (_jsx("div", { className: "mer-stack", children: slots.map((s) => (_jsx(SlotView, { slot: s }, s.id))) }));
58
+ }
59
+ return (_jsxs("div", { className: "mer-view", "data-view": view.id, "data-kind": view.kind, children: [_jsxs("header", { className: "mer-view-header", children: [_jsx("h2", { className: "mer-view-title", children: view.title }), _jsx(ActionsView, { actions: view.actions })] }), body] }));
60
+ }
61
+ function TabbedSlots({ slots }) {
62
+ const [active, setActive] = useState(0);
63
+ const ordered = [...slots].sort((a, b) => (a.placement?.tabPosition || 0) - (b.placement?.tabPosition || 0));
64
+ return (_jsxs("div", { className: "mer-tabs", children: [_jsx("div", { className: "mer-tabstrip", role: "tablist", children: ordered.map((s, i) => (_jsx("button", { type: "button", role: "tab", "aria-selected": i === active, className: i === active ? "mer-tab active" : "mer-tab", onClick: () => setActive(i), children: s.placement?.tabLabel || s.title || s.id }, s.id))) }), ordered[active] ? _jsx(SlotView, { slot: ordered[active] }) : null] }));
65
+ }
66
+ function TwoColumnSlots({ slots }) {
67
+ // Column.COLUMN_SIDEBAR = 2; everything else (main / unspecified) is main.
68
+ const sidebar = slots.filter((s) => s.placement?.column === 2);
69
+ const main = slots.filter((s) => s.placement?.column !== 2);
70
+ return (_jsxs("div", { className: "mer-two-column", children: [_jsx("div", { className: "mer-col-main", children: main.map((s) => (_jsx(SlotView, { slot: s }, s.id))) }), _jsx("aside", { className: "mer-col-sidebar", children: sidebar.map((s) => (_jsx(SlotView, { slot: s }, s.id))) })] }));
71
+ }