@magicvr/schema-ui-ui 0.1.2 → 0.1.3

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.
@@ -0,0 +1,156 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Inbox } from "lucide-react";
3
+ import { resolveAsyncDisplayState } from "@magicvr/schema-ui-ui/components/ui/async-state";
4
+ import { Skeleton } from "@magicvr/schema-ui-ui/components/ui/skeleton";
5
+ import { useTranslate } from "@magicvr/schema-ui-lib/i18n/runtime";
6
+ import { formatDisplayTime } from "@magicvr/schema-ui-lib/lib/datetime";
7
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
8
+ function cellContent(column, row) {
9
+ if (column.render !== undefined) {
10
+ return column.render(row);
11
+ }
12
+ const value = row[column.key];
13
+ // Universal empty fallback: null / undefined / empty string render a
14
+ // muted placeholder so blank cells stay visually consistent.
15
+ if (value === undefined || value === null || String(value) === "") {
16
+ return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
17
+ }
18
+ const text = String(value);
19
+ // Display formatting: ISO-8601 timestamps render as local
20
+ // "YYYY-MM-DD HH:mm" instead of the raw wire value.
21
+ const display = formatDisplayTime(value) ?? text;
22
+ // Universal single-line ellipsis: every string cell truncates at the
23
+ // column cap with a native title tooltip for the full value. The width
24
+ // cap lives on the td (W4 A-003 F-3) unless the column declares its own
25
+ // width, which then owns the constraint.
26
+ return (_jsx("span", { className: cn("block truncate", column.truncate === true ? "max-w-[16rem]" : ""), title: display, "data-table-cell": "truncated", children: display }));
27
+ }
28
+ function labelText(label) {
29
+ if (typeof label === "string" || typeof label === "number") {
30
+ return String(label);
31
+ }
32
+ return "";
33
+ }
34
+ /**
35
+ * Columns eligible for the mobile card surface. Action / selection chrome stays
36
+ * out of the primary title/secondary stack and is rendered as trailing controls.
37
+ */
38
+ function contentColumns(columns) {
39
+ return columns.filter((column) => column.key !== "__selection" && column.key !== "actions");
40
+ }
41
+ function actionColumn(columns) {
42
+ return columns.find((column) => column.key === "actions");
43
+ }
44
+ function MobileCardList({ columns, rows, rowKey, onRowClick, selectedKey, }) {
45
+ const fields = contentColumns(columns);
46
+ const actions = actionColumn(columns);
47
+ const titleColumn = fields[0];
48
+ // W14 F-14 (GOAL-018): do not silently drop columns on mobile — render every
49
+ // remaining content column in the secondary stack.
50
+ const secondaryColumns = fields.slice(1);
51
+ const t = useTranslate();
52
+ return (_jsx("ul", { "data-table-presentation": "mobile-cards", className: "space-y-2 md:hidden", "aria-label": t("feedback.mobileCardList"), children: rows.map((row) => {
53
+ const key = rowKey(row);
54
+ const selected = selectedKey !== undefined && selectedKey === key;
55
+ return (_jsx("li", { children: _jsx("div", { role: onRowClick === undefined ? undefined : "button", tabIndex: onRowClick === undefined ? undefined : 0, "aria-selected": onRowClick === undefined ? undefined : selected, onClick: onRowClick === undefined ? undefined : () => onRowClick(row), onKeyDown: onRowClick === undefined
56
+ ? undefined
57
+ : (event) => {
58
+ if (event.key === "Enter" || event.key === " ") {
59
+ event.preventDefault();
60
+ onRowClick(row);
61
+ }
62
+ }, className: cn("rounded-lg border border-border bg-card p-3 text-left shadow-sm transition-colors", onRowClick === undefined ? "" : "cursor-pointer hover:bg-accent/40", selected ? "border-primary/40 bg-accent/50 ring-1 ring-primary/20" : ""), children: _jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1 space-y-1", children: [titleColumn !== undefined ? (_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: cellContent(titleColumn, row) })) : null, secondaryColumns.map((column) => (_jsxs("p", { className: "truncate text-xs text-muted-foreground", children: [labelText(column.label) !== "" ? (_jsxs("span", { className: "mr-1 font-medium text-muted-foreground/80", children: [labelText(column.label), ":"] })) : null, cellContent(column, row)] }, column.key)))] }), actions !== undefined ? (_jsx("div", { className: "shrink-0", onClick: (event) => event.stopPropagation(), onKeyDown: (event) => event.stopPropagation(), children: actions.render !== undefined
63
+ ? actions.render(row)
64
+ : cellContent(actions, row) })) : (_jsx("span", { "aria-hidden": "true", className: "shrink-0 px-1 text-sm text-muted-foreground", children: "\u22EF" }))] }) }) }, key));
65
+ }) }));
66
+ }
67
+ export function DataTable({ columns, rows, rowKey, sort, onSortChange, loading = false, error = null, emptyMessage = "No rows.", caption, onRowClick, selectedKey, onRetry, }) {
68
+ const t = useTranslate();
69
+ const toggleSort = (column) => {
70
+ if (!column.sortable || onSortChange === undefined) {
71
+ return;
72
+ }
73
+ // W14 F-14 (GOAL-018): a third click on the active descending column clears
74
+ // the sort instead of cycling back to ascending.
75
+ if (sort?.field === column.key && sort.order === "desc") {
76
+ onSortChange(null);
77
+ return;
78
+ }
79
+ const nextOrder = sort?.field === column.key && sort.order === "asc" ? "desc" : "asc";
80
+ onSortChange({ field: column.key, order: nextOrder });
81
+ };
82
+ const arrowFor = (column) => {
83
+ if (sort?.field !== column.key) {
84
+ return "";
85
+ }
86
+ return sort.order === "asc" ? " ↑" : " ↓";
87
+ };
88
+ const state = resolveAsyncDisplayState({ loading, error, isEmpty: rows.length === 0 });
89
+ if (state === "loading") {
90
+ return (_jsx("div", { className: "space-y-2", "data-table-presentation": "loading", children: _jsxs("div", { role: "status", "aria-label": t("feedback.loading"), className: "space-y-2 rounded-md border border-border p-4", children: [_jsx(Skeleton, { className: "h-4 w-full" }), _jsx(Skeleton, { className: "h-4 w-full" }), _jsx(Skeleton, { className: "h-4 w-3/4" })] }) }));
91
+ }
92
+ if (state === "error") {
93
+ return (_jsxs("div", { role: "alert", className: "space-y-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive", children: [_jsx("p", { children: error }), onRetry !== undefined ? (_jsx("button", { type: "button", "data-table-retry": "true", onClick: onRetry, className: "rounded-md border border-destructive/40 bg-background px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10", children: t("feedback.retry") })) : null] }));
94
+ }
95
+ if (state === "empty") {
96
+ // W11 · U-07: graphic empty state — an inbox glyph plus the message.
97
+ return (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-md border border-dashed border-border bg-card px-4 py-10 text-center", "data-table-empty": "true", children: [_jsx(Inbox, { "aria-hidden": "true", className: "size-8 text-muted-foreground/40" }), _jsx("p", { className: "text-sm text-muted-foreground", children: emptyMessage })] }));
98
+ }
99
+ return (_jsxs("div", { className: "w-full min-w-0 space-y-0", "data-table-presentation": "dual-end", children: [_jsx("div", { "data-table-presentation": "desktop-table", className: "hidden w-full min-w-0 overflow-x-auto rounded-md border border-border md:block", children: _jsxs("table", { className: "w-full min-w-[32rem] border-collapse text-sm", children: [caption ? _jsx("caption", { className: "sr-only", children: caption }) : null, _jsx("thead", { children: _jsx("tr", { className: "border-b border-border bg-muted/30", children: columns.map((column) => {
100
+ const isActive = sort?.field === column.key;
101
+ return (_jsx("th", { style: {
102
+ ...(column.width !== undefined ? { width: column.width } : {}),
103
+ ...(column.minWidth !== undefined ? { minWidth: column.minWidth } : {}),
104
+ }, className: "px-4 py-3 text-left text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground", scope: "col", children: column.sortable ? (_jsxs("button", { type: "button", onClick: () => toggleSort(column), "aria-sort": isActive
105
+ ? sort.order === "asc"
106
+ ? "ascending"
107
+ : "descending"
108
+ : undefined, className: cn("inline-flex items-center gap-1 uppercase tracking-[0.12em]", isActive ? "text-foreground" : "hover:text-foreground"), children: [column.label, _jsx("span", { "aria-hidden": "true", className: "text-[10px]", children: arrowFor(column) })] })) : (column.label) }, column.key));
109
+ }) }) }), _jsx("tbody", { children: rows.map((row) => {
110
+ const key = rowKey(row);
111
+ const selected = selectedKey !== undefined && selectedKey === key;
112
+ return (_jsx("tr", { tabIndex: onRowClick === undefined ? undefined : 0, onClick: onRowClick === undefined
113
+ ? undefined
114
+ : (event) => {
115
+ // Action/selection chrome must not select the row
116
+ // (selecting opens recordView drawer — bad UX for Edit/Delete).
117
+ const target = event.target;
118
+ if (target?.closest("button, a, input, select, textarea, label, [data-row-click-ignore]")) {
119
+ return;
120
+ }
121
+ onRowClick(row);
122
+ }, onKeyDown: onRowClick === undefined
123
+ ? undefined
124
+ : (event) => {
125
+ const target = event.target;
126
+ if (target?.closest("button, a, input, select, textarea, label, [data-row-click-ignore]")) {
127
+ return;
128
+ }
129
+ if (event.key === "Enter" || event.key === " ") {
130
+ event.preventDefault();
131
+ onRowClick(row);
132
+ }
133
+ }, "aria-selected": onRowClick === undefined ? undefined : selected, className: cn("border-b border-border transition-colors last:border-b-0 hover:bg-accent/40", onRowClick === undefined
134
+ ? ""
135
+ : "cursor-pointer hover:bg-accent/50", selected ? "bg-accent/60" : ""), children: columns.map((column) => {
136
+ const interactive = column.key === "actions" || column.key === "__selection";
137
+ return (_jsx("td", { "data-row-click-ignore": interactive ? "true" : undefined, style: {
138
+ ...(column.width !== undefined ? { width: column.width } : {}),
139
+ ...(column.minWidth !== undefined ? { minWidth: column.minWidth } : {}),
140
+ }, className: cn("px-4 py-3 align-middle text-sm",
141
+ // W4 · GOAL-005: table-layout auto sizes a column by
142
+ // its cell max-content; the inner span's max-width
143
+ // alone does not clamp the cell, so the width cap
144
+ // must live on the td itself. Universal cap: every
145
+ // column truncates at 20rem unless it declares an
146
+ // explicit width (which then owns the constraint).
147
+ column.truncate === true
148
+ ? "max-w-[16rem]"
149
+ : column.width === undefined
150
+ ? "max-w-[20rem]"
151
+ : ""), onClick: interactive
152
+ ? (event) => event.stopPropagation()
153
+ : undefined, children: cellContent(column, row) }, column.key));
154
+ }) }, key));
155
+ }) })] }) }), _jsx(MobileCardList, { columns: columns, rows: rows, rowKey: rowKey, onRowClick: onRowClick, selectedKey: selectedKey })] }));
156
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared pure state determination for async display regions (S4 · GOAL-004).
3
+ *
4
+ * statCard / chart / list-table each fetch a resource independently and used
5
+ * to invent their own ad-hoc "Loading…" text placeholder. This module
6
+ * centralizes the loading / error / empty / ready decision into one pure,
7
+ * directly-testable function so every consumer renders the same sequence
8
+ * (Skeleton while loading, a `role="alert"` message on error, a muted empty
9
+ * message otherwise) instead of drifting independently.
10
+ */
11
+ /**
12
+ * Resolves the single display state a region should show.
13
+ *
14
+ * Precedence: `error` wins over `loading` (a failed fetch is not "still
15
+ * loading" even if a stale loading flag lingers), and `loading` wins over
16
+ * `isEmpty` (emptiness is unknown until the fetch settles).
17
+ */
18
+ export function resolveAsyncDisplayState({ loading, error, isEmpty = false, }) {
19
+ if (error !== null) {
20
+ return "error";
21
+ }
22
+ if (loading) {
23
+ return "loading";
24
+ }
25
+ if (isEmpty) {
26
+ return "empty";
27
+ }
28
+ return "ready";
29
+ }
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cva } from "class-variance-authority";
3
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
4
+ const badgeVariants = cva("inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", {
5
+ variants: {
6
+ variant: {
7
+ default: "border-transparent bg-primary text-primary-foreground shadow hover:opacity-80",
8
+ secondary: "border-transparent bg-secondary text-secondary-foreground hover:opacity-80",
9
+ destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:opacity-80",
10
+ success: "border-transparent bg-success text-success-foreground shadow hover:opacity-80",
11
+ outline: "text-foreground",
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ variant: "default",
16
+ },
17
+ });
18
+ function Badge({ className, variant, ...props }) {
19
+ return (_jsx("div", { className: cn(badgeVariants({ variant }), className), ...props }));
20
+ }
21
+ export { Badge, badgeVariants };
@@ -0,0 +1,126 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Breadcrumb navigation for nested admin pages (GOAL-015).
4
+ *
5
+ * Semantic hierarchy, not visit history (user ruling 2026-08-14):
6
+ * the trail is the page's place in the manifest navigation tree
7
+ * (slot → group labels → page) plus consumer-declared parents for
8
+ * inner pages reached by row navigation (e.g. dictionary-entries →
9
+ * data-dictionary, task-runs → scheduled-tasks). No protocol change:
10
+ * the parent map is a web-shell declaration (BREADCRUMB_PAGE_PARENTS).
11
+ *
12
+ * Trail shape: 首页 => 一级页 => ... => n级内页 — the home page
13
+ * (manifest homePageRef, the domain-root default) always leads, then nav
14
+ * group labels and declared parents, then the current page. Visual: compact
15
+ * 12px trail — muted clickable ancestors (hover brighten + underline), thin
16
+ * "/" separators, brighter non-clickable current item, and an optional
17
+ * small circular ghost back button (semantic parent) at the far left.
18
+ */
19
+ import { ArrowLeft } from "lucide-react";
20
+ import { useTranslate } from "@magicvr/schema-ui-lib/i18n/runtime";
21
+ import { resolveTextProp } from "@magicvr/schema-ui-lib/i18n/catalog";
22
+ export function Breadcrumbs({ entries, onNavigate, onBack, showBack = false, }) {
23
+ const t = useTranslate();
24
+ const hasTrail = entries.length > 1;
25
+ if (!hasTrail && !showBack) {
26
+ return null;
27
+ }
28
+ const backLabel = t("shell.back");
29
+ return (_jsxs("nav", { "aria-label": "Breadcrumb", className: "flex items-center gap-2", children: [showBack ? (_jsx("button", { type: "button", onClick: onBack, "aria-label": backLabel, title: backLabel, className: "inline-flex size-6 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: _jsx(ArrowLeft, { className: "size-3.5", "aria-hidden": "true" }) })) : null, hasTrail ? (_jsx("ol", { className: "flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-normal text-muted-foreground", children: entries.map((entry, index) => {
30
+ const isLast = index === entries.length - 1;
31
+ return (_jsxs("li", { className: "flex min-w-0 items-center gap-x-1.5", children: [isLast ? (_jsx("span", { "aria-current": "page", className: "truncate font-normal text-foreground/90", children: entry.label })) : entry.route !== "" ? (_jsx("button", { type: "button", onClick: () => onNavigate(entry.route), className: "truncate font-normal text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline", children: entry.label })) : (_jsx("span", { className: "truncate font-normal text-muted-foreground/75", children: entry.label })), !isLast ? (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground/40", children: "/" })) : null] }, entry.pageId));
32
+ }) })) : null] }));
33
+ }
34
+ /**
35
+ * Resolves the SEMANTIC breadcrumb trail for a matched page.
36
+ *
37
+ * Sources, in order:
38
+ * 1. manifest navigation tree — a page rendered under a group shows the
39
+ * group labels as non-clickable ancestors (outermost first);
40
+ * 2. declared parents (options.parents) — inner pages reached by row
41
+ * navigation declare their parent pageId; the chain walks up until a
42
+ * page with no declared parent (its nav group chain, if any, is
43
+ * included). Unknown declared parents fail safe (skipped).
44
+ * 3. pages not in the tree and without a declared parent are
45
+ * single-level (no trail UI).
46
+ *
47
+ * This is NOT the visit history: the same page always shows the same
48
+ * trail regardless of how the user got there (user ruling 2026-08-14).
49
+ */
50
+ export function resolveBreadcrumbTrail(pages, currentPage, t, options = {}) {
51
+ if (currentPage === undefined)
52
+ return [];
53
+ const labelOf = (page) => resolveTextProp(page, "titleKey", "title", t) ??
54
+ page.pageId;
55
+ const byId = new Map(pages.map((page) => [page.pageId, page]));
56
+ // Nav-tree group chain (outermost first) for a pageId; null when absent.
57
+ const groupChain = (pageId) => {
58
+ const walk = (items, chain) => {
59
+ for (const item of items ?? []) {
60
+ if (item.pageRef === pageId)
61
+ return chain;
62
+ if (item.items !== undefined) {
63
+ const label = resolveTextProp(item, "labelKey", "label", t) ?? "";
64
+ const next = label === "" ? chain : [...chain, label];
65
+ const found = walk(item.items, next);
66
+ if (found !== null)
67
+ return found;
68
+ }
69
+ }
70
+ return null;
71
+ };
72
+ for (const slot of [options.navigation?.top, options.navigation?.sidebar, options.navigation?.user]) {
73
+ const found = walk(slot, []);
74
+ if (found !== null)
75
+ return found;
76
+ }
77
+ return null;
78
+ };
79
+ // Declared-parent chain: each parent contributes its own nav group
80
+ // labels (outermost first) then the parent page, oldest ancestor first.
81
+ const ancestors = [];
82
+ const seen = new Set([currentPage.pageId]);
83
+ let cursor = options.parents?.[currentPage.pageId];
84
+ while (cursor !== undefined && !seen.has(cursor)) {
85
+ const page = byId.get(cursor);
86
+ if (page === undefined)
87
+ break; // unknown declared parent — fail safe
88
+ seen.add(cursor);
89
+ const groups = groupChain(cursor) ?? [];
90
+ for (const label of groups) {
91
+ ancestors.unshift({ pageId: label, label, route: "", current: false });
92
+ }
93
+ ancestors.unshift({
94
+ pageId: page.pageId,
95
+ label: labelOf(page),
96
+ route: page.route,
97
+ current: false,
98
+ });
99
+ cursor = options.parents?.[cursor];
100
+ }
101
+ const ownGroups = groupChain(currentPage.pageId) ?? [];
102
+ const chain = [
103
+ ...ownGroups.map((label) => ({ pageId: label, label, route: "", current: false })),
104
+ ...ancestors,
105
+ {
106
+ pageId: currentPage.pageId,
107
+ label: labelOf(currentPage),
108
+ route: currentPage.route,
109
+ current: true,
110
+ },
111
+ ];
112
+ // 首页 root: the domain-root default page leads every trail unless the
113
+ // current page IS home (deduplicated when the chain already contains it).
114
+ if (options.homePageId !== undefined && currentPage.pageId !== options.homePageId) {
115
+ const home = byId.get(options.homePageId);
116
+ if (home !== undefined && !chain.some((entry) => entry.pageId === home.pageId)) {
117
+ chain.unshift({
118
+ pageId: home.pageId,
119
+ label: labelOf(home),
120
+ route: home.route,
121
+ current: false,
122
+ });
123
+ }
124
+ }
125
+ return chain;
126
+ }
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cva } from "class-variance-authority";
5
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
6
+ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", {
7
+ variants: {
8
+ variant: {
9
+ default: "bg-primary text-primary-foreground hover:opacity-90",
10
+ outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
11
+ secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
12
+ ghost: "hover:bg-accent hover:text-accent-foreground",
13
+ },
14
+ size: {
15
+ default: "h-9 px-4 py-2",
16
+ sm: "h-8 rounded-md px-3 text-xs",
17
+ lg: "h-10 rounded-md px-8",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ size: "default",
23
+ },
24
+ });
25
+ const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
26
+ const Comp = asChild ? Slot : "button";
27
+ return (_jsx(Comp, { className: cn(buttonVariants({ variant, size, className })), ref: ref, ...props }));
28
+ });
29
+ Button.displayName = "Button";
30
+ export { Button, buttonVariants };
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
4
+ const Card = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className), ...props })));
5
+ Card.displayName = "Card";
6
+ const CardHeader = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("flex flex-col space-y-1.5 p-6", className), ...props })));
7
+ CardHeader.displayName = "CardHeader";
8
+ const CardTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx("h3", { ref: ref, className: cn("text-2xl font-semibold leading-none tracking-tight", className), ...props })));
9
+ CardTitle.displayName = "CardTitle";
10
+ const CardDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx("p", { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
11
+ CardDescription.displayName = "CardDescription";
12
+ const CardContent = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("p-6 pt-0", className), ...props })));
13
+ CardContent.displayName = "CardContent";
14
+ const CardFooter = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("flex items-center p-6 pt-0", className), ...props })));
15
+ CardFooter.displayName = "CardFooter";
16
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @schema-ui/ui 聚合导出:设计系统原子 + DataTable 核心(R3 六包化)。
3
3
  */
4
- export * from "./async-state";
5
- export * from "./badge";
6
- export * from "./breadcrumbs";
7
- export * from "./button";
8
- export * from "./card";
9
- export * from "./input";
10
- export * from "./label";
11
- export * from "./skeleton";
12
- export * from "./textarea";
13
- export * from "../data-table";
4
+ export * from "./async-state.js";
5
+ export * from "./badge.js";
6
+ export * from "./breadcrumbs.js";
7
+ export * from "./button.js";
8
+ export * from "./card.js";
9
+ export * from "./input.js";
10
+ export * from "./label.js";
11
+ export * from "./skeleton.js";
12
+ export * from "./textarea.js";
13
+ export * from "../data-table.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @magicvr/schema-ui-ui 聚合导出:设计系统原子 + DataTable 核心(R3 六包化)。
3
+ */
4
+ export * from "./async-state.js";
5
+ export * from "./badge.js";
6
+ export * from "./breadcrumbs.js";
7
+ export * from "./button.js";
8
+ export * from "./card.js";
9
+ export * from "./input.js";
10
+ export * from "./label.js";
11
+ export * from "./skeleton.js";
12
+ export * from "./textarea.js";
13
+ export * from "../data-table.js";
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
4
+ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
5
+ return (_jsx("input", { type: type, className: cn("flex h-9 w-full rounded-md border border-input/80 bg-background px-3 py-1 text-sm shadow-sm transition-all duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/50 hover:border-muted-foreground/30 focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50", className), ref: ref, ...props }));
6
+ });
7
+ Input.displayName = "Input";
8
+ export { Input };
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
4
+ const Label = React.forwardRef(({ className, ...props }, ref) => (_jsx("label", { ref: ref, className: cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className), ...props })));
5
+ Label.displayName = "Label";
6
+ export { Label };
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
3
+ function Skeleton({ className, ...props }) {
4
+ return (_jsx("div", { className: cn("animate-pulse rounded-md bg-primary/10", className), ...props }));
5
+ }
6
+ export { Skeleton };
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "@magicvr/schema-ui-lib/lib/utils";
4
+ const Textarea = React.forwardRef(({ className, ...props }, ref) => {
5
+ return (_jsx("textarea", { className: cn("flex min-h-[60px] w-full rounded-md border border-input/80 bg-background px-3 py-2 text-sm shadow-sm transition-all duration-150 placeholder:text-muted-foreground/50 hover:border-muted-foreground/30 focus-visible:outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50", className), ref: ref, ...props }));
6
+ });
7
+ Textarea.displayName = "Textarea";
8
+ export { Textarea };
package/i18n/catalog.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * observable via the `schema-ui:missing-translation` window event (deduped
13
13
  * per locale+key, so the first occurrence always reports).
14
14
  */
15
- import { type Locale } from "./locale";
15
+ import { type Locale } from "./locale.js";
16
16
  export type MessageParams = Record<string, string | number>;
17
17
  export interface MissingTranslationDetail {
18
18
  locale: Locale;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Translation catalog (S1 · C2/C3).
3
+ *
4
+ * Catalogs are pure data files under `messages/`; `en-US` is the canonical
5
+ * baseline. Resolution order for a key in locale L:
6
+ *
7
+ * catalog[L] → catalog[en-US] → observable missing-key event → key itself
8
+ *
9
+ * A key is "missing" only when neither the current catalog nor the en-US
10
+ * catalog has it; the en-US fallback is silent (designed behavior). Missing
11
+ * keys never render empty, never throw, and never block the flow; they are
12
+ * observable via the `schema-ui:missing-translation` window event (deduped
13
+ * per locale+key, so the first occurrence always reports).
14
+ */
15
+ import enUS from "./messages/en-US.json";
16
+ import zhCN from "./messages/zh-CN.json";
17
+ import { DEFAULT_LOCALE } from "./locale.js";
18
+ export const MISSING_TRANSLATION_EVENT = "schema-ui:missing-translation";
19
+ const catalogs = {
20
+ "en-US": enUS,
21
+ "zh-CN": zhCN,
22
+ };
23
+ const reportedMissing = new Set();
24
+ /** True when the key exists in the given locale catalog. */
25
+ export function hasTranslation(key, locale) {
26
+ return Object.prototype.hasOwnProperty.call(catalogs[locale], key);
27
+ }
28
+ /** Raw catalog text for a key, or null when the locale catalog lacks it. */
29
+ export function lookupTranslation(key, locale) {
30
+ if (hasTranslation(key, locale)) {
31
+ return catalogs[locale][key];
32
+ }
33
+ return null;
34
+ }
35
+ /** Replaces `{name}` placeholders with params; unknown placeholders stay. */
36
+ export function interpolate(template, params) {
37
+ if (params === undefined) {
38
+ return template;
39
+ }
40
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match);
41
+ }
42
+ /** Publishes a deduped missing-key report to the window event bus. */
43
+ export function reportMissingTranslation(detail) {
44
+ const dedupeKey = `${detail.locale}:${detail.key}`;
45
+ if (reportedMissing.has(dedupeKey)) {
46
+ return;
47
+ }
48
+ reportedMissing.add(dedupeKey);
49
+ if (typeof window !== "undefined") {
50
+ window.dispatchEvent(new CustomEvent(MISSING_TRANSLATION_EVENT, { detail }));
51
+ }
52
+ }
53
+ /** Resets the missing-key dedupe set (test seam). */
54
+ export function resetMissingTranslationReports() {
55
+ reportedMissing.clear();
56
+ }
57
+ /**
58
+ * Resolves a message key for a locale with the frozen fallback chain.
59
+ * Never throws, never returns an empty string for a missing key.
60
+ *
61
+ * Fallback order: catalog[locale] → catalog[en-US] → `literalFallback`
62
+ * (protocol literal text, when supplied) → key itself.
63
+ */
64
+ export function translate(key, params, locale = DEFAULT_LOCALE, path, literalFallback) {
65
+ const direct = lookupTranslation(key, locale);
66
+ if (direct !== null) {
67
+ return interpolate(direct, params);
68
+ }
69
+ const fallback = lookupTranslation(key, DEFAULT_LOCALE);
70
+ if (fallback !== null) {
71
+ return interpolate(fallback, params);
72
+ }
73
+ reportMissingTranslation({ locale, key, path });
74
+ return literalFallback !== undefined && literalFallback !== "" ? literalFallback : key;
75
+ }
76
+ /** Binds a locale (+ optional context path) to a translate function. */
77
+ export function createTranslator(locale, options) {
78
+ return (key, params, literalFallback) => translate(key, params, locale, options?.path, literalFallback);
79
+ }
80
+ /**
81
+ * Resolves a schema/manifest text prop pair — the `*Key` field wins over the
82
+ * literal protocol text, and the literal text is the last fallback before the
83
+ * key itself (frozen chain: 当前语种 → en-US → 字面文本 → key).
84
+ */
85
+ export function resolveTextProp(props, keyProp, literalProp, t, fallback = "") {
86
+ if (props === undefined) {
87
+ return fallback;
88
+ }
89
+ const key = props[keyProp];
90
+ if (typeof key === "string" && key !== "") {
91
+ const literal = typeof props[literalProp] === "string" ? props[literalProp] : undefined;
92
+ return t(key, undefined, literal);
93
+ }
94
+ const literal = props[literalProp];
95
+ return typeof literal === "string" ? literal : fallback;
96
+ }
package/i18n/format.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Formatting is fail-safe: invalid inputs render empty, invalid timezones
7
7
  * degrade to the locale's default zone instead of throwing.
8
8
  */
9
- import { type Locale } from "./locale";
9
+ import { type Locale } from "./locale.js";
10
10
  export interface FormatOptions {
11
11
  /** IANA timezone name; omitted = the environment's default zone. */
12
12
  timeZone?: string;
package/i18n/format.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Locale-aware date/number formatting (S1 · C5).
3
+ *
4
+ * Formatting follows the effective locale through Intl.* — no custom format
5
+ * templates (VP-007: "首版不暴露任意日期/数字格式模板,随有效 locale").
6
+ * Formatting is fail-safe: invalid inputs render empty, invalid timezones
7
+ * degrade to the locale's default zone instead of throwing.
8
+ */
9
+ import { DEFAULT_LOCALE } from "./locale.js";
10
+ /** Formats a date value in the given locale. Returns "" for invalid input. */
11
+ export function formatDate(value, locale = DEFAULT_LOCALE, options = {}) {
12
+ const date = value instanceof Date ? value : new Date(value);
13
+ if (!Number.isFinite(date.getTime())) {
14
+ return "";
15
+ }
16
+ const timeZone = options.timeZone !== undefined && options.timeZone !== "" ? options.timeZone : undefined;
17
+ try {
18
+ return new Intl.DateTimeFormat(locale, {
19
+ dateStyle: "medium",
20
+ timeStyle: "short",
21
+ ...(timeZone === undefined ? {} : { timeZone }),
22
+ }).format(date);
23
+ }
24
+ catch {
25
+ // Invalid IANA name — degrade to the default zone, never throw.
26
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date);
27
+ }
28
+ }
29
+ /** Formats a finite number in the given locale. Returns "" for invalid input. */
30
+ export function formatNumber(value, locale = DEFAULT_LOCALE, options = {}) {
31
+ if (typeof value !== "number" || !Number.isFinite(value)) {
32
+ return "";
33
+ }
34
+ return new Intl.NumberFormat(locale, options).format(value);
35
+ }