@calliopelabs/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +332 -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 +94 -0
- package/dist/appearance/brands.js +61 -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,96 @@
|
|
|
1
|
+
import { ACCENTS, ACCENT_KEYS, GROUNDS, GROUND_KEYS, } from "../theme/tokens.js";
|
|
2
|
+
export const CORNERS = [0, 2, 4, 10];
|
|
3
|
+
const SLUG = /^[a-z][a-z0-9-]*$/;
|
|
4
|
+
const HEX = /^#[0-9a-f]{6}$/;
|
|
5
|
+
function fail(product, message) {
|
|
6
|
+
throw new Error(`@calliopelabs/ui: product "${product}": ${message}`);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate a product config and fill in its defaults. Throws on anything
|
|
10
|
+
* that would paint wrong or inject into the generated CSS: a key that is not
|
|
11
|
+
* a slug, a colour that is not lowercase `#rrggbb`, a default ground the
|
|
12
|
+
* product does not offer, or a default ground of the wrong mode.
|
|
13
|
+
*/
|
|
14
|
+
export function defineProduct(config) {
|
|
15
|
+
const { id } = config;
|
|
16
|
+
if (!SLUG.test(id))
|
|
17
|
+
fail(id, "id must be a lowercase slug");
|
|
18
|
+
if (!config.name.trim())
|
|
19
|
+
fail(id, "name is required");
|
|
20
|
+
if (!HEX.test(config.sidebar))
|
|
21
|
+
fail(id, `sidebar must be lowercase #rrggbb, got ${config.sidebar}`);
|
|
22
|
+
const grounds = config.grounds ? [...config.grounds] : [...GROUND_KEYS];
|
|
23
|
+
if (grounds.length === 0)
|
|
24
|
+
fail(id, "offer at least one ground");
|
|
25
|
+
for (const ground of grounds) {
|
|
26
|
+
if (!(ground in GROUNDS))
|
|
27
|
+
fail(id, `unknown ground ${ground}`);
|
|
28
|
+
}
|
|
29
|
+
const defaultGround = config.defaultGround ?? {
|
|
30
|
+
dark: grounds.find((g) => GROUNDS[g].mode === "dark") ?? fail(id, "offer a dark ground"),
|
|
31
|
+
light: grounds.find((g) => GROUNDS[g].mode === "light") ?? fail(id, "offer a light ground"),
|
|
32
|
+
};
|
|
33
|
+
for (const mode of ["dark", "light"]) {
|
|
34
|
+
const ground = defaultGround[mode];
|
|
35
|
+
if (!grounds.includes(ground))
|
|
36
|
+
fail(id, `default ${mode} ground ${ground} is not offered`);
|
|
37
|
+
if (GROUNDS[ground].mode !== mode)
|
|
38
|
+
fail(id, `default ${mode} ground ${ground} is a ${GROUNDS[ground].mode} ground`);
|
|
39
|
+
}
|
|
40
|
+
const family = ACCENT_KEYS.map((key) => ({
|
|
41
|
+
key,
|
|
42
|
+
...ACCENTS[key],
|
|
43
|
+
brand: false,
|
|
44
|
+
}));
|
|
45
|
+
let brandAccent = null;
|
|
46
|
+
if (typeof config.accent === "string") {
|
|
47
|
+
if (!(config.accent in ACCENTS))
|
|
48
|
+
fail(id, `unknown accent ${config.accent}`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
brandAccent = config.accent;
|
|
52
|
+
if (!SLUG.test(brandAccent.key))
|
|
53
|
+
fail(id, "brand accent key must be a lowercase slug");
|
|
54
|
+
if (brandAccent.key in ACCENTS || brandAccent.key === "custom") {
|
|
55
|
+
fail(id, `brand accent key ${brandAccent.key} is taken by the family`);
|
|
56
|
+
}
|
|
57
|
+
for (const field of ["dark", "onDark", "light", "onLight"]) {
|
|
58
|
+
if (!HEX.test(brandAccent[field]))
|
|
59
|
+
fail(id, `brand accent ${field} must be lowercase #rrggbb`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const accents = brandAccent
|
|
63
|
+
? [{ ...brandAccent, brand: true }, ...family]
|
|
64
|
+
: family;
|
|
65
|
+
const defaultAccent = typeof config.accent === "string" ? config.accent : config.accent.key;
|
|
66
|
+
const density = config.defaultDensity ?? "compact";
|
|
67
|
+
if (density !== "compact" && density !== "cards")
|
|
68
|
+
fail(id, `unknown density ${String(density)}`);
|
|
69
|
+
const corners = config.defaultCorners ?? 2;
|
|
70
|
+
if (!CORNERS.includes(corners))
|
|
71
|
+
fail(id, `corners must be one of ${CORNERS.join(", ")}`);
|
|
72
|
+
const themes = config.themes ?? [];
|
|
73
|
+
for (const theme of themes) {
|
|
74
|
+
if (!grounds.includes(theme.ground))
|
|
75
|
+
fail(id, `theme ${theme.id} uses ground ${theme.ground}, which is not offered`);
|
|
76
|
+
if (!accents.some((a) => a.key === theme.accent))
|
|
77
|
+
fail(id, `theme ${theme.id} uses unknown accent ${theme.accent}`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
name: config.name,
|
|
82
|
+
sidebar: config.sidebar,
|
|
83
|
+
grounds,
|
|
84
|
+
accents,
|
|
85
|
+
brandAccent,
|
|
86
|
+
defaults: {
|
|
87
|
+
ground: defaultGround,
|
|
88
|
+
accent: defaultAccent,
|
|
89
|
+
density,
|
|
90
|
+
corners,
|
|
91
|
+
},
|
|
92
|
+
themes,
|
|
93
|
+
nav: config.nav ?? [],
|
|
94
|
+
storageKey: `${id}.appearance`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Density } from "../theme/tokens.js";
|
|
3
|
+
import { type Column, type EmptyStateSpec, type TableController } from "./types.js";
|
|
4
|
+
import type { RowSelection } from "./use-row-selection.js";
|
|
5
|
+
type RowActivation<TRow> = {
|
|
6
|
+
/** Rows that navigate are real links: middle-click, open in a new tab and copy link all work. */
|
|
7
|
+
rowHref: (row: TRow) => string;
|
|
8
|
+
onRowActivate?: never;
|
|
9
|
+
/** Names a row for its checkbox and its menu ("run 7f3c2a91"). Defaults to the row id. */
|
|
10
|
+
rowLabel?: (row: TRow) => string;
|
|
11
|
+
} | {
|
|
12
|
+
/** Rows that act in place are buttons, never fake links. */
|
|
13
|
+
onRowActivate: (row: TRow) => void;
|
|
14
|
+
/**
|
|
15
|
+
* The activator's accessible name, and the name of the row's checkbox
|
|
16
|
+
* and menu. Identify the row ("run 7f3c2a91"); the role supplies the verb.
|
|
17
|
+
*/
|
|
18
|
+
rowLabel: (row: TRow) => string;
|
|
19
|
+
rowHref?: never;
|
|
20
|
+
} | {
|
|
21
|
+
rowHref?: never;
|
|
22
|
+
onRowActivate?: never;
|
|
23
|
+
rowLabel?: (row: TRow) => string;
|
|
24
|
+
};
|
|
25
|
+
interface DataTableBaseProps<TRow> {
|
|
26
|
+
/** Names the table for screen readers and in the state copy ("Could not load runs"). */
|
|
27
|
+
label: string;
|
|
28
|
+
controller: TableController<TRow>;
|
|
29
|
+
columns: readonly Column<TRow>[];
|
|
30
|
+
getRowId: (row: TRow) => string;
|
|
31
|
+
/** The list is genuinely empty. Required: a table never renders nothing. */
|
|
32
|
+
empty: EmptyStateSpec;
|
|
33
|
+
/** The search or filters excluded everything. Defaults to a clear-search prompt. */
|
|
34
|
+
emptyFiltered?: Pick<EmptyStateSpec, "title" | "description">;
|
|
35
|
+
/** Turns on the row and page checkboxes. */
|
|
36
|
+
selection?: RowSelection;
|
|
37
|
+
/** Shown in a bar above the table while rows are selected. */
|
|
38
|
+
bulkActions?: (selection: RowSelection) => React.ReactNode;
|
|
39
|
+
/** Items for each row's `⋯` menu (`DropdownMenuItem`s). */
|
|
40
|
+
rowActions?: (row: TRow) => React.ReactNode;
|
|
41
|
+
searchPlaceholder?: string;
|
|
42
|
+
/** Filters and actions beside the search box. */
|
|
43
|
+
toolbar?: React.ReactNode;
|
|
44
|
+
rowClassName?: (row: TRow) => string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The card for a row, drawn when the density is `cards`: the content only;
|
|
47
|
+
* the table draws the box. Without it, cards density keeps the table with
|
|
48
|
+
* roomier rows. Cards carry no checkboxes and no row menu.
|
|
49
|
+
*/
|
|
50
|
+
renderCard?: (row: TRow) => React.ReactNode;
|
|
51
|
+
/** Overrides the person's density for this table. */
|
|
52
|
+
density?: Density;
|
|
53
|
+
/** `table` draws the table alone, for a page that draws its own filter bar and paging. */
|
|
54
|
+
chrome?: "full" | "table";
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
export type DataTableProps<TRow> = DataTableBaseProps<TRow> & RowActivation<TRow>;
|
|
58
|
+
/**
|
|
59
|
+
* The house list surface (Astrolift #1231), with its guarantees in the
|
|
60
|
+
* types: `label` and `empty` are required, `empty` and `emptyFiltered` are
|
|
61
|
+
* different states, rows are links or buttons and never both, loading draws
|
|
62
|
+
* skeleton rows inside the real table, and sorting is the server's. Density
|
|
63
|
+
* is real: compact rows, or boxed cards when the table has a card renderer.
|
|
64
|
+
*/
|
|
65
|
+
export declare function DataTable<TRow>({ label, controller, columns, getRowId, empty, emptyFiltered, selection, bulkActions, rowActions, searchPlaceholder, toolbar, rowClassName, renderCard, density: densityProp, chrome, className, rowHref, onRowActivate, rowLabel, }: DataTableProps<TRow>): React.JSX.Element;
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, MoreHorizontalIcon, SearchIcon, SearchXIcon, TriangleAlertIcon, XIcon, } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useDensity } from "../appearance/AppearanceProvider.js";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { Button } from "../primitives/Button.js";
|
|
8
|
+
import { Checkbox } from "../primitives/Checkbox.js";
|
|
9
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "../primitives/DropdownMenu.js";
|
|
10
|
+
import { Input } from "../primitives/Input.js";
|
|
11
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
12
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
13
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
14
|
+
import { PAGE_SIZES, } from "./types.js";
|
|
15
|
+
const ALIGN = { left: "text-left", right: "text-right", center: "text-center" };
|
|
16
|
+
const CELL = "px-(--row-pad-x) py-(--row-pad-y) align-middle whitespace-nowrap";
|
|
17
|
+
function sortKeys(sort) {
|
|
18
|
+
if (!sort)
|
|
19
|
+
return [];
|
|
20
|
+
return Array.isArray(sort) ? sort : [sort];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The house list surface (Astrolift #1231), with its guarantees in the
|
|
24
|
+
* types: `label` and `empty` are required, `empty` and `emptyFiltered` are
|
|
25
|
+
* different states, rows are links or buttons and never both, loading draws
|
|
26
|
+
* skeleton rows inside the real table, and sorting is the server's. Density
|
|
27
|
+
* is real: compact rows, or boxed cards when the table has a card renderer.
|
|
28
|
+
*/
|
|
29
|
+
export function DataTable({ label, controller, columns, getRowId, empty, emptyFiltered, selection, bulkActions, rowActions, searchPlaceholder, toolbar, rowClassName, renderCard, density: densityProp, chrome = "full", className, rowHref, onRowActivate, rowLabel, }) {
|
|
30
|
+
const { Link, messages } = useUI();
|
|
31
|
+
const t = messages.table;
|
|
32
|
+
const contextDensity = useDensity();
|
|
33
|
+
const density = densityProp ?? contextDensity;
|
|
34
|
+
const { rows, state, error, retry, pageSize, clearFilters } = controller;
|
|
35
|
+
const cards = density === "cards" && Boolean(renderCard);
|
|
36
|
+
const pageIds = React.useMemo(() => rows.map(getRowId), [rows, getRowId]);
|
|
37
|
+
const visibleColumns = rowActions
|
|
38
|
+
? [
|
|
39
|
+
...columns,
|
|
40
|
+
{
|
|
41
|
+
id: "__actions",
|
|
42
|
+
header: _jsx("span", { className: "sr-only", children: t.actions }),
|
|
43
|
+
width: "w-10",
|
|
44
|
+
align: "right",
|
|
45
|
+
// Above the row's stretched link, so the menu opens instead of the row.
|
|
46
|
+
cellClassName: "relative z-10",
|
|
47
|
+
cell: (row) => (_jsx(RowMenu, { label: t.rowActions(rowLabel?.(row) ?? getRowId(row)), children: rowActions(row) })),
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
: [...columns];
|
|
51
|
+
const columnCount = visibleColumns.length + (selection ? 1 : 0);
|
|
52
|
+
const firstColumnId = visibleColumns[0]?.id;
|
|
53
|
+
const sorts = sortKeys(controller.sort);
|
|
54
|
+
const message = (icon, title, body, action, alert = false) => (_jsxs("div", { role: alert ? "alert" : undefined, className: "flex flex-col items-center gap-3 px-6 text-center", children: [icon, _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "font-medium", children: title }), _jsx("p", { className: "mt-1 max-w-md text-sm [overflow-wrap:anywhere] text-muted-foreground", children: body })] }), action] }));
|
|
55
|
+
const errorMessage = message(_jsx(TriangleAlertIcon, { className: "size-5 text-danger", "aria-hidden": true }), t.loadFailed(label), error?.message || t.requestFailed, _jsx(Button, { size: "sm", variant: "outline", onClick: retry, children: t.retry }), true);
|
|
56
|
+
const filteredMessage = message(_jsx(SearchXIcon, { className: "size-5 text-muted-foreground", "aria-hidden": true }), emptyFiltered?.title ?? t.noMatches, emptyFiltered?.description ?? t.noMatchesDescription(label), _jsx(Button, { size: "sm", variant: "outline", onClick: clearFilters, children: t.clearSearch }));
|
|
57
|
+
// A message spanning the row stays in view when a wide table scrolls
|
|
58
|
+
// sideways: it is as wide as the scroll container (100cqw), pinned left.
|
|
59
|
+
const fullRow = (content, padded = true) => (_jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "p-0", children: _jsx("div", { className: cn("sticky left-0 w-[100cqw] max-w-full", padded && "py-10"), children: content }) }) }));
|
|
60
|
+
const tableBody = (() => {
|
|
61
|
+
switch (state) {
|
|
62
|
+
case "loading":
|
|
63
|
+
// Skeleton rows inside the real table, sized from the columns, so the
|
|
64
|
+
// first paint has the geometry the rows will have.
|
|
65
|
+
return Array.from({ length: Math.min(pageSize, 8) }, (_, i) => (_jsxs("tr", { className: "border-b last:border-0", children: [selection && (_jsx("td", { className: cn(CELL, "w-10"), children: _jsx(Skeleton, { className: "size-4" }) })), visibleColumns.map((column) => (_jsx("td", { className: cn(CELL, column.width), children: _jsx(Skeleton, { className: "h-4 w-full" }) }, column.id)))] }, `skeleton-${i}`)));
|
|
66
|
+
case "error":
|
|
67
|
+
return fullRow(errorMessage);
|
|
68
|
+
case "emptyFiltered":
|
|
69
|
+
return fullRow(filteredMessage);
|
|
70
|
+
case "empty":
|
|
71
|
+
return fullRow(_jsx(EmptyState, { ...empty, className: "rounded-none border-0" }), false);
|
|
72
|
+
case "ready":
|
|
73
|
+
return rows.map((row) => {
|
|
74
|
+
const id = getRowId(row);
|
|
75
|
+
const selected = selection?.isSelected(id) ?? false;
|
|
76
|
+
return (_jsxs("tr", { "data-state": selected ? "selected" : undefined,
|
|
77
|
+
// `relative`: the activator is stretched with `after:inset-0`,
|
|
78
|
+
// which reaches the nearest positioned ancestor; without it the
|
|
79
|
+
// last row's overlay would swallow every click (Astrolift #1502).
|
|
80
|
+
className: cn("border-b transition-colors last:border-0 hover:bg-muted/50 data-[state=selected]:bg-muted", (rowHref || onRowActivate) && "relative cursor-pointer", rowClassName?.(row)), children: [selection && (_jsx("td", { className: cn(CELL, "relative z-10 w-10"), children: _jsx(Checkbox, { checked: selected, onCheckedChange: () => selection.toggle(id), "aria-label": t.selectRow(rowLabel?.(row) ?? id) }) })), visibleColumns.map((column) => (_jsx("td", { className: cn(CELL, column.width, column.align && ALIGN[column.align], column.cellClassName), children: column.id === firstColumnId && rowHref ? (_jsx(Link, { href: rowHref(row), className: "outline-none after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset", children: column.cell(row) })) : column.id === firstColumnId && onRowActivate ? (_jsx("button", { type: "button", "aria-label": rowLabel?.(row), onClick: () => onRowActivate(row), className: "text-left outline-none after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset", children: column.cell(row) })) : (column.cell(row)) }, column.id)))] }, id));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
const table = (_jsxs("div", { className: "relative overflow-hidden rounded-lg border", "aria-busy": state === "loading" || controller.isStale || undefined, children: [controller.isStale && _jsx(StaleBar, {}), _jsx("div", { className: "[container-type:inline-size] relative w-full overflow-x-auto", children: _jsxs("table", { className: "w-full caption-bottom text-sm", children: [_jsx("caption", { className: "sr-only", children: label }), _jsx("thead", { children: _jsxs("tr", { className: "border-b", children: [selection && (_jsx("th", { scope: "col", className: "h-10 w-10 px-(--row-pad-x) align-middle", children: _jsx(Checkbox, { checked: selection.pageSelectionState(pageIds), onCheckedChange: () => selection.togglePage(pageIds), "aria-label": t.selectPage(label), disabled: pageIds.length === 0 }) })), visibleColumns.map((column) => (_jsx("th", { scope: "col", "aria-sort": ariaSort(column.sortKey, sorts), className: cn("h-10 px-(--row-pad-x) text-left align-middle text-2xs font-semibold tracking-[0.08em] whitespace-nowrap text-muted-foreground uppercase", column.width, column.align && ALIGN[column.align], column.headClassName), children: column.sortKey && controller.sortEnabled ? (_jsx(SortHeader, { sortKey: column.sortKey, sorts: sorts, onToggle: controller.toggleSort, children: column.header })) : (column.header) }, column.id)))] }) }), _jsx("tbody", { children: tableBody })] }) })] }));
|
|
85
|
+
const cardGrid = (() => {
|
|
86
|
+
const grid = "grid min-w-0 grid-cols-(--list-columns) gap-(--list-gap)";
|
|
87
|
+
const box = "relative min-w-0 rounded-(--list-item-radius) border-(length:--list-item-border) bg-(--list-item-bg) [padding:var(--list-item-pad)]";
|
|
88
|
+
switch (state) {
|
|
89
|
+
case "loading":
|
|
90
|
+
return (_jsx("div", { className: grid, "aria-busy": true, children: Array.from({ length: Math.min(pageSize, 6) }, (_, i) => (_jsx(Skeleton, { className: "h-28 w-full" }, i))) }));
|
|
91
|
+
case "error":
|
|
92
|
+
return _jsx("div", { className: "rounded-lg border py-10", children: errorMessage });
|
|
93
|
+
case "emptyFiltered":
|
|
94
|
+
return _jsx("div", { className: "rounded-lg border py-10", children: filteredMessage });
|
|
95
|
+
case "empty":
|
|
96
|
+
return _jsx(EmptyState, { ...empty });
|
|
97
|
+
case "ready":
|
|
98
|
+
return (_jsxs("div", { className: "relative", "aria-busy": controller.isStale || undefined, children: [controller.isStale && _jsx(StaleBar, { className: "-top-1.5" }), _jsx("ul", { "aria-label": label, className: grid, children: rows.map((row) => (_jsx("li", { className: cn(box, rowClassName?.(row)), children: rowHref ? (_jsx(Link, { href: rowHref(row), className: "block h-full rounded-lg outline-none after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-ring", children: renderCard?.(row) })) : onRowActivate ? (_jsx("button", { type: "button", "aria-label": rowLabel?.(row), onClick: () => onRowActivate(row), className: "block h-full w-full text-left outline-none after:absolute after:inset-0 focus-visible:after:ring-2 focus-visible:after:ring-ring", children: renderCard?.(row) })) : (renderCard?.(row)) }, getRowId(row)))) })] }));
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return (_jsxs("div", { "data-slot": "data-table", "data-density": densityProp, className: cn("flex min-w-0 flex-col gap-3", className), children: [chrome === "full" && (_jsx(Toolbar, { controller: controller, placeholder: searchPlaceholder, children: toolbar })), selection && selection.selectedCount > 0 && bulkActions && !cards && (_jsxs("div", { className: "flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2", children: [_jsx("span", { className: "text-sm font-medium tabular-nums", children: t.selected(selection.selectedCount) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: selection.clear, children: t.clearSelection }), _jsx("div", { className: "ml-auto flex flex-wrap items-center gap-2", children: bulkActions(selection) })] })), cards ? cardGrid : table, chrome === "full" && _jsx(Pagination, { controller: controller }), state === "loading" && (_jsx("p", { role: "status", className: "sr-only", children: t.loading }))] }));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The rows on screen answer the previous question while the next answer is
|
|
105
|
+
* on its way. They stay at full contrast; this line across the top of the
|
|
106
|
+
* frame says they are being replaced. With reduced motion it holds still.
|
|
107
|
+
*/
|
|
108
|
+
function StaleBar({ className }) {
|
|
109
|
+
return (_jsx("div", { "aria-hidden": true, className: cn("pointer-events-none absolute inset-x-0 top-0 z-20 h-0.5 overflow-hidden", className), children: _jsx("div", { className: "h-full w-1/3 animate-cui-progress rounded-full bg-primary motion-reduce:animate-none" }) }));
|
|
110
|
+
}
|
|
111
|
+
function ariaSort(sortKey, sorts) {
|
|
112
|
+
const primary = sorts[0];
|
|
113
|
+
if (!sortKey || primary?.key !== sortKey)
|
|
114
|
+
return undefined;
|
|
115
|
+
return primary.dir === "asc" ? "ascending" : "descending";
|
|
116
|
+
}
|
|
117
|
+
function SortHeader({ sortKey, sorts, onToggle, children, }) {
|
|
118
|
+
const { messages } = useUI();
|
|
119
|
+
const index = sorts.findIndex((s) => s.key === sortKey);
|
|
120
|
+
const dir = index === -1 ? undefined : sorts[index]?.dir;
|
|
121
|
+
return (_jsxs("button", { type: "button", onClick: (event) => onToggle(sortKey, event.shiftKey), className: cn("inline-flex items-center gap-1 rounded-lg uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", dir ? "text-foreground" : "text-muted-foreground"), children: [children, dir === "asc" ? (_jsx(ArrowUpIcon, { className: "size-3.5", "aria-hidden": true })) : dir === "desc" ? (_jsx(ArrowDownIcon, { className: "size-3.5", "aria-hidden": true })) : (_jsx(ChevronsUpDownIcon, { className: "size-3.5 opacity-40", "aria-hidden": true })), index !== -1 && sorts.length > 1 && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "font-mono text-2xs tabular-nums", children: index + 1 }), _jsxs("span", { className: "sr-only", children: [", ", messages.table.sortKey(index + 1)] })] }))] }));
|
|
122
|
+
}
|
|
123
|
+
function Toolbar({ controller, placeholder, children, }) {
|
|
124
|
+
const { messages } = useUI();
|
|
125
|
+
const t = messages.table;
|
|
126
|
+
const { search, setSearch, searchEnabled, totalCount } = controller;
|
|
127
|
+
if (!searchEnabled && !children && totalCount === null)
|
|
128
|
+
return null;
|
|
129
|
+
const searchLabel = placeholder ?? t.search;
|
|
130
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [searchEnabled && (_jsxs("div", { className: "relative min-w-56 flex-1 sm:max-w-xs", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }), _jsx(Input, { type: "search", value: search, onChange: (event) => setSearch(event.target.value), placeholder: searchLabel, "aria-label": searchLabel, className: "pr-8 pl-8 [&::-webkit-search-cancel-button]:appearance-none" }), search && (_jsx(Button, { variant: "ghost", size: "icon-xs", onClick: () => setSearch(""), "aria-label": t.clearSearch, className: "absolute top-1/2 right-1 -translate-y-1/2", children: _jsx(XIcon, { "aria-hidden": true }) }))] })), children, totalCount !== null && (_jsx("span", { className: "ml-auto text-sm text-muted-foreground tabular-nums", children: t.results(totalCount) }))] }));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Previous and next, never a page number to jump to: a keyset walk has no
|
|
134
|
+
* "page 7", so the control cannot be rendered honestly.
|
|
135
|
+
*/
|
|
136
|
+
function Pagination({ controller }) {
|
|
137
|
+
const { messages } = useUI();
|
|
138
|
+
const t = messages.table;
|
|
139
|
+
const selectId = React.useId();
|
|
140
|
+
const { pageIndex, hasNext, hasPrev, next, prev, pageSize, setPageSize } = controller;
|
|
141
|
+
const sizes = controller.pageSizes ?? PAGE_SIZES;
|
|
142
|
+
if (!hasNext && !hasPrev)
|
|
143
|
+
return null;
|
|
144
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center justify-end gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("label", { htmlFor: selectId, className: "text-sm text-muted-foreground", children: t.rowsPerPage }), _jsx("select", { id: selectId, value: pageSize, onChange: (event) => setPageSize(Number(event.target.value)), className: "h-7 rounded-lg border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50", children: sizes.map((size) => (_jsx("option", { value: size, children: size }, size))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: t.page(pageIndex + 1) }), _jsx(Button, { variant: "outline", size: "icon", onClick: prev, disabled: !hasPrev, "aria-label": t.previousPage, children: _jsx(ChevronLeftIcon, { "aria-hidden": true }) }), _jsx(Button, { variant: "outline", size: "icon", onClick: next, disabled: !hasNext, "aria-label": t.nextPage, children: _jsx(ChevronRightIcon, { "aria-hidden": true }) })] })] }));
|
|
145
|
+
}
|
|
146
|
+
function RowMenu({ label, children }) {
|
|
147
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, children: _jsx(MoreHorizontalIcon, { "aria-hidden": true }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "min-w-40", children: children })] }));
|
|
148
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { Density } from "../theme/tokens.js";
|
|
3
|
+
import type { EmptyStateSpec } from "./types.js";
|
|
4
|
+
export interface ListProps<TItem> {
|
|
5
|
+
/** Names the list for screen readers and in its error copy. */
|
|
6
|
+
label: string;
|
|
7
|
+
items: readonly TItem[];
|
|
8
|
+
getKey: (item: TItem) => string;
|
|
9
|
+
/** One item's content, usually a ListItem. The list draws the row or the card around it. */
|
|
10
|
+
renderItem: (item: TItem) => React.ReactNode;
|
|
11
|
+
/** Overrides the person's density for this list. */
|
|
12
|
+
density?: Density;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
/** How many placeholder items to draw while loading. */
|
|
15
|
+
loadingCount?: number;
|
|
16
|
+
error?: {
|
|
17
|
+
message: string;
|
|
18
|
+
} | null;
|
|
19
|
+
onRetry?: () => void;
|
|
20
|
+
/** Required: a list never renders nothing. */
|
|
21
|
+
empty: EmptyStateSpec;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A short, fixed list (integrations, links, a summary's top five) in the
|
|
26
|
+
* person's density: compact is one panel of hairline rows; cards boxes each
|
|
27
|
+
* item and flows them into responsive columns. Six links with a name and a
|
|
28
|
+
* sentence are a list, not six cards, until someone asks for cards.
|
|
29
|
+
*
|
|
30
|
+
* The layout is driven by CSS variables that `data-density` sets, so the
|
|
31
|
+
* first paint already has the right one and a subtree can override it. A
|
|
32
|
+
* list that can grow without bound is a DataTable, with server paging.
|
|
33
|
+
*/
|
|
34
|
+
export declare function List<TItem>({ label, items, getKey, renderItem, density, loading, loadingCount, error, onRetry, empty, className, }: ListProps<TItem>): React.JSX.Element;
|
|
35
|
+
export interface ListItemProps {
|
|
36
|
+
title: React.ReactNode;
|
|
37
|
+
/** The whole item links here; the title is the link, stretched over the item. */
|
|
38
|
+
href?: string;
|
|
39
|
+
description?: React.ReactNode;
|
|
40
|
+
/** Leading: an icon or a logo. */
|
|
41
|
+
icon?: React.ReactNode;
|
|
42
|
+
/** Trailing data: mono values, counts, times. */
|
|
43
|
+
meta?: React.ReactNode;
|
|
44
|
+
/** A StatusBadge. */
|
|
45
|
+
status?: React.ReactNode;
|
|
46
|
+
/** Controls, kept above the item's stretched link. */
|
|
47
|
+
actions?: React.ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* One item's content: a row in compact density (icon, title and description,
|
|
52
|
+
* then meta, status and actions on the right) and a card in cards density
|
|
53
|
+
* (the same parts stacked).
|
|
54
|
+
*/
|
|
55
|
+
export declare function ListItem({ title, href, description, icon, meta, status, actions, className, }: ListItemProps): React.JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
7
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
8
|
+
/**
|
|
9
|
+
* A short, fixed list (integrations, links, a summary's top five) in the
|
|
10
|
+
* person's density: compact is one panel of hairline rows; cards boxes each
|
|
11
|
+
* item and flows them into responsive columns. Six links with a name and a
|
|
12
|
+
* sentence are a list, not six cards, until someone asks for cards.
|
|
13
|
+
*
|
|
14
|
+
* The layout is driven by CSS variables that `data-density` sets, so the
|
|
15
|
+
* first paint already has the right one and a subtree can override it. A
|
|
16
|
+
* list that can grow without bound is a DataTable, with server paging.
|
|
17
|
+
*/
|
|
18
|
+
export function List({ label, items, getKey, renderItem, density, loading = false, loadingCount = 4, error, onRetry, empty, className, }) {
|
|
19
|
+
const { messages } = useUI();
|
|
20
|
+
const frame = "grid min-w-0 grid-cols-(--list-columns) gap-(--list-gap) overflow-hidden rounded-(--list-frame-radius) border-(length:--list-frame-border)";
|
|
21
|
+
const box = "relative min-w-0 rounded-(--list-item-radius) border-(length:--list-item-border) bg-(--list-item-bg) [padding:var(--list-item-pad)] not-first:border-t";
|
|
22
|
+
if (error) {
|
|
23
|
+
return (_jsx(ErrorState, { title: messages.table.loadFailed(label), error: error, onRetry: onRetry, className: className }));
|
|
24
|
+
}
|
|
25
|
+
if (loading) {
|
|
26
|
+
return (_jsxs("div", { "data-slot": "list", "data-density": density, "aria-busy": true, className: cn(frame, className), children: [_jsx("p", { role: "status", className: "sr-only", children: messages.states.loading }), Array.from({ length: loadingCount }, (_, i) => (_jsxs("div", { className: cn(box, "flex items-center gap-3"), children: [_jsx(Skeleton, { className: "size-4 shrink-0" }), _jsx(Skeleton, { className: "h-4 flex-1" })] }, i)))] }));
|
|
27
|
+
}
|
|
28
|
+
if (items.length === 0) {
|
|
29
|
+
return _jsx(EmptyState, { ...empty, className: className });
|
|
30
|
+
}
|
|
31
|
+
return (_jsx("ul", { "data-slot": "list", "data-density": density, "aria-label": label, className: cn(frame, className), children: items.map((item) => (_jsx("li", { className: box, children: renderItem(item) }, getKey(item)))) }));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One item's content: a row in compact density (icon, title and description,
|
|
35
|
+
* then meta, status and actions on the right) and a card in cards density
|
|
36
|
+
* (the same parts stacked).
|
|
37
|
+
*/
|
|
38
|
+
export function ListItem({ title, href, description, icon, meta, status, actions, className, }) {
|
|
39
|
+
const { Link } = useUI();
|
|
40
|
+
return (_jsxs("div", { "data-slot": "list-item", className: cn("flex min-w-0 [flex-direction:var(--list-item-direction)] [align-items:var(--list-item-align)] gap-3", className), children: [_jsxs("div", { className: "flex min-w-0 flex-1 items-start gap-3", children: [icon && (_jsx("span", { "aria-hidden": true, className: "mt-0.5 shrink-0 text-muted-foreground [&_svg]:size-4", children: icon })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-sm font-medium", children: href ? (_jsx(Link, { href: href, className: "outline-none after:absolute after:inset-0 after:rounded-(--list-item-radius) hover:underline focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset", children: title })) : (title) }), description && (_jsx("p", { className: "mt-0.5 line-clamp-2 text-sm [overflow-wrap:anywhere] text-muted-foreground", children: description }))] })] }), (meta || status || actions) && (_jsxs("div", { className: "[margin-inline-start:var(--list-item-trail)] flex shrink-0 flex-wrap items-center gap-2", children: [meta && (_jsx("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: meta })), status, actions && _jsx("span", { className: "relative z-10 flex items-center gap-1", children: actions })] }))] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* What a table is showing. A union rather than a bag of booleans, so the
|
|
4
|
+
* render is a total switch: every "renders nothing" bug Astrolift's audit
|
|
5
|
+
* found was a missing branch.
|
|
6
|
+
*/
|
|
7
|
+
export type PageState = "loading" | "error"
|
|
8
|
+
/** No rows and no filter narrowing them: the list is genuinely empty. */
|
|
9
|
+
| "empty"
|
|
10
|
+
/** No rows because the search or filters excluded them all. */
|
|
11
|
+
| "emptyFiltered" | "ready";
|
|
12
|
+
export type SortDirection = "asc" | "desc";
|
|
13
|
+
export interface SortState {
|
|
14
|
+
key: string;
|
|
15
|
+
dir: SortDirection;
|
|
16
|
+
}
|
|
17
|
+
export interface Column<TRow> {
|
|
18
|
+
/** Stable identity; also the React key. */
|
|
19
|
+
id: string;
|
|
20
|
+
header: React.ReactNode;
|
|
21
|
+
/** Plain-text name where a node will not do. Defaults to `header` when that is a string. */
|
|
22
|
+
label?: string;
|
|
23
|
+
cell: (row: TRow) => React.ReactNode;
|
|
24
|
+
/** A Tailwind width class (`w-32`); it also sizes the column's loading skeleton. */
|
|
25
|
+
width?: string;
|
|
26
|
+
align?: "left" | "right" | "center";
|
|
27
|
+
headClassName?: string;
|
|
28
|
+
cellClassName?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The server's sort key. Present, the header is a sort control; absent, a
|
|
31
|
+
* plain header. There is no client sort: reordering one page while the rest
|
|
32
|
+
* of the result set sits on the server is wrong at every page boundary.
|
|
33
|
+
*/
|
|
34
|
+
sortKey?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Copy for a genuinely empty list: what is missing, and how to change that. */
|
|
37
|
+
export interface EmptyStateSpec {
|
|
38
|
+
icon?: React.ReactNode;
|
|
39
|
+
title: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
actionHref?: string;
|
|
42
|
+
actionLabel?: string;
|
|
43
|
+
learnMoreHref?: string;
|
|
44
|
+
learnMoreLabel?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The data side of a DataTable: rows, state, paging, search and sort, with
|
|
48
|
+
* the server doing the work. The package fetches nothing, so each app
|
|
49
|
+
* implements this over its own data layer; Astrolift's `useCursorTable`
|
|
50
|
+
* (Apollo, keyset cursors, debounced server search) already satisfies it.
|
|
51
|
+
* Rules the type cannot hold: paging is server-side; the search box shows only
|
|
52
|
+
* when the query takes a search argument (`searchEnabled`); search is
|
|
53
|
+
* debounced by the controller, never per keystroke; rows from the previous
|
|
54
|
+
* question stay on screen (`isStale`, drawn with a progress line, at full
|
|
55
|
+
* contrast) until the next answer lands.
|
|
56
|
+
*/
|
|
57
|
+
export interface TableController<TRow> {
|
|
58
|
+
rows: readonly TRow[];
|
|
59
|
+
state: PageState;
|
|
60
|
+
error?: {
|
|
61
|
+
message: string;
|
|
62
|
+
} | null;
|
|
63
|
+
retry: () => void;
|
|
64
|
+
/** The exact count when the server knows it cheaply; null otherwise. */
|
|
65
|
+
totalCount: number | null;
|
|
66
|
+
/** 0-based index of the page on screen. */
|
|
67
|
+
pageIndex: number;
|
|
68
|
+
hasNext: boolean;
|
|
69
|
+
hasPrev: boolean;
|
|
70
|
+
next: () => void;
|
|
71
|
+
prev: () => void;
|
|
72
|
+
pageSize: number;
|
|
73
|
+
/** The page sizes offered. Defaults to 10, 25, 50 and 100. */
|
|
74
|
+
pageSizes?: readonly number[];
|
|
75
|
+
setPageSize: (size: number) => void;
|
|
76
|
+
/** The live input value: it updates on every keystroke; the query does not. */
|
|
77
|
+
search: string;
|
|
78
|
+
setSearch: (search: string) => void;
|
|
79
|
+
searchEnabled: boolean;
|
|
80
|
+
/** The rows on screen answer a previous question (a search still debouncing, a page in flight). */
|
|
81
|
+
isStale: boolean;
|
|
82
|
+
/** One key, or the ordered keys of a multi-key sort. */
|
|
83
|
+
sort: SortState | readonly SortState[] | undefined;
|
|
84
|
+
/** `additive` is true on shift-click: add the column as the next key. */
|
|
85
|
+
toggleSort: (key: string, additive: boolean) => void;
|
|
86
|
+
sortEnabled: boolean;
|
|
87
|
+
/** True when a search or filter is narrowing the result set. */
|
|
88
|
+
isFiltered: boolean;
|
|
89
|
+
clearFilters: () => void;
|
|
90
|
+
}
|
|
91
|
+
export declare const PAGE_SIZES: readonly number[];
|
|
92
|
+
export declare const DEFAULT_PAGE_SIZE = 25;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface RowSelection {
|
|
2
|
+
selectedIds: readonly string[];
|
|
3
|
+
selectedCount: number;
|
|
4
|
+
isSelected: (id: string) => boolean;
|
|
5
|
+
toggle: (id: string) => void;
|
|
6
|
+
/** Select, or clear, every row on the current page. */
|
|
7
|
+
togglePage: (ids: readonly string[]) => void;
|
|
8
|
+
/** `true`, `false` or `"indeterminate"`, for the header box. */
|
|
9
|
+
pageSelectionState: (ids: readonly string[]) => boolean | "indeterminate";
|
|
10
|
+
clear: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Row selection that survives paging (Astrolift #1231): the selected ids are
|
|
14
|
+
* kept across pages, so the count in the bulk bar means what it says, while
|
|
15
|
+
* the header box answers for the page on screen only.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useRowSelection(): RowSelection;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Row selection that survives paging (Astrolift #1231): the selected ids are
|
|
5
|
+
* kept across pages, so the count in the bulk bar means what it says, while
|
|
6
|
+
* the header box answers for the page on screen only.
|
|
7
|
+
*/
|
|
8
|
+
export function useRowSelection() {
|
|
9
|
+
const [selected, setSelected] = React.useState(() => new Set());
|
|
10
|
+
const toggle = React.useCallback((id) => {
|
|
11
|
+
setSelected((previous) => {
|
|
12
|
+
const next = new Set(previous);
|
|
13
|
+
if (!next.delete(id))
|
|
14
|
+
next.add(id);
|
|
15
|
+
return next;
|
|
16
|
+
});
|
|
17
|
+
}, []);
|
|
18
|
+
const togglePage = React.useCallback((ids) => {
|
|
19
|
+
setSelected((previous) => {
|
|
20
|
+
const next = new Set(previous);
|
|
21
|
+
const allOn = ids.length > 0 && ids.every((id) => next.has(id));
|
|
22
|
+
for (const id of ids) {
|
|
23
|
+
if (allOn)
|
|
24
|
+
next.delete(id);
|
|
25
|
+
else
|
|
26
|
+
next.add(id);
|
|
27
|
+
}
|
|
28
|
+
return next;
|
|
29
|
+
});
|
|
30
|
+
}, []);
|
|
31
|
+
const pageSelectionState = React.useCallback((ids) => {
|
|
32
|
+
if (ids.length === 0)
|
|
33
|
+
return false;
|
|
34
|
+
const on = ids.filter((id) => selected.has(id)).length;
|
|
35
|
+
if (on === 0)
|
|
36
|
+
return false;
|
|
37
|
+
return on === ids.length ? true : "indeterminate";
|
|
38
|
+
}, [selected]);
|
|
39
|
+
const isSelected = React.useCallback((id) => selected.has(id), [selected]);
|
|
40
|
+
const clear = React.useCallback(() => setSelected(new Set()), []);
|
|
41
|
+
const selectedIds = React.useMemo(() => [...selected], [selected]);
|
|
42
|
+
return {
|
|
43
|
+
selectedIds,
|
|
44
|
+
selectedCount: selected.size,
|
|
45
|
+
isSelected,
|
|
46
|
+
toggle,
|
|
47
|
+
togglePage,
|
|
48
|
+
pageSelectionState,
|
|
49
|
+
clear,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @calliopelabs/ui: the Calliope AI operator UI, shared by Astrolift AI and
|
|
3
|
+
* Zentinelle AI. Styles: `@import "@calliopelabs/ui/preset.css"` after Tailwind.
|
|
4
|
+
*/
|
|
5
|
+
export { UIProvider, useUI, type LinkComponent, type LinkProps, type UIProviderProps, } from "./provider/UIProvider.js";
|
|
6
|
+
export { defaultMessages, mergeMessages, type MessageOverrides, type Messages, } from "./provider/messages.js";
|
|
7
|
+
export { cn } from "./lib/cn.js";
|
|
8
|
+
export { ACCENTS, ACCENT_KEYS, DENSITIES, DENSITY_KEYS, GROUNDS, GROUND_KEYS, MODE_TOKENS, SURFACES, type AccentSpec, type Density, type FamilyAccent, type Ground, type Mode, type StatusName, } from "./theme/tokens.js";
|
|
9
|
+
export { MIN_NON_TEXT_CONTRAST, MIN_TEXT_CONTRAST, contrastRatio, luminance, } from "./theme/contrast.js";
|
|
10
|
+
export { CORNERS, defineProduct, type BrandAccent, type Corners, type Product, type ProductAccent, type ProductConfig, type ProductTheme, } from "./appearance/product.js";
|
|
11
|
+
export { astroliftBrand, zentinelleBrand } from "./appearance/brands.js";
|
|
12
|
+
export { accentContrast, appearanceFromServer, applyAppearance, clearAppearance, inkFor, isCustomAccent, modeFor, normalize, normalizePartial, parseCustomAccent, readAppearance, resolveAppearance, writeAppearance, type Appearance, type CustomAccent, type OrgAppearancePolicy, type ResolvedAppearance, } from "./appearance/appearance.js";
|
|
13
|
+
export { appearanceScript, productStyles } from "./appearance/boot.js";
|
|
14
|
+
export { AppearanceScript, type AppearanceScriptProps } from "./appearance/AppearanceScript.js";
|
|
15
|
+
export { AppearanceProvider, useAppearance, useDensity, useProduct, type AppearanceContextValue, type AppearanceProviderProps, } from "./appearance/AppearanceProvider.js";
|
|
16
|
+
export { AppearancePicker } from "./appearance/AppearancePicker.js";
|
|
17
|
+
export { activeFor, areaSwitcher, localizeNav, visibleNav, type CrumbSwitchOption, type NavActive, type NavArea, type NavFunction, type NavGroup, type NavIcon, type NavModel, } from "./nav/model.js";
|
|
18
|
+
export { Nav, type NavProps } from "./nav/Nav.js";
|
|
19
|
+
export { AppShell, type AppShellProps } from "./shell/AppShell.js";
|
|
20
|
+
export { Sidebar, type SidebarProps } from "./shell/Sidebar.js";
|
|
21
|
+
export { BrandMark, type BrandMarkProps } from "./shell/BrandMark.js";
|
|
22
|
+
export { UserMenu, type UserMenuItem, type UserMenuProps } from "./shell/UserMenu.js";
|
|
23
|
+
export { RailPanel, type RailPanelProps } from "./shell/RailPanel.js";
|
|
24
|
+
export { RailTree, type RailTreeGroup, type RailTreeItem, type RailTreeProps, } from "./shell/RailTree.js";
|
|
25
|
+
export { useRailState, useRailWidth } from "./shell/rail-state.js";
|
|
26
|
+
export { PageFrame, type PageFrameProps } from "./page/PageFrame.js";
|
|
27
|
+
export { PageHeader, type PageHeaderProps } from "./page/PageHeader.js";
|
|
28
|
+
export { Breadcrumbs, type BreadcrumbsProps, type Crumb } from "./page/Breadcrumbs.js";
|
|
29
|
+
export { TabNav, type TabNavItem, type TabNavProps } from "./page/TabNav.js";
|
|
30
|
+
export { Section, type SectionProps } from "./page/Section.js";
|
|
31
|
+
export { DataTable, type DataTableProps } from "./data/DataTable.js";
|
|
32
|
+
export { List, ListItem, type ListItemProps, type ListProps } from "./data/List.js";
|
|
33
|
+
export { useRowSelection, type RowSelection } from "./data/use-row-selection.js";
|
|
34
|
+
export { DEFAULT_PAGE_SIZE, PAGE_SIZES, type Column, type EmptyStateSpec, type PageState, type SortDirection, type SortState, type TableController, } from "./data/types.js";
|
|
35
|
+
export { StatusBadge, type StatusBadgeProps } from "./status/StatusBadge.js";
|
|
36
|
+
export { StatusDot, type StatusDotProps } from "./status/StatusDot.js";
|
|
37
|
+
export { DOT_TONE, IN_FLIGHT, PILL_TONE, STATUS_TONES, TONE_TOKEN, type StatusTone, } from "./status/tones.js";
|
|
38
|
+
export { EmptyState, type EmptyStateProps } from "./states/EmptyState.js";
|
|
39
|
+
export { ErrorState, type ErrorStateProps } from "./states/ErrorState.js";
|
|
40
|
+
export { LoadingState, type LoadingStateProps } from "./states/LoadingState.js";
|
|
41
|
+
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "./overlays/Dialog.js";
|
|
42
|
+
export { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "./overlays/Drawer.js";
|
|
43
|
+
export { ConfirmDialog, type ConfirmDialogProps, type ConfirmReason, } from "./overlays/ConfirmDialog.js";
|
|
44
|
+
export { Button, buttonVariants, type ButtonProps } from "./primitives/Button.js";
|
|
45
|
+
export { Input } from "./primitives/Input.js";
|
|
46
|
+
export { Checkbox } from "./primitives/Checkbox.js";
|
|
47
|
+
export { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./primitives/DropdownMenu.js";
|
|
48
|
+
export { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover.js";
|
|
49
|
+
export { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/Tabs.js";
|
|
50
|
+
export { Skeleton } from "./primitives/Skeleton.js";
|