@calliopelabs/ui 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +334 -2
- package/dist/appearance/AppearancePicker.d.ts +10 -0
- package/dist/appearance/AppearancePicker.js +75 -0
- package/dist/appearance/AppearanceProvider.d.ts +53 -0
- package/dist/appearance/AppearanceProvider.js +113 -0
- package/dist/appearance/AppearanceScript.d.ts +16 -0
- package/dist/appearance/AppearanceScript.js +14 -0
- package/dist/appearance/appearance.d.ts +90 -0
- package/dist/appearance/appearance.js +151 -0
- package/dist/appearance/boot.d.ts +30 -0
- package/dist/appearance/boot.js +91 -0
- package/dist/appearance/brands.d.ts +187 -0
- package/dist/appearance/brands.js +150 -0
- package/dist/appearance/product.d.ts +89 -0
- package/dist/appearance/product.js +96 -0
- package/dist/data/DataTable.d.ts +66 -0
- package/dist/data/DataTable.js +148 -0
- package/dist/data/List.d.ts +55 -0
- package/dist/data/List.js +41 -0
- package/dist/data/types.d.ts +92 -0
- package/dist/data/types.js +2 -0
- package/dist/data/use-row-selection.d.ts +17 -0
- package/dist/data/use-row-selection.js +51 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +61 -0
- package/dist/lib/cn.d.ts +3 -0
- package/dist/lib/cn.js +6 -0
- package/dist/lib/local-store.d.ts +12 -0
- package/dist/lib/local-store.js +54 -0
- package/dist/nav/Nav.d.ts +21 -0
- package/dist/nav/Nav.js +97 -0
- package/dist/nav/model.d.ts +77 -0
- package/dist/nav/model.js +72 -0
- package/dist/overlays/ConfirmDialog.d.ts +37 -0
- package/dist/overlays/ConfirmDialog.js +70 -0
- package/dist/overlays/Dialog.d.ts +18 -0
- package/dist/overlays/Dialog.js +39 -0
- package/dist/overlays/Drawer.d.ts +24 -0
- package/dist/overlays/Drawer.js +42 -0
- package/dist/page/Breadcrumbs.d.ts +20 -0
- package/dist/page/Breadcrumbs.js +25 -0
- package/dist/page/PageFrame.d.ts +17 -0
- package/dist/page/PageFrame.js +14 -0
- package/dist/page/PageHeader.d.ts +28 -0
- package/dist/page/PageHeader.js +13 -0
- package/dist/page/Section.d.ts +18 -0
- package/dist/page/Section.js +12 -0
- package/dist/page/TabNav.d.ts +19 -0
- package/dist/page/TabNav.js +16 -0
- package/dist/primitives/Button.d.ts +16 -0
- package/dist/primitives/Button.js +36 -0
- package/dist/primitives/Checkbox.d.ts +8 -0
- package/dist/primitives/Checkbox.js +13 -0
- package/dist/primitives/DropdownMenu.d.ts +11 -0
- package/dist/primitives/DropdownMenu.js +25 -0
- package/dist/primitives/Input.d.ts +2 -0
- package/dist/primitives/Input.js +5 -0
- package/dist/primitives/Popover.d.ts +5 -0
- package/dist/primitives/Popover.js +13 -0
- package/dist/primitives/Skeleton.d.ts +3 -0
- package/dist/primitives/Skeleton.js +6 -0
- package/dist/primitives/Tabs.d.ts +12 -0
- package/dist/primitives/Tabs.js +22 -0
- package/dist/provider/UIProvider.d.ts +29 -0
- package/dist/provider/UIProvider.js +21 -0
- package/dist/provider/messages.d.ts +106 -0
- package/dist/provider/messages.js +104 -0
- package/dist/shell/AppShell.d.ts +43 -0
- package/dist/shell/AppShell.js +62 -0
- package/dist/shell/BrandMark.d.ts +14 -0
- package/dist/shell/BrandMark.js +14 -0
- package/dist/shell/RailPanel.d.ts +39 -0
- package/dist/shell/RailPanel.js +62 -0
- package/dist/shell/RailTree.d.ts +35 -0
- package/dist/shell/RailTree.js +32 -0
- package/dist/shell/Sidebar.d.ts +26 -0
- package/dist/shell/Sidebar.js +21 -0
- package/dist/shell/UserMenu.d.ts +22 -0
- package/dist/shell/UserMenu.js +31 -0
- package/dist/shell/placement.d.ts +10 -0
- package/dist/shell/placement.js +6 -0
- package/dist/shell/rail-state.d.ts +13 -0
- package/dist/shell/rail-state.js +28 -0
- package/dist/states/EmptyState.d.ts +20 -0
- package/dist/states/EmptyState.js +15 -0
- package/dist/states/ErrorState.d.ts +23 -0
- package/dist/states/ErrorState.js +16 -0
- package/dist/states/LoadingState.d.ts +14 -0
- package/dist/states/LoadingState.js +14 -0
- package/dist/status/StatusBadge.d.ts +19 -0
- package/dist/status/StatusBadge.js +13 -0
- package/dist/status/StatusDot.d.ts +14 -0
- package/dist/status/StatusDot.js +7 -0
- package/dist/status/tones.d.ts +17 -0
- package/dist/status/tones.js +29 -0
- package/dist/styles/preset.css +175 -0
- package/dist/styles/theme.css +339 -0
- package/dist/theme/contrast.d.ts +8 -0
- package/dist/theme/contrast.js +18 -0
- package/dist/theme/css.d.ts +23 -0
- package/dist/theme/css.js +116 -0
- package/dist/theme/tokens.d.ts +195 -0
- package/dist/theme/tokens.js +294 -0
- package/package.json +112 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { NavIcon } from "../nav/model.js";
|
|
3
|
+
import type { StatusTone } from "../status/tones.js";
|
|
4
|
+
export interface RailTreeItem {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
icon?: NavIcon;
|
|
9
|
+
status?: StatusTone;
|
|
10
|
+
/** Read with the status dot, which carries no text of its own ("failing"). */
|
|
11
|
+
statusLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface RailTreeGroup {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
items: readonly RailTreeItem[];
|
|
17
|
+
}
|
|
18
|
+
export interface RailTreeProps {
|
|
19
|
+
/** The tree's heading ("My projects"). */
|
|
20
|
+
label: string;
|
|
21
|
+
groups: readonly RailTreeGroup[];
|
|
22
|
+
/** The page on screen: its row is lit and its group opens. */
|
|
23
|
+
activeHref?: string;
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
/** Shown when there are no groups. */
|
|
26
|
+
emptyText: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A tree of linked groups for a rail: Astrolift's projects and the apps,
|
|
31
|
+
* agents and workflows in each. It navigates; it never filters the lists on
|
|
32
|
+
* the page. Each group shows its count and folds; the group holding the
|
|
33
|
+
* current page starts open.
|
|
34
|
+
*/
|
|
35
|
+
export declare function RailTree({ label, groups, activeHref, loading, emptyText, className, }: RailTreeProps): React.JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
7
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
8
|
+
import { StatusDot } from "../status/StatusDot.js";
|
|
9
|
+
/**
|
|
10
|
+
* A tree of linked groups for a rail: Astrolift's projects and the apps,
|
|
11
|
+
* agents and workflows in each. It navigates; it never filters the lists on
|
|
12
|
+
* the page. Each group shows its count and folds; the group holding the
|
|
13
|
+
* current page starts open.
|
|
14
|
+
*/
|
|
15
|
+
export function RailTree({ label, groups, activeHref, loading = false, emptyText, className, }) {
|
|
16
|
+
const headingId = React.useId();
|
|
17
|
+
return (_jsxs("div", { className: cn("flex min-w-0 flex-col", className), children: [_jsx("div", { id: headingId, className: "px-3 pt-3 pb-1 text-2xs font-semibold tracking-wider text-muted-foreground uppercase", children: label }), _jsx("div", { className: "px-2 pb-2", children: loading ? (_jsx("div", { className: "flex flex-col gap-2 px-1 pt-1", "aria-busy": true, children: Array.from({ length: 4 }, (_, i) => (_jsx(Skeleton, { className: "h-6 w-full" }, i))) })) : groups.length === 0 ? (_jsx("p", { className: "px-2 py-2 text-xs text-muted-foreground", children: emptyText })) : (_jsx("ul", { "aria-labelledby": headingId, className: "flex flex-col gap-0.5", children: groups.map((group) => (_jsx(Branch, { group: group, activeHref: activeHref }, group.key))) })) })] }));
|
|
18
|
+
}
|
|
19
|
+
function Branch({ group, activeHref }) {
|
|
20
|
+
const { Link } = useUI();
|
|
21
|
+
const holdsActive = group.items.some((item) => item.href === activeHref);
|
|
22
|
+
const [open, setOpen] = React.useState(holdsActive);
|
|
23
|
+
const listId = React.useId();
|
|
24
|
+
const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
|
|
25
|
+
return (_jsxs("li", { children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-expanded": open, "aria-controls": listId, className: "flex h-7 w-full min-w-0 items-center gap-1.5 rounded-lg px-1.5 text-left text-sm hover:bg-sidebar-accent/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", children: [_jsx(Chevron, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "truncate font-medium", title: group.label, children: group.label }), " ", _jsx("span", { className: "ml-auto font-mono text-2xs text-muted-foreground", children: group.items.length })] }), _jsx("ul", { id: listId, hidden: !open, className: "ml-3 flex flex-col gap-0.5 border-l border-current/10 pl-2", children: group.items.map((item) => {
|
|
26
|
+
const Icon = item.icon;
|
|
27
|
+
const isActive = item.href === activeHref;
|
|
28
|
+
return (_jsx("li", { children: _jsxs(Link, { href: item.href, "aria-current": isActive ? "page" : undefined, className: cn("flex h-7 min-w-0 items-center gap-2 rounded-lg px-1.5 text-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", isActive
|
|
29
|
+
? "bg-primary/15 font-medium text-sidebar-foreground"
|
|
30
|
+
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60"), children: [Icon && _jsx(Icon, { className: "size-3.5 shrink-0 opacity-70", "aria-hidden": true }), _jsx("span", { className: "truncate", title: item.label, children: item.label }), " ", item.status && (_jsx(StatusDot, { tone: item.status, label: item.statusLabel, className: "ml-auto" }))] }) }, item.key));
|
|
31
|
+
}) })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { NavActive, NavModel } from "../nav/model.js";
|
|
3
|
+
export interface SidebarProps {
|
|
4
|
+
/** The model, filtered to what the viewer may open (`visibleNav`). */
|
|
5
|
+
nav: NavModel;
|
|
6
|
+
/** The current row (`activeFor(nav, pathname)`). */
|
|
7
|
+
active: NavActive | null;
|
|
8
|
+
collapsed: boolean;
|
|
9
|
+
onCollapsedChange: (collapsed: boolean) => void;
|
|
10
|
+
/** Above the areas: the brand mark. */
|
|
11
|
+
header?: React.ReactNode;
|
|
12
|
+
/** The mark alone, for the collapsed rail; nothing when absent (a name cut to one letter reads as a bug). */
|
|
13
|
+
headerCollapsed?: React.ReactNode;
|
|
14
|
+
/** Below the areas: the user menu, notifications, help. Hidden while collapsed. */
|
|
15
|
+
footer?: React.ReactNode;
|
|
16
|
+
/** The navigation landmark's name. Defaults to "Main". */
|
|
17
|
+
label?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The main rail, on the left (Astrolift layout D): the one brand surface.
|
|
22
|
+
* 220px with labels, 48px with icons. Pure: the filtered model, the active
|
|
23
|
+
* row and the collapsed state are the caller's; `[` toggles it through the
|
|
24
|
+
* AppShell.
|
|
25
|
+
*/
|
|
26
|
+
export declare function Sidebar({ nav, active, collapsed: collapsedProp, onCollapsedChange, header, headerCollapsed, footer, label, className, }: SidebarProps): React.JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronsLeftIcon, ChevronsRightIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { Nav } from "../nav/Nav.js";
|
|
6
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
7
|
+
import { useRailPlacement } from "./placement.js";
|
|
8
|
+
/**
|
|
9
|
+
* The main rail, on the left (Astrolift layout D): the one brand surface.
|
|
10
|
+
* 220px with labels, 48px with icons. Pure: the filtered model, the active
|
|
11
|
+
* row and the collapsed state are the caller's; `[` toggles it through the
|
|
12
|
+
* AppShell.
|
|
13
|
+
*/
|
|
14
|
+
export function Sidebar({ nav, active, collapsed: collapsedProp, onCollapsedChange, header, headerCollapsed, footer, label, className, }) {
|
|
15
|
+
const { messages } = useUI();
|
|
16
|
+
const inDrawer = useRailPlacement() === "drawer";
|
|
17
|
+
const collapsed = collapsedProp && !inDrawer;
|
|
18
|
+
return (_jsxs("div", { "data-slot": "sidebar", "data-collapsed": collapsed || undefined, className: cn("flex h-full min-h-0 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground", inDrawer ? "w-full border-r-0" : "transition-[width] duration-(--motion-base)", !inDrawer && (collapsed ? "w-12" : "w-55"), className), children: [collapsed
|
|
19
|
+
? headerCollapsed && _jsx("div", { className: "flex justify-center py-3", children: headerCollapsed })
|
|
20
|
+
: header && _jsx("div", { className: "flex min-w-0 items-center px-3 py-3", children: header }), _jsx("nav", { "aria-label": label ?? messages.nav.label, className: "min-h-0 flex-1 overflow-y-auto px-2 py-1", children: _jsx(Nav, { nav: nav, active: active, collapsed: collapsed }) }), (footer || !inDrawer) && (_jsxs("div", { className: "flex flex-col gap-1 border-t border-sidebar-border px-2 py-2", children: [!collapsed && footer, !inDrawer && (_jsx("button", { type: "button", onClick: () => onCollapsedChange(!collapsed), "aria-label": collapsed ? messages.nav.expand : messages.nav.collapse, title: collapsed ? messages.nav.expandTitle : messages.nav.collapseTitle, className: "flex h-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", children: collapsed ? (_jsx(ChevronsRightIcon, { className: "size-4", "aria-hidden": true })) : (_jsx(ChevronsLeftIcon, { className: "size-4", "aria-hidden": true })) }))] }))] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { NavIcon } from "../nav/model.js";
|
|
3
|
+
export interface UserMenuItem {
|
|
4
|
+
label: string;
|
|
5
|
+
icon?: NavIcon;
|
|
6
|
+
/** A page: every item is a link unless it acts in place. */
|
|
7
|
+
href?: string;
|
|
8
|
+
/** An action in place (open the appearance picker, say) when there is no page. */
|
|
9
|
+
onSelect?: () => void;
|
|
10
|
+
}
|
|
11
|
+
export interface UserMenuProps {
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
/** Groups of items, separated by rules: account pages, then help. */
|
|
15
|
+
groups?: readonly (readonly UserMenuItem[])[];
|
|
16
|
+
onSignOut: () => void;
|
|
17
|
+
/** The avatar alone, for a collapsed rail. */
|
|
18
|
+
collapsed?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The signed-in person's menu, at the foot of the sidebar. Pure: the person, the items and sign-out are the app's. */
|
|
22
|
+
export declare function UserMenu({ name, email, groups, onSignOut, collapsed, className, }: UserMenuProps): React.JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { LogOutIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../primitives/DropdownMenu.js";
|
|
6
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
7
|
+
function initialsOf(name) {
|
|
8
|
+
return (name
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.map((part) => part[0])
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.slice(0, 2)
|
|
13
|
+
.join("")
|
|
14
|
+
.toUpperCase() || "?");
|
|
15
|
+
}
|
|
16
|
+
/** The signed-in person's menu, at the foot of the sidebar. Pure: the person, the items and sign-out are the app's. */
|
|
17
|
+
export function UserMenu({ name, email, groups = [], onSignOut, collapsed = false, className, }) {
|
|
18
|
+
const { Link, messages } = useUI();
|
|
19
|
+
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger
|
|
20
|
+
// Expanded, the visible name and email are the trigger's name (WCAG
|
|
21
|
+
// 2.5.3, label in name); collapsed, only the initials show, so it
|
|
22
|
+
// needs one.
|
|
23
|
+
, { "aria-label": collapsed ? messages.user.accountMenu(name, initialsOf(name)) : undefined, className: cn("flex h-10 w-full min-w-0 items-center gap-2 rounded-lg px-1.5 text-left hover:bg-sidebar-accent/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", collapsed && "justify-center px-0", className), children: [_jsx("span", { "aria-hidden": true, className: "flex size-7 shrink-0 items-center justify-center rounded-full bg-muted font-mono text-xs font-medium text-foreground", children: initialsOf(name) }), !collapsed && (_jsxs("span", { className: "grid min-w-0 flex-1 leading-tight", children: [_jsx("span", { className: "truncate text-sm font-medium", children: name }), " ", _jsx("span", { className: "truncate text-xs text-muted-foreground", children: email })] }))] }), _jsxs(DropdownMenuContent, { side: "right", align: "end", className: "min-w-56", children: [_jsxs(DropdownMenuLabel, { className: "grid font-normal", children: [_jsx("span", { className: "truncate text-sm font-medium text-popover-foreground", children: name }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: email })] }), groups.map((items, i) => (_jsx(MenuGroup, { items: items, Link: Link }, i))), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: onSignOut, children: [_jsx(LogOutIcon, { "aria-hidden": true }), " ", messages.user.signOut] })] })] }));
|
|
24
|
+
}
|
|
25
|
+
function MenuGroup({ items, Link, }) {
|
|
26
|
+
return (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: items.map((item) => {
|
|
27
|
+
const Icon = item.icon;
|
|
28
|
+
const content = (_jsxs(_Fragment, { children: [Icon && _jsx(Icon, { "aria-hidden": true }), " ", item.label] }));
|
|
29
|
+
return item.href ? (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { href: item.href, children: content }) }, item.label)) : (_jsx(DropdownMenuItem, { onSelect: item.onSelect, children: content }, item.label));
|
|
30
|
+
}) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Where a rail is drawn: inline beside the content, or inside the drawer the
|
|
4
|
+
* shell opens on narrow screens. In a drawer a rail fills it, ignores its
|
|
5
|
+
* collapsed state and width, and hides the controls that only make sense
|
|
6
|
+
* beside the content.
|
|
7
|
+
*/
|
|
8
|
+
export type RailPlacement = "inline" | "drawer";
|
|
9
|
+
export declare const RailPlacementContext: React.Context<RailPlacement>;
|
|
10
|
+
export declare function useRailPlacement(): RailPlacement;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rail's collapsed state, remembered per person in this browser.
|
|
3
|
+
* `defaultCollapsed` applies until they choose: Astrolift's projects rail
|
|
4
|
+
* passes "narrower than 1280px". The server renders the rail expanded and the
|
|
5
|
+
* client applies the saved choice without a hydration mismatch; a change in
|
|
6
|
+
* one tab reaches the others.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useRailState(storageKey: string, defaultCollapsed?: () => boolean): readonly [boolean, (collapsed: boolean) => void];
|
|
9
|
+
/** A resizable rail's width in px, remembered like its collapsed state and held to its range. */
|
|
10
|
+
export declare function useRailWidth(storageKey: string, defaultWidth: number, range: {
|
|
11
|
+
min: number;
|
|
12
|
+
max: number;
|
|
13
|
+
}): readonly [number, (width: number) => void];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { readLocal, subscribeLocal, writeLocal } from "../lib/local-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* One rail's collapsed state, remembered per person in this browser.
|
|
6
|
+
* `defaultCollapsed` applies until they choose: Astrolift's projects rail
|
|
7
|
+
* passes "narrower than 1280px". The server renders the rail expanded and the
|
|
8
|
+
* client applies the saved choice without a hydration mismatch; a change in
|
|
9
|
+
* one tab reaches the others.
|
|
10
|
+
*/
|
|
11
|
+
export function useRailState(storageKey, defaultCollapsed = () => false) {
|
|
12
|
+
const collapsed = React.useSyncExternalStore(subscribeLocal, () => {
|
|
13
|
+
const saved = readLocal(storageKey);
|
|
14
|
+
return saved === "1" ? true : saved === "0" ? false : defaultCollapsed();
|
|
15
|
+
}, () => false);
|
|
16
|
+
const set = React.useCallback((next) => writeLocal(storageKey, next ? "1" : "0"), [storageKey]);
|
|
17
|
+
return [collapsed, set];
|
|
18
|
+
}
|
|
19
|
+
/** A resizable rail's width in px, remembered like its collapsed state and held to its range. */
|
|
20
|
+
export function useRailWidth(storageKey, defaultWidth, range) {
|
|
21
|
+
const { min, max } = range;
|
|
22
|
+
const width = React.useSyncExternalStore(subscribeLocal, () => {
|
|
23
|
+
const saved = Number(readLocal(storageKey));
|
|
24
|
+
return Number.isFinite(saved) && saved >= min && saved <= max ? saved : defaultWidth;
|
|
25
|
+
}, () => defaultWidth);
|
|
26
|
+
const set = React.useCallback((next) => writeLocal(storageKey, String(Math.round(Math.min(max, Math.max(min, next))))), [storageKey, min, max]);
|
|
27
|
+
return [width, set];
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
export interface EmptyStateProps {
|
|
3
|
+
icon?: React.ReactNode;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: React.ReactNode;
|
|
6
|
+
/** The create action: a link, so it can open in a new tab. */
|
|
7
|
+
actionHref?: string;
|
|
8
|
+
actionLabel?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Context help, low-prominence beneath the description (Astrolift #356).
|
|
11
|
+
* External URLs open in a new tab; app paths go through the app's link.
|
|
12
|
+
*/
|
|
13
|
+
learnMoreHref?: string;
|
|
14
|
+
learnMoreLabel?: string;
|
|
15
|
+
/** Anything else: a secondary action, a button with a handler. */
|
|
16
|
+
secondary?: React.ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Nothing to show yet, with the way to change that. "No matches" is a different state; see DataTable. */
|
|
20
|
+
export declare function EmptyState({ icon, title, description, actionHref, actionLabel, learnMoreHref, learnMoreLabel, secondary, className, }: EmptyStateProps): React.JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ExternalLinkIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { Button } from "../primitives/Button.js";
|
|
6
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
7
|
+
/** Nothing to show yet, with the way to change that. "No matches" is a different state; see DataTable. */
|
|
8
|
+
export function EmptyState({ icon, title, description, actionHref, actionLabel, learnMoreHref, learnMoreLabel, secondary, className, }) {
|
|
9
|
+
const { Link, messages } = useUI();
|
|
10
|
+
const learnMoreText = learnMoreLabel ?? messages.states.learnMore;
|
|
11
|
+
const external = learnMoreHref ? /^https?:\/\//.test(learnMoreHref) : false;
|
|
12
|
+
const learnMoreClass = "mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline";
|
|
13
|
+
return (_jsxs("div", { "data-slot": "empty-state", className: cn("flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 py-12 text-center whitespace-normal", className), children: [icon && (_jsx("div", { "aria-hidden": true, className: "rounded-lg bg-muted p-2 text-muted-foreground [&_svg]:size-5", children: icon })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "font-medium [overflow-wrap:anywhere]", children: title }), description && (_jsx("p", { className: "mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground", children: description })), learnMoreHref &&
|
|
14
|
+
(external ? (_jsxs("a", { href: learnMoreHref, target: "_blank", rel: "noreferrer", className: learnMoreClass, children: [learnMoreText, _jsx(ExternalLinkIcon, { className: "size-3", "aria-hidden": true })] })) : (_jsx(Link, { href: learnMoreHref, className: learnMoreClass, children: learnMoreText })))] }), actionHref && actionLabel && (_jsx(Button, { asChild: true, size: "sm", children: _jsx(Link, { href: actionHref, children: actionLabel }) })), secondary] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ErrorStateProps {
|
|
2
|
+
/** What failed ("Could not load runs"). Defaults to "Something went wrong". */
|
|
3
|
+
title?: string;
|
|
4
|
+
/** The error, or its message. Without one, a generic line. */
|
|
5
|
+
error?: string | {
|
|
6
|
+
message: string;
|
|
7
|
+
} | null;
|
|
8
|
+
onRetry?: () => void;
|
|
9
|
+
retryLabel?: string;
|
|
10
|
+
/**
|
|
11
|
+
* `inline` is a callout inside a page, for one query that failed. `page` is
|
|
12
|
+
* the whole surface, for an error boundary: centred, with the digest a
|
|
13
|
+
* report can be traced by, and a way out when retrying fails the same way.
|
|
14
|
+
*/
|
|
15
|
+
variant?: "inline" | "page";
|
|
16
|
+
/** The server's error digest, shown on the page variant. */
|
|
17
|
+
digest?: string;
|
|
18
|
+
/** A known-good page to escape to, on the page variant. */
|
|
19
|
+
homeHref?: string;
|
|
20
|
+
homeLabel?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function ErrorState({ title, error, onRetry, retryLabel, variant, digest, homeHref, homeLabel, className, }: ErrorStateProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { CircleAlertIcon, TriangleAlertIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { Button } from "../primitives/Button.js";
|
|
6
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
7
|
+
export function ErrorState({ title, error, onRetry, retryLabel, variant = "inline", digest, homeHref, homeLabel, className, }) {
|
|
8
|
+
const { Link, messages } = useUI();
|
|
9
|
+
const message = (typeof error === "string" ? error : error?.message) || messages.states.unexpected;
|
|
10
|
+
const heading = title ?? messages.states.errorTitle;
|
|
11
|
+
const retry = onRetry && (_jsx(Button, { type: "button", size: "sm", variant: "outline", onClick: onRetry, children: retryLabel ?? messages.states.retry }));
|
|
12
|
+
if (variant === "page") {
|
|
13
|
+
return (_jsxs("div", { role: "alert", "data-slot": "error-state", className: cn("flex flex-1 flex-col items-center justify-center gap-4 p-6 text-center", className), children: [_jsx("div", { "aria-hidden": true, className: "rounded-full bg-danger-bg p-4 text-danger", children: _jsx(CircleAlertIcon, { className: "size-8" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-lg font-semibold", children: heading }), _jsx("p", { className: "mt-1 max-w-sm text-sm [overflow-wrap:anywhere] text-muted-foreground", children: message }), digest && (_jsx("p", { className: "mt-2 font-mono text-xs text-muted-foreground", children: messages.states.errorId(digest) }))] }), (retry || homeHref) && (_jsxs("div", { className: "flex items-center gap-2", children: [retry, homeHref && homeLabel && (_jsx(Button, { asChild: true, size: "sm", variant: "ghost", children: _jsx(Link, { href: homeHref, children: homeLabel }) }))] }))] }));
|
|
14
|
+
}
|
|
15
|
+
return (_jsxs("div", { role: "alert", "data-slot": "error-state", className: cn("flex min-w-0 flex-col gap-3 rounded-lg border border-danger-border bg-danger/5 p-4", className), children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [_jsx(TriangleAlertIcon, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0 text-danger" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium text-danger-fg", children: heading }), _jsx("p", { className: "mt-1 text-xs [overflow-wrap:anywhere] text-muted-foreground", children: message })] })] }), retry && _jsx("div", { children: retry })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface LoadingStateProps {
|
|
2
|
+
/** Announced to screen readers ("Loading runs"). Defaults to "Loading". */
|
|
3
|
+
label?: string;
|
|
4
|
+
/**
|
|
5
|
+
* `rows` stands in for a list: hairline rows in one panel. `page` stands in
|
|
6
|
+
* for a whole route: a row of stat tiles over two panels. Tables draw their
|
|
7
|
+
* own skeleton rows inside the real table, sized from the columns.
|
|
8
|
+
*/
|
|
9
|
+
variant?: "rows" | "page";
|
|
10
|
+
rows?: number;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The loading placeholder: the geometry of what is coming, so nothing jumps when it lands. */
|
|
14
|
+
export declare function LoadingState({ label, variant, rows, className }: LoadingStateProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
5
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
6
|
+
/** The loading placeholder: the geometry of what is coming, so nothing jumps when it lands. */
|
|
7
|
+
export function LoadingState({ label, variant = "rows", rows = 5, className }) {
|
|
8
|
+
const { messages } = useUI();
|
|
9
|
+
const status = (_jsx("p", { role: "status", className: "sr-only", children: label ?? messages.states.loading }));
|
|
10
|
+
if (variant === "page") {
|
|
11
|
+
return (_jsxs("div", { "data-slot": "loading-state", "aria-busy": true, className: cn("flex min-w-0 flex-1 flex-col gap-6", className), children: [status, _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: Array.from({ length: 4 }, (_, i) => (_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border bg-card p-4", children: [_jsx(Skeleton, { className: "h-4 w-24" }), _jsx(Skeleton, { className: "h-8 w-32" })] }, i))) }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border bg-card p-4 lg:col-span-2", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-60 w-full" })] }), _jsxs("div", { className: "flex flex-col gap-3 rounded-lg border bg-card p-4", children: [_jsx(Skeleton, { className: "h-5 w-28" }), _jsx(Skeleton, { className: "h-60 w-full" })] })] })] }));
|
|
12
|
+
}
|
|
13
|
+
return (_jsxs("div", { "data-slot": "loading-state", "aria-busy": true, className: cn("min-w-0 overflow-hidden rounded-lg border", className), children: [status, Array.from({ length: rows }, (_, i) => (_jsxs("div", { className: "flex items-center gap-3 border-b px-(--row-pad-x) py-(--row-pad-y) last:border-b-0", children: [_jsx(Skeleton, { className: "size-4 shrink-0" }), _jsx(Skeleton, { className: "h-4 flex-1" }), _jsx(Skeleton, { className: "h-4 w-20 shrink-0" })] }, i)))] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import { type StatusTone } from "./tones.js";
|
|
3
|
+
export interface StatusBadgeProps extends Omit<React.ComponentProps<"span">, "children"> {
|
|
4
|
+
tone: StatusTone;
|
|
5
|
+
/** The status in words ("running", "failed"). Colour is never the only carrier. */
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
/** In flight (deploying, running): the dot pulses; the label holds still, so it stays readable. */
|
|
8
|
+
pulse?: boolean;
|
|
9
|
+
/** Strike the label through, for superseded states. */
|
|
10
|
+
struck?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A status pill: mono label, tinted fill, glowing dot (the admin badge
|
|
14
|
+
* recipe). Apps map their own states onto the five tones, once, in a table:
|
|
15
|
+
*
|
|
16
|
+
* const DEPLOYMENT_TONE = { running: "ok", failed: "error", deploying: "info", ... };
|
|
17
|
+
* <StatusBadge tone={DEPLOYMENT_TONE[d.status]} pulse={d.status === "deploying"}>{label}</StatusBadge>
|
|
18
|
+
*/
|
|
19
|
+
export declare function StatusBadge({ tone, children, pulse, struck, className, ...props }: StatusBadgeProps): React.JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { IN_FLIGHT, PILL_TONE } from "./tones.js";
|
|
4
|
+
/**
|
|
5
|
+
* A status pill: mono label, tinted fill, glowing dot (the admin badge
|
|
6
|
+
* recipe). Apps map their own states onto the five tones, once, in a table:
|
|
7
|
+
*
|
|
8
|
+
* const DEPLOYMENT_TONE = { running: "ok", failed: "error", deploying: "info", ... };
|
|
9
|
+
* <StatusBadge tone={DEPLOYMENT_TONE[d.status]} pulse={d.status === "deploying"}>{label}</StatusBadge>
|
|
10
|
+
*/
|
|
11
|
+
export function StatusBadge({ tone, children, pulse = false, struck = false, className, ...props }) {
|
|
12
|
+
return (_jsxs("span", { "data-slot": "status-badge", "data-tone": tone, className: cn("inline-flex h-5 w-fit shrink-0 items-center gap-1.5 overflow-hidden rounded-full border px-2 font-mono text-xs font-semibold whitespace-nowrap", PILL_TONE[tone], className), ...props, children: [_jsx("span", { "aria-hidden": true, className: cn("size-1.5 shrink-0 rounded-full bg-current shadow-[0_0_8px_currentColor]", pulse && IN_FLIGHT) }), _jsx("span", { className: cn("truncate", struck && "line-through"), children: children })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type StatusTone } from "./tones.js";
|
|
2
|
+
export interface StatusDotProps {
|
|
3
|
+
tone: StatusTone;
|
|
4
|
+
/** In flight (deploying, running): the dot pulses. */
|
|
5
|
+
pulse?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Read by screen readers when the dot stands alone (a tree row, a table
|
|
8
|
+
* cell with no text). Leave it out when a visible label sits beside it.
|
|
9
|
+
*/
|
|
10
|
+
label?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
/** A status mark. Its colour is a status token, so it means the same thing whatever the accent. */
|
|
14
|
+
export declare function StatusDot({ tone, pulse, label, className }: StatusDotProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { DOT_TONE, IN_FLIGHT } from "./tones.js";
|
|
4
|
+
/** A status mark. Its colour is a status token, so it means the same thing whatever the accent. */
|
|
5
|
+
export function StatusDot({ tone, pulse = false, label, className }) {
|
|
6
|
+
return (_jsxs("span", { className: cn("inline-flex shrink-0 items-center", className), "data-tone": tone, children: [_jsx("span", { "aria-hidden": true, className: cn("inline-block size-2 rounded-full shadow-[0_0_0_2px_var(--background)]", DOT_TONE[tone], pulse && IN_FLIGHT) }), label && _jsx("span", { className: "sr-only", children: label })] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one status colour map (Astrolift #2126). Status is semantic and never
|
|
3
|
+
* follows the accent: with a copper or ice accent a healthy deployment is
|
|
4
|
+
* still lime, a failure still red. Every tone here is a status token.
|
|
5
|
+
*/
|
|
6
|
+
export type StatusTone = "ok" | "warn" | "error" | "info" | "muted";
|
|
7
|
+
export declare const STATUS_TONES: readonly StatusTone[];
|
|
8
|
+
/** The status token each tone paints. `muted` has none: it is the muted foreground. */
|
|
9
|
+
export declare const TONE_TOKEN: Record<StatusTone, "success" | "warning" | "danger" | "info" | null>;
|
|
10
|
+
/** A dot: solid status colour with a soft halo, the one glow the brand permits besides focus. */
|
|
11
|
+
export declare const DOT_TONE: Record<StatusTone, string>;
|
|
12
|
+
/** How much of the status colour tints a pill's fill (`bg-success/15`). */
|
|
13
|
+
export declare const PILL_TINT = 0.15;
|
|
14
|
+
/** A pill: tinted fill, readable text, tinted hairline. */
|
|
15
|
+
export declare const PILL_TONE: Record<StatusTone, string>;
|
|
16
|
+
/** In-flight states pulse; nothing else moves. Reduced motion holds it still. */
|
|
17
|
+
export declare const IN_FLIGHT = "animate-pulse motion-reduce:animate-none";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const STATUS_TONES = ["ok", "warn", "error", "info", "muted"];
|
|
2
|
+
/** The status token each tone paints. `muted` has none: it is the muted foreground. */
|
|
3
|
+
export const TONE_TOKEN = {
|
|
4
|
+
ok: "success",
|
|
5
|
+
warn: "warning",
|
|
6
|
+
error: "danger",
|
|
7
|
+
info: "info",
|
|
8
|
+
muted: null,
|
|
9
|
+
};
|
|
10
|
+
/** A dot: solid status colour with a soft halo, the one glow the brand permits besides focus. */
|
|
11
|
+
export const DOT_TONE = {
|
|
12
|
+
ok: "bg-success shadow-success/50",
|
|
13
|
+
warn: "bg-warning shadow-warning/50",
|
|
14
|
+
error: "bg-danger shadow-danger/50",
|
|
15
|
+
info: "bg-info shadow-info/50",
|
|
16
|
+
muted: "bg-muted-foreground shadow-muted-foreground/40",
|
|
17
|
+
};
|
|
18
|
+
/** How much of the status colour tints a pill's fill (`bg-success/15`). */
|
|
19
|
+
export const PILL_TINT = 0.15;
|
|
20
|
+
/** A pill: tinted fill, readable text, tinted hairline. */
|
|
21
|
+
export const PILL_TONE = {
|
|
22
|
+
ok: "border-success-border bg-success/15 text-success-fg",
|
|
23
|
+
warn: "border-warning-border bg-warning/15 text-warning-fg",
|
|
24
|
+
error: "border-danger-border bg-danger/15 text-danger-fg",
|
|
25
|
+
info: "border-info-border bg-info/15 text-info-fg",
|
|
26
|
+
muted: "border-border bg-foreground/5 text-muted-foreground",
|
|
27
|
+
};
|
|
28
|
+
/** In-flight states pulse; nothing else moves. Reduced motion holds it still. */
|
|
29
|
+
export const IN_FLIGHT = "animate-pulse motion-reduce:animate-none";
|