@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/ui-foundation.mjs +249 -0
- package/conventions/AGENTS.md +149 -0
- package/conventions/agents/deny-impl-read.mjs +36 -0
- package/conventions/agents/skills/new-entity/SKILL.md +22 -0
- package/conventions/agents/spec-tester.md +35 -0
- package/conventions/docs/add-an-entity.md +204 -0
- package/conventions/docs/cell-patterns.md +204 -0
- package/conventions/docs/column-options.md +122 -0
- package/conventions/docs/design-language.md +116 -0
- package/conventions/docs/entity-plan-template.md +67 -0
- package/dist/api/contracts.d.ts +20 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/gateway/auth.d.ts +9 -0
- package/dist/api/gateway/auth.js +37 -0
- package/dist/api/gateway/errors.d.ts +5 -0
- package/dist/api/gateway/errors.js +36 -0
- package/dist/api/query-client.d.ts +2 -0
- package/dist/api/query-client.js +19 -0
- package/dist/api/schema.d.ts +246 -0
- package/dist/api/schema.js +5 -0
- package/dist/api/transport/index.d.ts +5 -0
- package/dist/api/transport/index.js +14 -0
- package/dist/auth/auth-context.d.ts +19 -0
- package/dist/auth/auth-context.js +12 -0
- package/dist/auth/auth-provider.d.ts +4 -0
- package/dist/auth/auth-provider.js +101 -0
- package/dist/auth/use-auth.d.ts +2 -0
- package/dist/auth/use-auth.js +9 -0
- package/dist/components/app/app-shell.d.ts +28 -0
- package/dist/components/app/app-shell.js +64 -0
- package/dist/components/app/data-table.d.ts +56 -0
- package/dist/components/app/data-table.js +167 -0
- package/dist/components/app/entity-form.d.ts +14 -0
- package/dist/components/app/entity-form.js +8 -0
- package/dist/components/app/error-state.d.ts +6 -0
- package/dist/components/app/error-state.js +20 -0
- package/dist/components/app/foundation-providers.d.ts +9 -0
- package/dist/components/app/foundation-providers.js +15 -0
- package/dist/components/app/mock-mode-banner.d.ts +3 -0
- package/dist/components/app/mock-mode-banner.js +16 -0
- package/dist/components/app/multi-choice.d.ts +14 -0
- package/dist/components/app/multi-choice.js +13 -0
- package/dist/components/app/password-input.d.ts +3 -0
- package/dist/components/app/password-input.js +30 -0
- package/dist/components/app/route-error-boundary.d.ts +1 -0
- package/dist/components/app/route-error-boundary.js +13 -0
- package/dist/components/theme-provider.d.ts +2 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +50 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/calendar.d.ts +10 -0
- package/dist/components/ui/calendar.js +76 -0
- package/dist/components/ui/card.d.ts +11 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/combobox.d.ts +24 -0
- package/dist/components/ui/combobox.js +62 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-group.js +57 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +23 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +35 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +38 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +255 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/sonner.d.ts +3 -0
- package/dist/components/ui/sonner.js +24 -0
- package/dist/components/ui/spinner.d.ts +2 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +45 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +5 -0
- package/dist/hooks/table-url-changes.d.ts +14 -0
- package/dist/hooks/table-url-changes.js +32 -0
- package/dist/hooks/use-debounced-value.d.ts +1 -0
- package/dist/hooks/use-debounced-value.js +12 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +14 -0
- package/dist/hooks/use-table-url-state.js +43 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +28 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +1 -0
- package/dist/mocks/auth.d.ts +9 -0
- package/dist/mocks/auth.js +79 -0
- package/dist/mocks/e2e-hooks.d.ts +3 -0
- package/dist/mocks/e2e-hooks.js +28 -0
- package/dist/mocks/index.d.ts +3 -0
- package/dist/mocks/index.js +5 -0
- package/dist/mocks/override.d.ts +23 -0
- package/dist/mocks/override.js +1 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +13 -0
- package/dist/routes/login.d.ts +8 -0
- package/dist/routes/login.js +58 -0
- package/dist/routes/register-schema.d.ts +9 -0
- package/dist/routes/register-schema.js +23 -0
- package/dist/routes/register.d.ts +1 -0
- package/dist/routes/register.js +58 -0
- package/dist/routes/return-path.d.ts +1 -0
- package/dist/routes/return-path.js +9 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +103 -0
- package/eslint/index.d.ts +11 -0
- package/eslint/index.js +179 -0
- package/openapi/foundation.yaml +156 -0
- package/package.json +158 -0
- package/styles/index.css +88 -0
- 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,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,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,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,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 };
|