@tristan2828/ui-foundation 3.0.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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/ui-foundation.mjs +249 -0
  4. package/conventions/AGENTS.md +149 -0
  5. package/conventions/agents/deny-impl-read.mjs +36 -0
  6. package/conventions/agents/skills/new-entity/SKILL.md +22 -0
  7. package/conventions/agents/spec-tester.md +35 -0
  8. package/conventions/docs/add-an-entity.md +204 -0
  9. package/conventions/docs/cell-patterns.md +204 -0
  10. package/conventions/docs/column-options.md +122 -0
  11. package/conventions/docs/design-language.md +116 -0
  12. package/conventions/docs/entity-plan-template.md +67 -0
  13. package/dist/api/contracts.d.ts +20 -0
  14. package/dist/api/contracts.js +4 -0
  15. package/dist/api/gateway/auth.d.ts +9 -0
  16. package/dist/api/gateway/auth.js +37 -0
  17. package/dist/api/gateway/errors.d.ts +5 -0
  18. package/dist/api/gateway/errors.js +36 -0
  19. package/dist/api/query-client.d.ts +2 -0
  20. package/dist/api/query-client.js +19 -0
  21. package/dist/api/schema.d.ts +246 -0
  22. package/dist/api/schema.js +5 -0
  23. package/dist/api/transport/index.d.ts +5 -0
  24. package/dist/api/transport/index.js +14 -0
  25. package/dist/auth/auth-context.d.ts +19 -0
  26. package/dist/auth/auth-context.js +12 -0
  27. package/dist/auth/auth-provider.d.ts +4 -0
  28. package/dist/auth/auth-provider.js +101 -0
  29. package/dist/auth/use-auth.d.ts +2 -0
  30. package/dist/auth/use-auth.js +9 -0
  31. package/dist/components/app/app-shell.d.ts +28 -0
  32. package/dist/components/app/app-shell.js +64 -0
  33. package/dist/components/app/data-table.d.ts +56 -0
  34. package/dist/components/app/data-table.js +167 -0
  35. package/dist/components/app/entity-form.d.ts +14 -0
  36. package/dist/components/app/entity-form.js +8 -0
  37. package/dist/components/app/error-state.d.ts +6 -0
  38. package/dist/components/app/error-state.js +20 -0
  39. package/dist/components/app/foundation-providers.d.ts +9 -0
  40. package/dist/components/app/foundation-providers.js +15 -0
  41. package/dist/components/app/mock-mode-banner.d.ts +3 -0
  42. package/dist/components/app/mock-mode-banner.js +16 -0
  43. package/dist/components/app/multi-choice.d.ts +14 -0
  44. package/dist/components/app/multi-choice.js +13 -0
  45. package/dist/components/app/password-input.d.ts +3 -0
  46. package/dist/components/app/password-input.js +30 -0
  47. package/dist/components/app/route-error-boundary.d.ts +1 -0
  48. package/dist/components/app/route-error-boundary.js +13 -0
  49. package/dist/components/theme-provider.d.ts +2 -0
  50. package/dist/components/theme-provider.js +8 -0
  51. package/dist/components/ui/badge.d.ts +7 -0
  52. package/dist/components/ui/badge.js +50 -0
  53. package/dist/components/ui/button.d.ts +8 -0
  54. package/dist/components/ui/button.js +34 -0
  55. package/dist/components/ui/calendar.d.ts +10 -0
  56. package/dist/components/ui/calendar.js +76 -0
  57. package/dist/components/ui/card.d.ts +11 -0
  58. package/dist/components/ui/card.js +25 -0
  59. package/dist/components/ui/combobox.d.ts +24 -0
  60. package/dist/components/ui/combobox.js +62 -0
  61. package/dist/components/ui/dialog.d.ts +17 -0
  62. package/dist/components/ui/dialog.js +37 -0
  63. package/dist/components/ui/empty.d.ts +11 -0
  64. package/dist/components/ui/empty.js +33 -0
  65. package/dist/components/ui/field.d.ts +24 -0
  66. package/dist/components/ui/field.js +67 -0
  67. package/dist/components/ui/input-group.d.ts +18 -0
  68. package/dist/components/ui/input-group.js +57 -0
  69. package/dist/components/ui/input.d.ts +3 -0
  70. package/dist/components/ui/input.js +8 -0
  71. package/dist/components/ui/label.d.ts +3 -0
  72. package/dist/components/ui/label.js +7 -0
  73. package/dist/components/ui/popover.d.ts +9 -0
  74. package/dist/components/ui/popover.js +23 -0
  75. package/dist/components/ui/select.d.ts +15 -0
  76. package/dist/components/ui/select.js +35 -0
  77. package/dist/components/ui/separator.d.ts +3 -0
  78. package/dist/components/ui/separator.js +8 -0
  79. package/dist/components/ui/sheet.d.ts +14 -0
  80. package/dist/components/ui/sheet.js +38 -0
  81. package/dist/components/ui/sidebar.d.ts +63 -0
  82. package/dist/components/ui/sidebar.js +255 -0
  83. package/dist/components/ui/skeleton.d.ts +2 -0
  84. package/dist/components/ui/skeleton.js +6 -0
  85. package/dist/components/ui/sonner.d.ts +3 -0
  86. package/dist/components/ui/sonner.js +24 -0
  87. package/dist/components/ui/spinner.d.ts +2 -0
  88. package/dist/components/ui/spinner.js +7 -0
  89. package/dist/components/ui/table.d.ts +13 -0
  90. package/dist/components/ui/table.js +45 -0
  91. package/dist/components/ui/textarea.d.ts +3 -0
  92. package/dist/components/ui/textarea.js +7 -0
  93. package/dist/components/ui/tooltip.d.ts +6 -0
  94. package/dist/components/ui/tooltip.js +16 -0
  95. package/dist/gateway.d.ts +2 -0
  96. package/dist/gateway.js +5 -0
  97. package/dist/hooks/table-url-changes.d.ts +14 -0
  98. package/dist/hooks/table-url-changes.js +32 -0
  99. package/dist/hooks/use-debounced-value.d.ts +1 -0
  100. package/dist/hooks/use-debounced-value.js +12 -0
  101. package/dist/hooks/use-mobile.d.ts +1 -0
  102. package/dist/hooks/use-mobile.js +14 -0
  103. package/dist/hooks/use-table-url-state.d.ts +14 -0
  104. package/dist/hooks/use-table-url-state.js +43 -0
  105. package/dist/index.d.ts +22 -0
  106. package/dist/index.js +28 -0
  107. package/dist/lib/utils.d.ts +1 -0
  108. package/dist/lib/utils.js +1 -0
  109. package/dist/mocks/auth.d.ts +9 -0
  110. package/dist/mocks/auth.js +79 -0
  111. package/dist/mocks/e2e-hooks.d.ts +3 -0
  112. package/dist/mocks/e2e-hooks.js +28 -0
  113. package/dist/mocks/index.d.ts +3 -0
  114. package/dist/mocks/index.js +5 -0
  115. package/dist/mocks/override.d.ts +23 -0
  116. package/dist/mocks/override.js +1 -0
  117. package/dist/routes/login-schema.d.ts +7 -0
  118. package/dist/routes/login-schema.js +13 -0
  119. package/dist/routes/login.d.ts +8 -0
  120. package/dist/routes/login.js +58 -0
  121. package/dist/routes/register-schema.d.ts +9 -0
  122. package/dist/routes/register-schema.js +23 -0
  123. package/dist/routes/register.d.ts +1 -0
  124. package/dist/routes/register.js +58 -0
  125. package/dist/routes/return-path.d.ts +1 -0
  126. package/dist/routes/return-path.js +9 -0
  127. package/dist/testing/index.d.ts +40 -0
  128. package/dist/testing/index.js +103 -0
  129. package/eslint/index.d.ts +11 -0
  130. package/eslint/index.js +179 -0
  131. package/openapi/foundation.yaml +156 -0
  132. package/package.json +158 -0
  133. package/styles/index.css +88 -0
  134. package/styles/theme.css +209 -0
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
3
+ import { useTheme } from 'next-themes';
4
+ import { useState } from 'react';
5
+ import { NavLink, Navigate, Outlet, useLocation } from 'react-router';
6
+ import { useAuth } from '../../auth/use-auth.js';
7
+ import { ErrorState } from './error-state.js';
8
+ import { Button } from '../ui/button.js';
9
+ import { Skeleton } from '../ui/skeleton.js';
10
+ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, } from '../ui/sidebar.js';
11
+ import { Toaster } from '../ui/sonner.js';
12
+ import { TooltipProvider } from '../ui/tooltip.js';
13
+ // Must match SIDEBAR_COOKIE_NAME in ../ui/sidebar.tsx, which writes the
14
+ // cookie on every toggle but never reads it back — that half is here.
15
+ const SIDEBAR_COOKIE_NAME = 'sidebar_state';
16
+ function storedSidebarOpen(fallback) {
17
+ const match = document.cookie.match(new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)`));
18
+ return match ? match[1] === 'true' : fallback;
19
+ }
20
+ function ThemeToggle() {
21
+ const { resolvedTheme, setTheme } = useTheme();
22
+ const isDark = resolvedTheme === 'dark';
23
+ return (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Toggle dark mode", onClick: () => setTheme(isDark ? 'light' : 'dark'), children: isDark ? _jsx(SunIcon, {}) : _jsx(MoonIcon, {}) }));
24
+ }
25
+ function AppShellSkeleton() {
26
+ return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
27
+ }
28
+ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }) {
29
+ const { user, status, error, retry, logout } = useAuth();
30
+ const location = useLocation();
31
+ // Set before logout starts, so the redirect below knows this was a
32
+ // deliberate logout. It can't be a follow-up navigate() after logout
33
+ // resolves: that runs before React re-renders, and the <Navigate> this
34
+ // component then renders starts a second navigation that wins.
35
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
36
+ if (status === 'loading') {
37
+ return _jsx(AppShellSkeleton, {});
38
+ }
39
+ // The session check itself failed (backend down, network) — not the same
40
+ // as logged out, so don't send the user to a login form that can't work.
41
+ if (status === 'unavailable' && error) {
42
+ return (_jsx("div", { className: "flex min-h-screen items-center justify-center p-6", children: _jsx(ErrorState, { error: error, onRetry: retry }) }));
43
+ }
44
+ // Also covers `status === 'unauthenticated'` (user is always null then) —
45
+ // narrowing on `user` here, rather than `status`, is what lets TypeScript
46
+ // treat `user` as non-null for the rest of this component. `from` lets
47
+ // /login send the user back here afterwards; router state, not a query
48
+ // param, so it can't be used as an open redirect from a crafted link.
49
+ if (!user) {
50
+ // A deliberate logout lands on a clean /login — otherwise whoever signs
51
+ // in next would be sent back to the previous user's page.
52
+ const from = `${location.pathname}${location.search}${location.hash}`;
53
+ return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
54
+ }
55
+ return (_jsxs(TooltipProvider, { children: [_jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: "Navigation" }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: nav.map(({ to, label, icon: Icon, end }) => {
56
+ const isActive = end
57
+ ? location.pathname === to
58
+ : location.pathname.startsWith(to);
59
+ return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(NavLink, { to: to, end: end }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
60
+ }) }) })] }), sidebarExtra] }), _jsx(SidebarFooter, { children: _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate text-xs text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
61
+ setIsLoggingOut(true);
62
+ logout().catch(() => setIsLoggingOut(false));
63
+ }, children: _jsx(LogOutIcon, {}) })] }) })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(Toaster, { position: "top-center" })] }));
64
+ }
@@ -0,0 +1,56 @@
1
+ import type { CellData, RowData, TableFeatures } from '@tanstack/react-table';
2
+ import { type LegacyColumnDef } from '@tanstack/react-table/legacy';
3
+ import type { AppError } from '../../api/contracts.js';
4
+ export type SortingState = {
5
+ id: string;
6
+ desc: boolean;
7
+ }[];
8
+ declare module '@tanstack/table-core' {
9
+ interface ColumnMeta<TFeatures extends TableFeatures, TData extends RowData, TValue extends CellData = CellData> {
10
+ align?: 'center';
11
+ }
12
+ }
13
+ export type DataTableProps<TData extends Record<string, unknown>> = {
14
+ columns: LegacyColumnDef<TData, unknown>[];
15
+ data: TData[];
16
+ total: number;
17
+ /** 1-based, matching QuerySpec.page. */
18
+ page: number;
19
+ pageSize: number;
20
+ onPageChange: (page: number) => void;
21
+ sorting: SortingState;
22
+ onSortingChange: (sorting: SortingState) => void;
23
+ isLoading: boolean;
24
+ error?: AppError | null;
25
+ onRetry?: () => void;
26
+ emptyTitle: string;
27
+ emptyDescription?: string;
28
+ emptyAction?: React.ReactNode;
29
+ toolbar?: React.ReactNode;
30
+ getRowId?: (row: TData) => string;
31
+ /**
32
+ * Pins the first column so it stays put while the rest scrolls
33
+ * horizontally, and adds a second scrollbar stuck to the viewport's
34
+ * bottom edge. Opt in per table — a narrow table needs neither.
35
+ */
36
+ pinFirstColumn?: boolean;
37
+ /**
38
+ * Pins the last column to the right edge, the mirror of
39
+ * `pinFirstColumn`, for a table whose row actions sit there: on a wide
40
+ * table they otherwise scroll off-screen, so the control used most is
41
+ * the one that can't be seen. No second scrollbar of its own. Opt in per
42
+ * table.
43
+ */
44
+ pinLastColumn?: boolean;
45
+ /**
46
+ * Set false when the caller supplies a default sort it falls back to
47
+ * whenever `sorting` is empty. TanStack's cycle is asc → desc →
48
+ * unsorted, and that third click hands back `[]`, which such a caller
49
+ * immediately turns back into the same default — so the header looks
50
+ * stuck and can never advance past it. Leaving this undefined keeps
51
+ * TanStack's own behaviour, so a table with no default sort is
52
+ * unaffected. See conventions/docs/entity-plan-template.md "Default sort".
53
+ */
54
+ enableSortingRemoval?: boolean;
55
+ };
56
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -0,0 +1,167 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Generic server-side data table: sorting, pagination and a toolbar slot for
3
+ // filters, all driven externally (the caller owns the QuerySpec and re-fetches
4
+ // via TanStack Query — this component never fetches).
5
+ //
6
+ // Built on @tanstack/react-table's useLegacyTable, not the v9 useTable +
7
+ // tableFeatures() API. useLegacyTable is TanStack's own officially shipped,
8
+ // fully-typed v8-compatibility layer (not a hand-rolled shim) — the v9
9
+ // feature-slot architecture exists to make row models like sorting and
10
+ // pagination tree-shakeable and independently swappable, which buys nothing
11
+ // here: every row model is manual (the server sorts and pages), so this
12
+ // table never uses TanStack's own sorted/paginated row models at all.
13
+ import { useEffect, useRef, useState } from 'react';
14
+ import { flexRender } from '@tanstack/react-table';
15
+ import { cn } from 'cn';
16
+ import { getCoreRowModel, useLegacyTable } from '@tanstack/react-table/legacy';
17
+ import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
18
+ import { ErrorState } from './error-state.js';
19
+ import { Button } from '../ui/button.js';
20
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
21
+ import { Skeleton } from '../ui/skeleton.js';
22
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
23
+ function TableSkeleton({ columnCount }) {
24
+ return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
25
+ }
26
+ // The pinned cell gets a flat `bg-background`, not `bg-inherit`: the zebra
27
+ // stripe (table.tsx) is a *translucent* color, so inheriting it would let
28
+ // the columns scrolling behind show through at 40% opacity — exactly the
29
+ // bleed-through a solid background avoids. That also means the pinned
30
+ // column shows no stripe, which is why it is the thing that carries the
31
+ // hover highlight. --accent is only ~3% off the page background, so a
32
+ // solid left edge marker carries the signal; an inset shadow rather than a
33
+ // border, so nothing reflows.
34
+ const PINNED_COLUMN_CLASS = 'sticky left-0 z-10 border-r bg-background group-hover/row:bg-accent ' +
35
+ 'group-hover/row:shadow-[inset_3px_0_0_0_var(--primary)]';
36
+ // The right-edge mirror, opaque for the same reason. Its edge marker points
37
+ // inward from the right, so the two pinned columns bracket the row.
38
+ const PINNED_LAST_COLUMN_CLASS = 'sticky right-0 z-10 border-l bg-background group-hover/row:bg-accent ' +
39
+ 'group-hover/row:shadow-[inset_-3px_0_0_0_var(--primary)]';
40
+ // Hover highlights the pinned cell, not the whole row. A row-level hover
41
+ // cannot be made consistent against the zebra stripe: `tr:nth-child(even)`
42
+ // is specificity (0,2,1) and `tr:hover` is (0,2,0), so the stripe wins and
43
+ // even rows stay unlit — odd rows highlight, even rows don't.
44
+ // `hover:bg-transparent` drops table.tsx's own `hover:bg-muted/50` via
45
+ // tailwind-merge so no row hover is attempted at all. The pinned cell is
46
+ // the better target anyway: it names the row, it sits on an opaque
47
+ // background so it reads identically on striped and unstriped rows, and it
48
+ // stays on screen when the table is scrolled right.
49
+ const ROW_GROUP_CLASS = 'group/row hover:bg-transparent';
50
+ // A second horizontal scrollbar, stuck to the viewport's bottom edge. The
51
+ // real one sits directly under the last row, which on a full page is
52
+ // off-screen until you have already scrolled past every row. Only rendered
53
+ // once the content actually overflows.
54
+ function useBottomScrollbar(containerRef, isSuccessView) {
55
+ const barRef = useRef(null);
56
+ const [scrollWidth, setScrollWidth] = useState(0);
57
+ const [clientWidth, setClientWidth] = useState(0);
58
+ const isOverflowing = scrollWidth > clientWidth;
59
+ useEffect(() => {
60
+ const container = containerRef.current;
61
+ if (!isSuccessView || !container)
62
+ return;
63
+ const contentTable = container.querySelector('table');
64
+ if (!contentTable)
65
+ return;
66
+ const measure = () => {
67
+ setScrollWidth(container.scrollWidth);
68
+ setClientWidth(container.clientWidth);
69
+ };
70
+ measure();
71
+ // The inner <table> grows with column and content width; the container
72
+ // changes with the viewport and the sidebar. Either can change whether,
73
+ // and how far, there is to scroll.
74
+ const resizeObserver = new ResizeObserver(measure);
75
+ resizeObserver.observe(container);
76
+ resizeObserver.observe(contentTable);
77
+ return () => resizeObserver.disconnect();
78
+ }, [containerRef, isSuccessView]);
79
+ // Separate from the measuring effect: the bar only enters the DOM once
80
+ // `isOverflowing` is true, which that effect is what sets. Wiring the
81
+ // listeners there would read barRef.current before the bar had ever
82
+ // rendered and silently attach to nothing.
83
+ useEffect(() => {
84
+ const container = containerRef.current;
85
+ const bar = barRef.current;
86
+ if (!isSuccessView || !isOverflowing || !container || !bar)
87
+ return;
88
+ let isSyncing = false;
89
+ const syncBarFromContainer = () => {
90
+ if (isSyncing)
91
+ return;
92
+ isSyncing = true;
93
+ bar.scrollLeft = container.scrollLeft;
94
+ isSyncing = false;
95
+ };
96
+ const syncContainerFromBar = () => {
97
+ if (isSyncing)
98
+ return;
99
+ isSyncing = true;
100
+ container.scrollLeft = bar.scrollLeft;
101
+ isSyncing = false;
102
+ };
103
+ container.addEventListener('scroll', syncBarFromContainer);
104
+ bar.addEventListener('scroll', syncContainerFromBar);
105
+ return () => {
106
+ container.removeEventListener('scroll', syncBarFromContainer);
107
+ bar.removeEventListener('scroll', syncContainerFromBar);
108
+ };
109
+ }, [containerRef, isSuccessView, isOverflowing]);
110
+ return { barRef, isOverflowing, scrollWidth };
111
+ }
112
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }) {
113
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
114
+ const table = useLegacyTable({
115
+ data,
116
+ columns,
117
+ getRowId: getRowId,
118
+ state: {
119
+ sorting,
120
+ pagination: { pageIndex: page - 1, pageSize },
121
+ },
122
+ manualSorting: true,
123
+ ...(enableSortingRemoval === undefined ? {} : { enableSortingRemoval }),
124
+ manualPagination: true,
125
+ pageCount,
126
+ onSortingChange: (updater) => onSortingChange(typeof updater === 'function' ? updater(sorting) : updater),
127
+ onPaginationChange: (updater) => {
128
+ const next = typeof updater === 'function' ? updater({ pageIndex: page - 1, pageSize }) : updater;
129
+ onPageChange(next.pageIndex + 1);
130
+ },
131
+ getCoreRowModel: getCoreRowModel(),
132
+ });
133
+ // Past the last page — e.g. after deleting the only row on the last page,
134
+ // or a stale ?page= link. The server still reports rows (total > 0), so
135
+ // this is not "empty": go to the real last page instead of showing
136
+ // "No widgets yet" with data one click away.
137
+ const isPastLastPage = !isLoading && !error && data.length === 0 && total > 0 && page > pageCount;
138
+ useEffect(() => {
139
+ if (isPastLastPage)
140
+ onPageChange(pageCount);
141
+ }, [isPastLastPage, pageCount, onPageChange]);
142
+ const isEmpty = !isLoading && !error && data.length === 0 && !isPastLastPage;
143
+ const isSuccessView = !error && !isLoading && !isPastLastPage && !isEmpty;
144
+ const containerRef = useRef(null);
145
+ const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
146
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: columns.length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
147
+ // With the sticky bar below as the visible scrollbar, hide the
148
+ // container's own. It stays overflow-x-auto and fully
149
+ // scrollable (wheel, trackpad, drag, and the sync effect); it
150
+ // just no longer draws a bar directly under the last row.
151
+ containerClassName: cn(pinFirstColumn &&
152
+ '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden'), children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { children: headerGroup.headers.map((header, index) => {
153
+ const canSort = header.column.getCanSort();
154
+ const sortDirection = header.column.getIsSorted();
155
+ return (_jsx(TableHead, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
156
+ index === headerGroup.headers.length - 1 &&
157
+ PINNED_LAST_COLUMN_CLASS, header.column.columnDef.meta?.align === 'center' && 'text-center'), "aria-sort": !canSort
158
+ ? undefined
159
+ : sortDirection === 'asc'
160
+ ? 'ascending'
161
+ : sortDirection === 'desc'
162
+ ? 'descending'
163
+ : 'none', children: header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: cn('flex items-center gap-1 text-foreground', header.column.columnDef.meta?.align === 'center' && 'w-full justify-center'), onClick: header.column.getToggleSortingHandler(), children: [flexRender(header.column.columnDef.header, header.getContext()), sortDirection === 'asc' ? (_jsx(ArrowUpIcon, { className: "size-3.5 text-muted-foreground" })) : sortDirection === 'desc' ? (_jsx(ArrowDownIcon, { className: "size-3.5 text-muted-foreground" })) : (_jsx(ArrowUpDownIcon, { className: "size-3.5 text-muted-foreground/50" }))] })) : (flexRender(header.column.columnDef.header, header.getContext())) }, header.id));
164
+ }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { className: cn((pinFirstColumn || pinLastColumn) && ROW_GROUP_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn &&
165
+ index === row.getVisibleCells().length - 1 &&
166
+ PINNED_LAST_COLUMN_CLASS, cell.column.columnDef.meta?.align === 'center' && 'text-center'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, row.id))) })] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "text-sm text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "text-sm text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
167
+ }
@@ -0,0 +1,14 @@
1
+ import type { FormEvent, ReactNode } from 'react';
2
+ import type { AppError } from '../../api/contracts.js';
3
+ export type EntityFormProps = {
4
+ title: string;
5
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void;
6
+ isSubmitting: boolean;
7
+ submitError?: AppError | null;
8
+ submitLabel: string;
9
+ onCancel: () => void;
10
+ /** An edit-only destructive action (e.g. delete), shown opposite Cancel/Submit. */
11
+ danger?: ReactNode;
12
+ children: ReactNode;
13
+ };
14
+ export declare function EntityForm({ title, onSubmit, isSubmitting, submitError, submitLabel, onCancel, danger, children, }: EntityFormProps): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ErrorState } from './error-state.js';
3
+ import { Button } from '../ui/button.js';
4
+ import { FieldGroup } from '../ui/field.js';
5
+ import { Spinner } from '../ui/spinner.js';
6
+ export function EntityForm({ title, onSubmit, isSubmitting, submitError, submitLabel, onCancel, danger, children, }) {
7
+ return (_jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex max-w-xl flex-col gap-6", children: [_jsx("h1", { className: "text-lg font-semibold text-foreground", children: title }), _jsx(FieldGroup, { children: children }), submitError && (_jsx(ErrorState, { error: submitError, className: "border border-dashed border-destructive/30" })), _jsxs("div", { className: `flex items-center gap-2 ${danger ? 'justify-between' : 'justify-end'}`, children: [danger, _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isSubmitting, children: "Cancel" }), _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), submitLabel] })] })] })] }));
8
+ }
@@ -0,0 +1,6 @@
1
+ import type { AppError } from '../../api/contracts.js';
2
+ export declare function ErrorState({ error, onRetry, className, }: {
3
+ error: AppError;
4
+ onRetry?: () => void;
5
+ className?: string;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The composite AGENTS.md's "Correct Patterns" section names directly:
3
+ // `if (error) return <ErrorState error={error} />`. Renders an AppError —
4
+ // never a raw response (the gateway translates every failure) —
5
+ // using the same <Empty> primitive the "no rows" state uses, so a screen's
6
+ // error and empty states read as one family instead of two unrelated
7
+ // widgets.
8
+ import { AlertCircleIcon } from 'lucide-react';
9
+ import { Button } from '../ui/button.js';
10
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
11
+ const TITLE_BY_KIND = {
12
+ network: 'Connection problem',
13
+ server: 'Something went wrong',
14
+ notfound: 'Not found',
15
+ auth: 'Not authorized',
16
+ validation: 'Check the form',
17
+ };
18
+ export function ErrorState({ error, onRetry, className, }) {
19
+ return (_jsxs(Empty, { className: className, "data-state": "error", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(AlertCircleIcon, { className: "text-destructive" }) }), _jsx(EmptyTitle, { children: TITLE_BY_KIND[error.kind] }), _jsx(EmptyDescription, { children: error.message })] }), onRetry && (_jsx(EmptyContent, { children: _jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, children: "Try again" }) }))] }));
20
+ }
@@ -0,0 +1,9 @@
1
+ import { type QueryClient } from '@tanstack/react-query';
2
+ import { type ReactNode } from 'react';
3
+ export declare function FoundationProviders({ mockMode, queryClient, children, }: {
4
+ /** Whether the API is served by MSW — the app's own IS_MOCK_MODE. */
5
+ mockMode: boolean;
6
+ /** Defaults to createQueryClient(); pass one to share it with code outside React. */
7
+ queryClient?: QueryClient;
8
+ children: ReactNode;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { useState } from 'react';
4
+ import { createQueryClient } from '../../api/query-client.js';
5
+ import { AuthProvider } from '../../auth/use-auth.js';
6
+ import { MockModeBanner } from './mock-mode-banner.js';
7
+ import { ThemeProvider } from '../theme-provider.js';
8
+ // Everything an app's root needs around its router, in the order that
9
+ // works: the theme outermost, the mock-mode banner above the router so it
10
+ // shows on every route (/login included), and AuthProvider inside
11
+ // QueryClientProvider because it queries /auth/me through TanStack Query.
12
+ export function FoundationProviders({ mockMode, queryClient, children, }) {
13
+ const [client] = useState(() => queryClient ?? createQueryClient());
14
+ return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: children }) })] }));
15
+ }
@@ -0,0 +1,3 @@
1
+ export declare function MockModeBanner({ active }: {
2
+ active: boolean;
3
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { FlaskConicalIcon } from 'lucide-react';
3
+ // Rendered above the router, so it shows on every route including /login —
4
+ // which is the point. MSW's seeded user and small fixed fixture look
5
+ // identical to a real signed-in session at a glance, and in the first app
6
+ // built on this foundation that cost a real user real time chasing what
7
+ // looked like a broken login before realising they were on the mock dev
8
+ // server rather than the backend origin.
9
+ //
10
+ // `active` comes from the app (its src/lib/mock-mode.ts): VITE_API is baked
11
+ // in when the *app* is built, so package code must not read it itself.
12
+ export function MockModeBanner({ active }) {
13
+ if (!active)
14
+ return null;
15
+ return (_jsxs("div", { role: "status", className: "flex h-9 items-center justify-center gap-1.5 border-b border-border bg-warning px-4 text-sm font-semibold text-warning-foreground", children: [_jsx(FlaskConicalIcon, { className: "size-4", "aria-hidden": "true" }), "Mock data \u2014 not connected to the real backend"] }));
16
+ }
@@ -0,0 +1,14 @@
1
+ import * as React from 'react';
2
+ export declare function MultiChoice<T extends string>({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
3
+ /** Every allowed option, in display order. */
4
+ options: readonly T[];
5
+ value: T[];
6
+ onValueChange: (value: T[]) => void;
7
+ /** Id for the text input, so a <FieldLabel htmlFor> labels it. */
8
+ id?: string;
9
+ placeholder?: string;
10
+ /** For use without a visible label (e.g. a table toolbar filter). */
11
+ 'aria-label'?: string;
12
+ 'aria-invalid'?: boolean;
13
+ className?: string;
14
+ }): React.JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A multi-choice picker: selected options as removable chips, the rest in a
3
+ // filterable dropdown. The one control for every "multi choice" field
4
+ // (conventions/docs/entity-plan-template.md) — on a form (Widget Tags) and as a table
5
+ // filter — so entities reuse it rather than re-assembling the combobox
6
+ // primitive each time. Built on shadcn's Combobox `multiple` mode, following
7
+ // its own ComboboxMultiple example.
8
+ import * as React from 'react';
9
+ import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
10
+ export function MultiChoice({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
11
+ const anchor = useComboboxAnchor();
12
+ return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next) => onValueChange(next), children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: option }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: option }, option)) })] })] }));
13
+ }
@@ -0,0 +1,3 @@
1
+ export declare function PasswordInput({ id, autoComplete, 'aria-invalid': ariaInvalid, ...props }: React.ComponentProps<'input'> & {
2
+ 'aria-invalid'?: boolean;
3
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { EyeIcon, EyeOffIcon } from 'lucide-react';
3
+ import { useId, useState } from 'react';
4
+ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, } from '../ui/input-group.js';
5
+ // A password field with a show/hide toggle. Composed from shadcn's
6
+ // InputGroup rather than hand-rolled: shadcn has no dedicated password
7
+ // input, but InputGroupAddon + InputGroupButton is exactly the
8
+ // trailing-control shape, and AGENTS.md's Hard Rule is about not
9
+ // re-implementing what shadcn ships, not about avoiding composition.
10
+ //
11
+ // Three fields need this (login, and register's password + confirmation),
12
+ // which is why it is a component rather than repeated markup.
13
+ //
14
+ // One consequence worth knowing before writing a test against a password
15
+ // field: the toggle's accessible name contains the word "password", so a
16
+ // substring `getByLabel('Password')` now matches both the input and this
17
+ // button and fails Playwright's strict mode. Use `{ exact: true }` — the
18
+ // button's name is right for users, and the test should be the precise
19
+ // one.
20
+ export function PasswordInput({ id, autoComplete, 'aria-invalid': ariaInvalid, ...props }) {
21
+ const [isVisible, setIsVisible] = useState(false);
22
+ // The toggle's own accessible name changes with state, so a screen
23
+ // reader hears what the button will *do*, not what it is.
24
+ const Icon = isVisible ? EyeOffIcon : EyeIcon;
25
+ const describedById = useId();
26
+ return (_jsxs(InputGroup, { children: [_jsx(InputGroupInput, { id: id, type: isVisible ? 'text' : 'password', autoComplete: autoComplete, "aria-invalid": ariaInvalid, "aria-describedby": describedById, ...props }), _jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { type: "button", "aria-label": isVisible ? 'Hide password' : 'Show password',
27
+ // aria-pressed would also be defensible; the changing label is
28
+ // clearer, and announcing both is redundant.
29
+ onClick: () => setIsVisible((shown) => !shown), children: _jsx(Icon, {}) }) }), _jsx("span", { id: describedById, className: "sr-only", children: "Password is hidden. Use the show password button to reveal it." })] }));
30
+ }
@@ -0,0 +1 @@
1
+ export declare function RouteErrorBoundary(): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TriangleAlertIcon } from 'lucide-react';
3
+ import { isRouteErrorResponse, useRouteError } from 'react-router';
4
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
5
+ export function RouteErrorBoundary() {
6
+ const error = useRouteError();
7
+ const message = isRouteErrorResponse(error)
8
+ ? `${error.status} ${error.statusText}`
9
+ : error instanceof Error
10
+ ? error.message
11
+ : 'Something went wrong.';
12
+ return (_jsx(Empty, { children: _jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(TriangleAlertIcon, {}) }), _jsx(EmptyTitle, { children: "This page hit an error" }), _jsx(EmptyDescription, { children: message })] }) }));
13
+ }
@@ -0,0 +1,2 @@
1
+ import { type ThemeProviderProps } from 'next-themes';
2
+ export declare function ThemeProvider({ children, ...props }: ThemeProviderProps): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ThemeProvider as NextThemesProvider } from 'next-themes';
3
+ // Thin wrapper so the rest of the app imports from @/components/theme-provider
4
+ // rather than next-themes directly. Toggles the .dark class that
5
+ // src/styles/theme.css already keys its dark-mode block on.
6
+ export function ThemeProvider({ children, ...props }) {
7
+ return _jsx(NextThemesProvider, { ...props, children: children });
8
+ }
@@ -0,0 +1,7 @@
1
+ import { useRender } from "@base-ui/react/use-render";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare const badgeVariants: (props?: ({
4
+ variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "warning" | "outline-success" | "outline-warning" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-destructive" | null | undefined;
5
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
+ declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
7
+ export { Badge, badgeVariants };
@@ -0,0 +1,50 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import { useRender } from "@base-ui/react/use-render";
3
+ import { cva } from "class-variance-authority";
4
+ import { cn } from "cn";
5
+ const badgeVariants = cva("group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", {
6
+ variants: {
7
+ variant: {
8
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
9
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
10
+ destructive: "bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/90",
11
+ success: "bg-success text-success-foreground focus-visible:ring-success/20 dark:focus-visible:ring-success/40 [a]:hover:bg-success/90",
12
+ warning: "bg-warning text-warning-foreground focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 [a]:hover:bg-warning/90",
13
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
14
+ // Three tones x three styles. The *-text tokens, never the solid
15
+ // fill tokens: `border-destructive`/`text-destructive` would use
16
+ // the shade chosen to carry white text, which is too light to read
17
+ // against the page in light mode and too dark in dark mode. See
18
+ // styles/theme.css and conventions/docs/design-language.md.
19
+ "outline-success": "border-success-text text-success-text",
20
+ "outline-warning": "border-warning-text text-warning-text",
21
+ "outline-destructive": "border-destructive-text text-destructive-text",
22
+ // The fill is the solid token at low alpha (a wash of the tone),
23
+ // while the text is the *-text shade — mixing the two is the point.
24
+ // Dark mode needs a little more alpha for the wash to register
25
+ // against a near-black page.
26
+ "tinted-success": "bg-success/15 text-success-text dark:bg-success/20",
27
+ "tinted-warning": "bg-warning/15 text-warning-text dark:bg-warning/20",
28
+ "tinted-destructive": "bg-destructive/15 text-destructive-text dark:bg-destructive/20",
29
+ ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
30
+ link: "text-primary underline-offset-4 hover:underline",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ variant: "default",
35
+ },
36
+ });
37
+ function Badge({ className, variant = "default", render, ...props }) {
38
+ return useRender({
39
+ defaultTagName: "span",
40
+ props: mergeProps({
41
+ className: cn(badgeVariants({ variant }), className),
42
+ }, props),
43
+ render,
44
+ state: {
45
+ slot: "badge",
46
+ variant,
47
+ },
48
+ });
49
+ }
50
+ export { Badge, badgeVariants };
@@ -0,0 +1,8 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare const buttonVariants: (props?: ({
4
+ variant?: "link" | "default" | "outline" | "secondary" | "ghost" | "destructive" | null | undefined;
5
+ size?: "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
6
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
+ declare function Button({ className, variant, size, ...props }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>): import("react").JSX.Element;
8
+ export { Button, buttonVariants };
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
3
+ import { cva } from "class-variance-authority";
4
+ import { cn } from "cn";
5
+ const buttonVariants = cva("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
6
+ variants: {
7
+ variant: {
8
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
9
+ outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
10
+ secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
11
+ ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
12
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
13
+ link: "text-primary underline-offset-4 hover:underline",
14
+ },
15
+ size: {
16
+ default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
17
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
18
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
19
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
20
+ icon: "size-8",
21
+ "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
22
+ "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
23
+ "icon-lg": "size-9",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: "default",
28
+ size: "default",
29
+ },
30
+ });
31
+ function Button({ className, variant = "default", size = "default", ...props }) {
32
+ return (_jsx(ButtonPrimitive, { "data-slot": "button", className: cn(buttonVariants({ variant, size, className })), ...props }));
33
+ }
34
+ export { Button, buttonVariants };