diff --git a/.agents/skills/constructive-blocks/SKILL.md b/.agents/skills/constructive-blocks/SKILL.md index b4126e49..ce7be540 100644 --- a/.agents/skills/constructive-blocks/SKILL.md +++ b/.agents/skills/constructive-blocks/SKILL.md @@ -104,6 +104,9 @@ to endpoints, sessions, adapters, or tenant data. | Secure document rooms shared inside and outside an organization (roles, folder shares, agreements, Q&A, insights) | `data-rooms` | | Global commands and workflows | `command-palette` | | Metadata-driven data grid | `sheets` | +| List, detail, and form screens for an app's own tables (relations, many-to-many, delete) | `resource-kit` | +| Sign-in, sign-up, check-email, verify-email, and password reset screens | `auth-pages` | +| An app's home screen with counts and recent records | `dashboard-tiles` | | PostgreSQL schema workspace | `schema-builder` | | Reporting hierarchy | `org-chart` | | Object storage workspace | `storage-browser` or a focused storage leaf | diff --git a/.agents/skills/constructive-blocks/references/surfaces-and-ownership.md b/.agents/skills/constructive-blocks/references/surfaces-and-ownership.md index 8e8b93ee..bbdd60c2 100644 --- a/.agents/skills/constructive-blocks/references/surfaces-and-ownership.md +++ b/.agents/skills/constructive-blocks/references/surfaces-and-ownership.md @@ -14,6 +14,9 @@ live `shadcn@latest search` and `view`. | Secure document rooms | `data-rooms` | Installed source owns the rooms, documents, people, Q&A, insights, settings, and access-map views, the access explanations and previews, optimistic local updates, and scripted assistant answers; the host supplies workspace data and owns storage, delivery, authorization, the activity log, and assistant runs. | | Searchable application commands | `command-palette` | The npm package owns the headless engine; installed source owns the dialog and task presentation; the host owns routing and actions. | | Arbitrary application-table CRUD | `sheets` | Installed source owns the grid; the data runtime interprets current `_meta`; the host owns endpoint and session discovery. | +| Generated app screens for known tables | `resource-kit` | Installed source owns the list, detail, form, relation picker, and delete views, query caching, and invalidation after writes; the host supplies resource definitions, each table's source (list, get, create, update, remove, link), and the router. | +| Public account screens | `auth-pages` | Installed source owns the panels' validation, pending, and result states; the host owns routing, sessions, tokens, and every auth call. | +| App home screen | `dashboard-tiles` | Installed source owns the tiles and their layout and reads counts and recent rows through `resource-kit` sources; the host chooses what to show. | | Database schema editing | `schema-builder` | Installed source owns the workspace; a host adapter performs control-plane operations and confirmation workflows. | | Reporting relationships | `org-chart` | The controlled block owns hierarchy presentation; the host loads and persists reporting lines. | | Object storage management | `storage-browser` or a storage leaf | Blocks are controlled and transport-neutral; the host filters, authorizes, fetches, uploads, and mutates objects. | @@ -73,6 +76,7 @@ and dependency closure. The public documentation provides richer examples: - Storage: `https://constructive-io.github.io/blocks/blocks/storage/` (Storage Browser at `/blocks/storage/browser/`, the feature pack at `/blocks/features/storage/`) - Agents Builder: `https://constructive-io.github.io/blocks/blocks/agents-builder/` - Data Rooms: `https://constructive-io.github.io/blocks/blocks/data-rooms/` +- Resource Kit, Auth Pages, Dashboard Tiles: `https://constructive-io.github.io/blocks/blocks/resource-kit/`, `/blocks/auth-pages/`, `/blocks/dashboard-tiles/` - Billing: `https://constructive-io.github.io/blocks/blocks/billing/` (Billing Account at `/blocks/billing/account/`, Billing Console at `/blocks/billing/console/`) - Feature packs: `https://constructive-io.github.io/blocks/blocks/features/` - Console Kit: `https://constructive-io.github.io/blocks/blocks/console-kit/` diff --git a/apps/blocks/src/app/blocks/auth-pages/page.tsx b/apps/blocks/src/app/blocks/auth-pages/page.tsx new file mode 100644 index 00000000..f7444569 --- /dev/null +++ b/apps/blocks/src/app/blocks/auth-pages/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockDocsPage } from '@/components/application-block-showcase/application-block-docs-page'; +import { getApplicationBlock } from '@/lib/application-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const block = getApplicationBlock('auth-pages')!; + +export default function AuthPagesPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/auth-pages') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/auth-pages'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/auth-pages/preview/page.tsx b/apps/blocks/src/app/blocks/auth-pages/preview/page.tsx new file mode 100644 index 00000000..526631c8 --- /dev/null +++ b/apps/blocks/src/app/blocks/auth-pages/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockShowcaseCanvas } from '@/components/application-block-showcase/application-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function AuthPagesPreviewPage() { + return ( + <> +

Auth Pages live preview

+ + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/auth-pages') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/dashboard-tiles/page.tsx b/apps/blocks/src/app/blocks/dashboard-tiles/page.tsx new file mode 100644 index 00000000..625a5e78 --- /dev/null +++ b/apps/blocks/src/app/blocks/dashboard-tiles/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockDocsPage } from '@/components/application-block-showcase/application-block-docs-page'; +import { getApplicationBlock } from '@/lib/application-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const block = getApplicationBlock('dashboard-tiles')!; + +export default function DashboardTilesPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/dashboard-tiles') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/dashboard-tiles'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/dashboard-tiles/preview/page.tsx b/apps/blocks/src/app/blocks/dashboard-tiles/preview/page.tsx new file mode 100644 index 00000000..355f9ec8 --- /dev/null +++ b/apps/blocks/src/app/blocks/dashboard-tiles/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockShowcaseCanvas } from '@/components/application-block-showcase/application-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function DashboardTilesPreviewPage() { + return ( + <> +

Dashboard Tiles live preview

+ + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/dashboard-tiles') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/resource-kit/page.tsx b/apps/blocks/src/app/blocks/resource-kit/page.tsx new file mode 100644 index 00000000..aa0e89d7 --- /dev/null +++ b/apps/blocks/src/app/blocks/resource-kit/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockDocsPage } from '@/components/application-block-showcase/application-block-docs-page'; +import { getApplicationBlock } from '@/lib/application-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const block = getApplicationBlock('resource-kit')!; + +export default function ResourceKitPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/resource-kit') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/resource-kit'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/resource-kit/preview/page.tsx b/apps/blocks/src/app/blocks/resource-kit/preview/page.tsx new file mode 100644 index 00000000..eea46e44 --- /dev/null +++ b/apps/blocks/src/app/blocks/resource-kit/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockShowcaseCanvas } from '@/components/application-block-showcase/application-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function ResourceKitPreviewPage() { + return ( + <> +

Resource Kit live preview

+ + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/resource-kit') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/globals.css b/apps/blocks/src/app/globals.css index 49e3af7b..8feb219c 100644 --- a/apps/blocks/src/app/globals.css +++ b/apps/blocks/src/app/globals.css @@ -14,6 +14,9 @@ @source '../../../../packages/ui/src/components/billing-kit'; @source '../../../../packages/ui/src/components/billing-account'; @source '../../../../packages/ui/src/components/billing-console'; +@source '../../../../packages/ui/src/components/resource-kit'; +@source '../../../../packages/ui/src/components/auth-pages'; +@source '../../../../packages/ui/src/components/dashboard-tiles'; html { scroll-padding-top: 4.5rem; diff --git a/apps/blocks/src/components/application-block-showcase/app-kit-preview-shell.tsx b/apps/blocks/src/components/application-block-showcase/app-kit-preview-shell.tsx new file mode 100644 index 00000000..439788b1 --- /dev/null +++ b/apps/blocks/src/components/application-block-showcase/app-kit-preview-shell.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { LayoutDashboard } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; + +import { + CRM_DEMO_ICONS, + CRM_DEMO_NOW, + createCrmDemo, + MemoryRoute, + ResourceKitProvider, + ResourceNav, + useMemoryRouter, +} from '@/components/ui/resource-kit'; +import { NavIcon, NavRow, NavSection, SidebarFrame, WorkspaceShell } from '@/components/ui/workspace-kit'; + +/** + * The CRM demo inside the workspace shell, routed by component state the way + * a host routes it by URL: `/` for home, `/` for a list, and + * `//` for a record. + */ +export function AppKitPreviewShell({ initialPath, home }: { initialPath: string; home?: ReactNode }) { + const { path, setPath, router } = useMemoryRouter(initialPath); + const [resources] = useState(() => createCrmDemo({ latency: 200 })); + + return ( +
+ + { + const rail = mode === 'rail' && collapsed; + return ( + {rail ? 'A' : 'Acme CRM'}} + collapsed={collapsed} + onCollapsedChange={onCollapsedChange} + drawer={mode === 'drawer'} + onClose={onNavigate} + > + + + ); + }} + > + + + +
+ ); +} diff --git a/apps/blocks/src/components/application-block-showcase/application-block-showcase-canvas.tsx b/apps/blocks/src/components/application-block-showcase/application-block-showcase-canvas.tsx index a105f197..b78989d9 100644 --- a/apps/blocks/src/components/application-block-showcase/application-block-showcase-canvas.tsx +++ b/apps/blocks/src/components/application-block-showcase/application-block-showcase-canvas.tsx @@ -23,6 +23,15 @@ const OrgChartPreview = dynamic(() => const StorageBrowserPreview = dynamic(() => import('./storage-browser-preview').then((module) => module.StorageBrowserPreview), ); +const ResourceKitPreview = dynamic(() => + import('./resource-kit-preview').then((module) => module.ResourceKitPreview), +); +const AuthPagesPreview = dynamic(() => + import('./auth-pages-preview').then((module) => module.AuthPagesPreview), +); +const DashboardTilesPreview = dynamic(() => + import('./dashboard-tiles-preview').then((module) => module.DashboardTilesPreview), +); export function ApplicationBlockShowcaseCanvas({ name, @@ -33,6 +42,9 @@ export function ApplicationBlockShowcaseCanvas({ if (name === 'data-rooms') return ; if (name === 'billing-account') return ; if (name === 'billing-console') return ; + if (name === 'resource-kit') return ; + if (name === 'auth-pages') return ; + if (name === 'dashboard-tiles') return ; return name === 'org-chart' ? ( ) : ( diff --git a/apps/blocks/src/components/application-block-showcase/auth-pages-preview.tsx b/apps/blocks/src/components/application-block-showcase/auth-pages-preview.tsx new file mode 100644 index 00000000..93e430ff --- /dev/null +++ b/apps/blocks/src/components/application-block-showcase/auth-pages-preview.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { Fragment, useCallback, useMemo, useState } from 'react'; + +import { + AuthLayout, + type AuthLinkComponent, + type AuthResult, + CheckEmailPanel, + ForgotPasswordPanel, + ResetPasswordPanel, + SignInPanel, + SignUpPanel, + VerifyEmailPanel, +} from '@/components/ui/auth-pages'; +import { Segmented } from '@/components/ui/workspace-kit'; + +type Panel = 'sign-in' | 'sign-up' | 'check-email' | 'verify-email' | 'forgot-password' | 'reset-password'; + +const PANELS: { value: Panel; label: string }[] = [ + { value: 'sign-in', label: 'Sign in' }, + { value: 'sign-up', label: 'Sign up' }, + { value: 'check-email', label: 'Check email' }, + { value: 'verify-email', label: 'Verify' }, + { value: 'forgot-password', label: 'Forgot' }, + { value: 'reset-password', label: 'Reset' }, +]; + +const PANEL_BY_HREF = new Map(PANELS.map((panel) => [`/${panel.value}`, panel.value])); + +const INTRO = 'Try the password wrong-password, or sign up as taken@example.com, to see how a refusal looks.'; + +const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +function BrandLogo() { + return ( + + + + ); +} + +export function AuthPagesPreview() { + const [panel, setPanel] = useState('sign-in'); + // Remounts the panel on every switch, so a finished state (check email, password updated) starts over. + const [visit, setVisit] = useState(0); + const [message, setMessage] = useState(INTRO); + + // Setters only, so the memoized `PreviewLink` can call it. + const show = useCallback((next: Panel) => { + setPanel(next); + setVisit((count) => count + 1); + setMessage(INTRO); + }, []); + + // Footer links switch panels here, as `#/` so the static page never + // points outside the site's base path; a host passes its router link instead. + const PreviewLink = useMemo( + () => + function PreviewLink({ href, className, children }) { + return ( + { + event.preventDefault(); + const target = PANEL_BY_HREF.get(href); + if (target) { + show(target); + } else { + setMessage(`The host would navigate to ${href}.`); + } + }} + > + {children} + + ); + }, + [show], + ); + + const respond = async (result: AuthResult, hostAction?: string): Promise => { + await wait(700); + setMessage(result.ok ? (hostAction ?? 'The host would carry on.') : `The handler answered: ${result.message}`); + return result; + }; + + const content = (() => { + switch (panel) { + case 'sign-in': + return ( + + respond( + password === 'wrong-password' ? { ok: false, message: 'That email and password do not match.' } : { ok: true }, + `The host would start a ${rememberMe ? 'long-lived' : 'browser'} session for ${email} and go to /app.`, + ) + } + /> + ); + case 'sign-up': + return ( + + respond( + email === 'taken@example.com' ? { ok: false, message: 'An account with that email already exists. Sign in instead.' } : { ok: true }, + `The host would create the account and email a confirmation link to ${email}.`, + ) + } + /> + ); + case 'check-email': + return ( + respond({ ok: true }, 'The host would send another confirmation email.')} + /> + ); + case 'verify-email': + return ( + respond({ ok: true }, 'The host would confirm the token from the link and mark the address verified.')} + /> + ); + case 'forgot-password': + return ( + respond({ ok: true }, `If ${email} has an account, the host would email it a reset link.`)} + /> + ); + case 'reset-password': + return ( + respond({ ok: true }, 'The host would save the new password for the reset token in the link.')} + /> + ); + } + })(); + + return ( +
+
+ +
+
+ }} + className="min-h-0 flex-1" + footer="By continuing you agree to the Terms and the Privacy Policy." + linkComponent={PreviewLink} + > + {content} + +
+

+ {message} +

+
+ ); +} diff --git a/apps/blocks/src/components/application-block-showcase/dashboard-tiles-preview.tsx b/apps/blocks/src/components/application-block-showcase/dashboard-tiles-preview.tsx new file mode 100644 index 00000000..a496be9a --- /dev/null +++ b/apps/blocks/src/components/application-block-showcase/dashboard-tiles-preview.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { LayoutDashboard, Wallet } from 'lucide-react'; + +import { DashboardGrid, RecentRecords, ResourceCountTile, StatTile } from '@/components/ui/dashboard-tiles'; +import { ViewFrame } from '@/components/ui/workspace-kit'; + +import { AppKitPreviewShell } from './app-kit-preview-shell'; + +const NEWEST = { field: 'createdAt', direction: 'desc' } as const; + +function Overview() { + return ( + + + + + + + + + + + ); +} + +export function DashboardTilesPreview() { + return } />; +} diff --git a/apps/blocks/src/components/application-block-showcase/resource-kit-preview.tsx b/apps/blocks/src/components/application-block-showcase/resource-kit-preview.tsx new file mode 100644 index 00000000..ba4748a5 --- /dev/null +++ b/apps/blocks/src/components/application-block-showcase/resource-kit-preview.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { AppKitPreviewShell } from './app-kit-preview-shell'; + +export function ResourceKitPreview() { + return ; +} diff --git a/apps/blocks/src/components/site/registry-shell.tsx b/apps/blocks/src/components/site/registry-shell.tsx index 57185025..9ac7c64e 100644 --- a/apps/blocks/src/components/site/registry-shell.tsx +++ b/apps/blocks/src/components/site/registry-shell.tsx @@ -41,7 +41,7 @@ export function RegistryShell({ children }: { children: ReactNode }) { const isConsoleKitProof = pathname === '/__integration/console-kit'; const isStandalonePreview = isConsoleKitProof || - /^\/blocks\/(?:features\/[^/]+|org-chart|storage\/browser|agents-builder|data-rooms|billing\/(?:account|console)|sheets|schema-builder|documents|create)\/preview\/?$/.test(pathname); + /^\/blocks\/(?:features\/[^/]+|org-chart|storage\/browser|agents-builder|data-rooms|resource-kit|auth-pages|dashboard-tiles|billing\/(?:account|console)|sheets|schema-builder|documents|create)\/preview\/?$/.test(pathname); const menuButtonRef = useRef(null); const sidebarRef = useRef(null); const previouslyFocusedRef = useRef(null); diff --git a/apps/blocks/src/lib/app-kit/auth-pages.ts b/apps/blocks/src/lib/app-kit/auth-pages.ts new file mode 100644 index 00000000..6e11704a --- /dev/null +++ b/apps/blocks/src/lib/app-kit/auth-pages.ts @@ -0,0 +1,145 @@ +import type { ApplicationBlockDoc } from '../application-blocks'; + +export const AUTH_PAGES_DOC: ApplicationBlockDoc = { + name: 'auth-pages', + title: 'Auth Pages', + description: + 'Sign-in, sign-up, check-email, verify-email, forgot-password, and reset-password panels with a page layout. Each panel validates its fields, shows pending and failure states, and hands the result to injected handlers, so it works with any router and any auth backend.', + previewDescription: + 'Switch between the six panels, submit with a bad email to see field errors, sign in with the password wrong-password or sign up as taken@example.com to see a refusal, and follow the footer links between panels.', + previewHeight: 760, + whenToUse: [ + 'Use Auth Pages for the public account screens of an app: signing in, creating an account, confirming an email address, and recovering a password.', + 'Use the Auth feature pack when the console already owns the auth policy and actions and you want the entry panel and account view wired to them.', + ], + usage: { + description: + 'Render one panel per route inside AuthLayout and pass your auth calls as handlers that answer { ok: true } or { ok: false, message }. The panels own validation, pending, and result states; the host owns routing, sessions, tokens, and the backend.', + example: `'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { + AuthLayout, + type AuthLinkComponent, + type AuthResult, + SignInPanel, + VerifyEmailPanel +} from '@/components/ui/auth-pages'; + +type AuthCalls = Readonly<{ + signIn: (credentials: { + email: string; + password: string; + rememberMe: boolean; + }) => Promise; + verifyEmail: (token: string) => Promise; +}>; + +const RouterLink: AuthLinkComponent = ({ href, className, children }) => ( + + {children} + +); + +const brand = { name: 'Acme', href: '/' }; + +export function SignInPage({ auth }: Readonly<{ auth: AuthCalls }>) { + const router = useRouter(); + + return ( + + { + const result = await auth.signIn(credentials); + if (result.ok) router.push('/app'); + return result; + }} + /> + + ); +} + +export function VerifyEmailPage({ + auth, + token +}: Readonly<{ auth: AuthCalls; token: string }>) { + return ( + + auth.verifyEmail(token)} + /> + + ); +}`, + }, + state: { + title: 'Validation, pending, and results', + description: + 'Every form checks its fields before calling the handler: a required email with a basic shape check (trimmed before it is sent), a required password, the minimum length on sign-up and reset, and a matching confirmation on reset. While a handler runs, the submit button is disabled and only one call runs at a time. { ok: false, message } shows the message in an alert; a handler that throws shows “Something went wrong. Try again.” and never the raw error. On { ok: true }, sign-up shows the check-email state for the address, forgot-password says a link is on its way if the account exists, reset-password offers sign-in, and sign-in leaves navigation to the host.', + }, + composition: [ + 'Built from the same card, field, input group, checkbox, alert, and button primitives as the Auth feature pack, so the public pages match the console’s entry panel.', + 'Pass linkComponent once on AuthLayout and every panel inside it uses it; a panel’s own linkComponent wins. Without one, links are plain anchors, and a link is left out when its href is not given.', + 'title, description, and notice let a host rename a panel or explain why the person is there, such as a session that ended. Field labels and validation messages are fixed English copy.', + 'EmailInput and PasswordInput are exported for other credential forms, such as a change-password dialog.', + ], + accessibility: [ + 'Field errors set aria-invalid and aria-describedby on their input, and focus moves to the first invalid field.', + 'A refused or failed call is announced as an alert, and focus returns to the field nearest the action so the person can correct it.', + 'When a result replaces a form (check email, password updated, email verified), focus moves to the new heading so it is read out.', + 'The show/hide password button has a label and aria-pressed; email addresses read left to right on right-to-left pages, and long addresses and translated copy wrap instead of overflowing.', + ], + api: [ + { + name: 'SignInPanel', + type: 'onSubmit({ email, password, rememberMe }), links?: { signUp, forgotPassword }, defaultEmail?', + behavior: + 'Email, password, and “Keep me signed in on this device”. On { ok: true } the button stays down as “Signed in” while the host navigates.', + }, + { + name: 'SignUpPanel', + type: 'onSubmit({ email, password }), links?: { signIn }, passwordMinLength? (8)', + behavior: 'Creates an account and never expects a session: on { ok: true } it shows the check-email state for the address.', + }, + { + name: 'CheckEmailPanel', + type: 'email?, onResend?(), links?: { signIn }', + behavior: 'Tells the person to confirm their address, then sign in with their password. onResend adds a resend button.', + }, + { + name: 'VerifyEmailPanel', + type: 'onVerify(), links?: { signIn, continue }', + behavior: + 'Calls onVerify exactly once on mount, even under StrictMode, then shows the verified state with a link onward or the failure with “Try again”.', + }, + { + name: 'ForgotPasswordPanel', + type: 'onSubmit({ email }), links?: { signIn }', + behavior: 'On { ok: true } says a reset link is on its way if an account exists for the address, so it never reveals which accounts exist.', + }, + { + name: 'ResetPasswordPanel', + type: 'onSubmit({ password }), links?: { signIn }, passwordMinLength? (8)', + behavior: 'New password and confirmation; a short or mismatched entry is a field error. On { ok: true } it offers sign-in.', + }, + { + name: 'AuthLayout', + type: 'children, brand?: { name, logo?, href? }, footer?, linkComponent?', + behavior: 'A centred column on the page background with the brand above the card and a muted footer below, from 320px phones to desktop.', + }, + { + name: 'Every panel', + type: 'title?, description?, notice?: { tone, message }, linkComponent?, className?', + behavior: 'title and description replace the first state’s copy; notice shows an info, success, or error alert above the form.', + }, + { + name: 'AuthResult', + type: '{ ok: true } | { ok: false; message: string }', + behavior: 'What every handler returns, directly or from a promise. The message is shown to the person as written.', + }, + ], +}; diff --git a/apps/blocks/src/lib/app-kit/dashboard-tiles.ts b/apps/blocks/src/lib/app-kit/dashboard-tiles.ts new file mode 100644 index 00000000..c70f5e09 --- /dev/null +++ b/apps/blocks/src/lib/app-kit/dashboard-tiles.ts @@ -0,0 +1,87 @@ +import type { ApplicationBlockDoc } from '../application-blocks'; + +export const DASHBOARD_TILES_DOC: ApplicationBlockDoc = { + name: 'dashboard-tiles', + title: 'Dashboard Tiles', + description: + 'The home screen of a resource-kit app: stat tiles on their own cards, count tiles that read a resource’s total with optional filters, panels of the latest records, and a grid that lays them out by its own width.', + previewDescription: + 'A CRM overview with a count per resource, an open-deals count from a filter, a fixed figure, and the latest contacts and deals. Open a tile to jump to its list, or narrow the frame to watch the grid go from four tiles across to two.', + previewHeight: 720, + whenToUse: [ + 'Use Dashboard Tiles for the first screen of a generated app: how many of each thing there are and what changed lately, each linking to where it came from.', + 'Use the billing or data-room insight views when a dashboard needs charts, trends, or domain-specific summaries.', + ], + usage: { + description: + 'Render the tiles inside the ResourceKitProvider that holds your resources. Count tiles and recent lists read through each resource’s source and share its cache, so a write anywhere in the app refreshes them.', + example: `'use client'; + +import { Wallet } from 'lucide-react'; + +import { + DashboardGrid, + RecentRecords, + ResourceCountTile, + StatTile +} from '@/components/ui/dashboard-tiles'; + +export function Overview({ pipeline }: { pipeline: string }) { + return ( + + + + + + + + + ); +}`, + }, + state: { + title: 'Counts and recent records', + description: + 'A count tile asks its source for one row and shows the totalCount, with the same equality filters a list takes. Recent records follow the resource’s default sort, or newest first by createdAt when the resource has no default sort; pass sort to choose. Both refresh when the resource or a related one is written.', + }, + composition: [ + 'Tiles use the workspace kit’s stat figure and card surface; recent records use its panel, dashed rows, and empty state.', + 'StatTile works without the provider for figures the host computes itself; inside the provider its href goes through the host router’s Link.', + 'DashboardGrid sizes by its own container: one column on the narrowest screens, two tiles across in a pane or on a phone, four from 48rem. Recent records take two cells.', + ], + accessibility: [ + 'A linked tile is a single link whose name is its label and figure, with the chevron hidden from assistive tech.', + 'Loading tiles and lists mark themselves busy, and every record title in a recent list is a link to that record.', + ], + api: [ + { + name: 'StatTile', + type: '{ label, value, hint?, href?, icon?, loading? }', + behavior: 'A figure on its own card. With href the whole tile links to it.', + }, + { + name: 'ResourceCountTile', + type: '{ resource, label?, filters?, hint? }', + behavior: 'The resource’s total (optionally filtered) with its icon, linking to its list.', + }, + { + name: 'RecentRecords', + type: '{ resource, limit?, sort?, filters?, title? }', + behavior: 'The latest records with one detail and how long ago each was made, plus a View all link.', + }, + { + name: 'DashboardGrid', + type: 'div props', + behavior: 'Lays tiles out by its own width.', + }, + ], +}; diff --git a/apps/blocks/src/lib/app-kit/resource-kit.ts b/apps/blocks/src/lib/app-kit/resource-kit.ts new file mode 100644 index 00000000..b83054ce --- /dev/null +++ b/apps/blocks/src/lib/app-kit/resource-kit.ts @@ -0,0 +1,172 @@ +import type { ApplicationBlockDoc } from '../application-blocks'; + +export const RESOURCE_KIT_DOC: ApplicationBlockDoc = { + name: 'resource-kit', + title: 'Resource Kit', + description: + 'List, detail, and form screens generated from resource definitions: search, sort, and pagination over a source, a table that formats every field and links related records, a create and edit sheet with relation pickers and many-to-many links, record pages with their has-many lists, and delete confirmation.', + previewDescription: + 'Browse a small CRM: search and sort contacts, open a company to see its contacts and deals, create a deal from a company, edit a contact’s tags, delete a record, and narrow the frame to watch columns fold into the first cell.', + previewHeight: 780, + whenToUse: [ + 'Use Resource Kit for the data screens of an application: one list, detail, and form per table, described once and rendered the same way everywhere.', + 'Use Sheets when people need spreadsheet-style editing of arbitrary tables discovered from _meta, and a purpose-built block when a screen is a workflow rather than records.', + ], + usage: { + description: + 'Describe each table with defineResource: its fields, how they read and edit, and a source with list, get, create, update, and remove (plus link and linked for many-to-many fields). Wrap the app once in ResourceKitProvider with your router, then render ResourcePage and RecordDetail on your routes.', + example: `'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Building2, Users } from 'lucide-react'; + +import { + type AnyResource, + defineResource, + RecordDetail, + ResourceKitProvider, + ResourcePage, + type ResourceRouter, + type ResourceSource +} from '@/components/ui/resource-kit'; + +type Contact = { + id: string; + fullName: string; + email: string; + companyId: string | null; + stage: string; +}; + +export function contactsResource(source: ResourceSource) { + return defineResource({ + name: 'contacts', + label: 'Contacts', + singular: 'Contact', + icon: 'users', + primaryKey: 'id', + titleField: 'fullName', + columns: ['fullName', 'companyId', 'stage', 'email'], + formFields: ['fullName', 'email', 'companyId', 'stage'], + searchFields: ['fullName', 'email'], + defaultSort: { field: 'fullName', direction: 'asc' }, + fields: { + id: { name: 'id', label: 'ID', input: 'uuid', hidden: true }, + fullName: { name: 'fullName', label: 'Name', input: 'text', required: true }, + email: { name: 'email', label: 'Email', input: 'email', required: true }, + companyId: { + name: 'companyId', + label: 'Company', + input: 'relation', + relation: { resource: 'companies' } + }, + stage: { + name: 'stage', + label: 'Stage', + input: 'select', + options: [ + { value: 'lead', label: 'Lead', tone: 'info' }, + { value: 'customer', label: 'Customer', tone: 'success' } + ] + } + }, + source + }); +} + +export function AppResources({ + resources, + children +}: { + resources: AnyResource[]; + children: ReactNode; +}) { + const router = useRouter(); + const resourceRouter: ResourceRouter = { + Link, + navigate: (href) => router.push(href), + listHref: (resource) => \`/\${resource}\`, + detailHref: (resource, id) => \`/\${resource}/\${id}\` + }; + + return ( + + {children} + + ); +} + +// app/contacts/page.tsx +export function ContactsPage() { + return ; +} + +// app/contacts/[id]/page.tsx +export function ContactPage({ id }: { id: string }) { + return ; +}`, + }, + state: { + title: 'Sources, caching, and writes', + description: + 'Every read goes through the resource’s source and a shared query cache: the provider reuses the host’s QueryClient when one encloses it and makes its own otherwise. Creating, editing, linking, or deleting a record invalidates that resource and every resource related to it, so lists, record pages, related lists, labels, and dashboard counts refresh together. A rejected write keeps the sheet or dialog open with the source’s message, and an error carrying fields shows those messages under the matching inputs.', + }, + composition: [ + 'Built on the workspace kit (view frames, table surfaces, panels, empty states, search fields) with the form kit’s fields, select, switch, and combobox, so generated screens match the templates beside them.', + 'Relation fields read a label from a row the source embedded (companyId reads company), or else from one cached get per related record; many-to-many fields read source.linked and write the whole set with source.link only when it changed.', + 'Columns fold away as a table narrows and their values move under the first cell, so a list stays readable in a 700-pixel pane or on a phone.', + 'DataTable, EntityFormSheet, RelationPicker, RecordDetail, DeleteDialog, ResourceNav, and createMemorySource are exported for hosts that compose their own screens or prototype before a backend exists.', + ], + accessibility: [ + 'Record titles are links, and every row has an actions menu named for its record, so nothing depends on clicking a row.', + 'Sortable headers are buttons that announce their sort direction, and loading tables mark their body busy.', + 'Form errors are tied to their inputs with aria-describedby, the first invalid field takes focus, and rejected saves are announced as alerts.', + 'Relation pickers are comboboxes that search as you type; Escape closes the list without clearing a choice.', + ], + api: [ + { + name: 'defineResource(definition)', + type: 'ResourceDefinition', + behavior: + 'Names a table (name, label, singular, icon, primaryKey, titleField), its list columns, form and detail fields, search fields, default sort, field definitions, and source.', + }, + { + name: 'ResourceSource', + type: 'list / get / create / update / remove / link? / linked?', + behavior: + 'The only data boundary. list receives search, sort, offset, limit, and equality filters and answers rows with a totalCount; link replaces the linked ids of one many-to-many field.', + }, + { + name: 'ResourceKitProvider', + type: '{ router, resources, queryClient?, locale?, currency?, now?, icons? }', + behavior: + 'Resolves resources and relation targets by name, owns the query cache, and maps icon names to icon components. The router supplies Link, navigate, listHref, and detailHref.', + }, + { + name: 'ResourcePage', + type: '{ resource, pageSize?, filters?, readOnly? }', + behavior: 'The list screen: header with count and create button, search, sort, table, pagination, and empty, no-match, and error states.', + }, + { + name: 'RecordDetail', + type: '{ resource, id, relatedLimit?, readOnly? }', + behavior: 'One record with its fields, a list for every resource that belongs to it, and edit and delete. Deleting navigates back to the list.', + }, + { + name: 'EntityFormSheet', + type: '{ resource, record?, open, onOpenChange, onSaved?, defaultValues? }', + behavior: 'Creates or edits a record in a sheet, validating required and typed fields and sending only changed fields on edit.', + }, + { + name: 'DataTable / RelationPicker / DeleteDialog', + type: 'Components', + behavior: 'The table, the async relation search (single or many), and the delete confirmation, for screens the host composes itself.', + }, + ], +}; diff --git a/apps/blocks/src/lib/application-blocks.ts b/apps/blocks/src/lib/application-blocks.ts index a5eb94d5..c73cfdd3 100644 --- a/apps/blocks/src/lib/application-blocks.ts +++ b/apps/blocks/src/lib/application-blocks.ts @@ -1,3 +1,6 @@ +import { AUTH_PAGES_DOC } from './app-kit/auth-pages'; +import { DASHBOARD_TILES_DOC } from './app-kit/dashboard-tiles'; +import { RESOURCE_KIT_DOC } from './app-kit/resource-kit'; import type { DocSectionId } from './doc-sections'; export type ApplicationBlockApiRow = Readonly<{ @@ -7,7 +10,16 @@ export type ApplicationBlockApiRow = Readonly<{ }>; export type ApplicationBlockDoc = Readonly<{ - name: 'org-chart' | 'storage-browser' | 'agents-builder' | 'data-rooms' | 'billing-account' | 'billing-console'; + name: + | 'org-chart' + | 'storage-browser' + | 'agents-builder' + | 'data-rooms' + | 'billing-account' + | 'billing-console' + | 'resource-kit' + | 'auth-pages' + | 'dashboard-tiles'; /** Docs route when it is not `/blocks/`, e.g. inside a section such as Billing. */ href?: string; /** Section hub the block is listed under instead of its own nav item. */ @@ -849,6 +861,12 @@ export function OperatorBilling({ data, api }: OperatorBillingProps) { { name: 'StandingTable', type: 'platform', behavior: 'Billing suspensions and admin holds with hold and release.' }, ], }, + // The app kit: the blocks a generated application is composed from. Resource + // Kit turns resource definitions into list, detail, and form screens; Auth + // Pages and Dashboard Tiles cover the screens around them. + RESOURCE_KIT_DOC, + AUTH_PAGES_DOC, + DASHBOARD_TILES_DOC, ]; const APPLICATION_BLOCK_BY_NAME = new Map( diff --git a/apps/blocks/src/lib/application-doc-navigation.ts b/apps/blocks/src/lib/application-doc-navigation.ts index 175b2e40..77d98cca 100644 --- a/apps/blocks/src/lib/application-doc-navigation.ts +++ b/apps/blocks/src/lib/application-doc-navigation.ts @@ -5,6 +5,9 @@ export type ApplicationDocId = | 'data-rooms' | 'billing-account' | 'billing-console' + | 'resource-kit' + | 'auth-pages' + | 'dashboard-tiles' | 'sheets' | 'schema-builder' | 'console-kit' @@ -24,6 +27,9 @@ export const APPLICATION_DOC_SEQUENCE: readonly ApplicationDocLink[] = [ { id: 'data-rooms', title: 'Data Rooms' }, { id: 'billing-account', title: 'Billing Account', href: '/blocks/billing/account' }, { id: 'billing-console', title: 'Billing Console', href: '/blocks/billing/console' }, + { id: 'resource-kit', title: 'Resource Kit' }, + { id: 'auth-pages', title: 'Auth Pages' }, + { id: 'dashboard-tiles', title: 'Dashboard Tiles' }, { id: 'sheets', title: 'Sheets' }, { id: 'schema-builder', title: 'Schema Builder' }, { id: 'console-kit', title: 'Console Kit' }, diff --git a/apps/registry/REGISTRY.md b/apps/registry/REGISTRY.md index 122850b7..5fd2991e 100644 --- a/apps/registry/REGISTRY.md +++ b/apps/registry/REGISTRY.md @@ -41,6 +41,7 @@ pnpm dlx shadcn@latest add @constructive/workspace-kit pnpm dlx shadcn@latest add @constructive/billing-account pnpm dlx shadcn@latest add @constructive/account-phone-numbers pnpm dlx shadcn@latest add @constructive/agents-builder +pnpm dlx shadcn@latest add @constructive/resource-kit @constructive/auth-pages @constructive/dashboard-tiles ``` `feature-pack-*` installs are standalone views and write their machine-readable diff --git a/apps/registry/scripts/smoke-install.ts b/apps/registry/scripts/smoke-install.ts index 9c133e05..44510733 100644 --- a/apps/registry/scripts/smoke-install.ts +++ b/apps/registry/scripts/smoke-install.ts @@ -529,6 +529,126 @@ export function AiRegistrySmokeConsumer() { ], strictNullChecks: true, }, + { + name: 'resource-kit', + consumer: `'use client'; + +import { Users } from 'lucide-react'; + +import { + createMemorySource, + defineResource, + RecordDetail, + ResourceKitProvider, + ResourcePage, + type ResourceRouter +} from '@/components/ui/resource-kit'; + +type Contact = { id: string; name: string; companyId: string | null; stage: string }; + +const contacts = defineResource({ + name: 'contacts', + label: 'Contacts', + singular: 'Contact', + icon: 'users', + primaryKey: 'id', + titleField: 'name', + columns: ['name', 'stage'], + formFields: ['name', 'stage'], + fields: { + id: { name: 'id', label: 'ID', input: 'uuid', hidden: true }, + name: { name: 'name', label: 'Name', input: 'text', required: true }, + companyId: { name: 'companyId', label: 'Company', input: 'relation', relation: { resource: 'contacts' } }, + stage: { name: 'stage', label: 'Stage', input: 'select', options: [{ value: 'lead', label: 'Lead', tone: 'info' }] } + }, + source: createMemorySource([]) +}); + +export function ResourceKitSmokeConsumer({ router, id }: { router: ResourceRouter; id?: string }) { + return ( + + {id ? : } + + ); +} +`, + expected: [ + 'src/components/ui/resource-kit/index.ts', + 'src/components/ui/resource-kit/types.ts', + 'src/components/ui/resource-kit/context.tsx', + 'src/components/ui/resource-kit/queries.ts', + 'src/components/ui/resource-kit/resource-page.tsx', + 'src/components/ui/resource-kit/data-table.tsx', + 'src/components/ui/resource-kit/entity-form-sheet.tsx', + 'src/components/ui/resource-kit/relation-picker.tsx', + 'src/components/ui/resource-kit/record-detail.tsx', + 'src/components/ui/resource-kit/delete-dialog.tsx', + 'src/components/ui/resource-kit/memory-source.ts', + 'src/components/ui/workspace-kit/surface.tsx', + 'src/components/ui/combobox.tsx', + 'src/components/ui/sheet.tsx', + 'src/components/ui/alert-dialog.tsx', + ], + expectedPackages: ['@tanstack/react-query', 'lucide-react'], + strictNullChecks: true, + }, + { + name: 'auth-pages', + consumer: `'use client'; + +import { AuthLayout, SignInPanel, type AuthLinkComponent } from '@/components/ui/auth-pages'; + +export function AuthPagesSmokeConsumer({ link }: { link: AuthLinkComponent }) { + return ( + + (email ? { ok: true } : { ok: false, message: 'Enter an email address.' })} + /> + + ); +} +`, + expected: [ + 'src/components/ui/auth-pages/index.ts', + 'src/components/ui/auth-pages/auth-layout.tsx', + 'src/components/ui/auth-pages/sign-in-panel.tsx', + 'src/components/ui/auth-pages/sign-up-panel.tsx', + 'src/components/ui/auth-pages/check-email-panel.tsx', + 'src/components/ui/auth-pages/verify-email-panel.tsx', + 'src/components/ui/auth-pages/forgot-password-panel.tsx', + 'src/components/ui/auth-pages/reset-password-panel.tsx', + 'src/components/ui/card.tsx', + 'src/components/ui/field.tsx', + ], + strictNullChecks: true, + }, + { + name: 'dashboard-tiles', + consumer: `'use client'; + +import { DashboardGrid, RecentRecords, ResourceCountTile, StatTile } from '@/components/ui/dashboard-tiles'; + +export function DashboardTilesSmokeConsumer() { + return ( + + + + + + ); +} +`, + expected: [ + 'src/components/ui/dashboard-tiles/index.ts', + 'src/components/ui/dashboard-tiles/stat-tile.tsx', + 'src/components/ui/dashboard-tiles/recent-records.tsx', + 'src/components/ui/dashboard-tiles/dashboard-grid.tsx', + 'src/components/ui/resource-kit/queries.ts', + ], + expectedPackages: ['@tanstack/react-query'], + strictNullChecks: true, + }, { name: 'sheets', expectedPackages: ['@constructive-io/data'], diff --git a/packages/sheets/registry.json b/packages/sheets/registry.json index 43ef01fc..15949282 100644 --- a/packages/sheets/registry.json +++ b/packages/sheets/registry.json @@ -8,7 +8,7 @@ "type": "registry:block", "title": "Sheets", "description": "A metadata-driven, spreadsheet-grade CRUD grid with accessible DOM cells and adapter-owned data access.", - "docs": "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source depends on `@constructive-io/data` for the current `_meta` contract and runtime GraphQL operations; your host remains responsible for its endpoint, session, and RLS-aware execution boundary.", + "docs": "Import from `@/components/ui/sheets`. A metadata-driven CRUD grid: it reads a table's columns from the endpoint's `_meta`, then loads, edits, creates, and deletes rows itself over GraphQL with the host's session. There's no `data` prop and no save callback. The host owns the endpoint, the session token, and auth errors, and PostgreSQL privileges and RLS at the endpoint decide every read and write.\n\nRender it in a client component. `Sheets` fills its parent, so give the parent a bounded height, and mount `` from `@/components/ui/portal` once as the last child of `` for the overlay editors:\n```tsx\n'use client';\nimport { useMemo } from 'react';\nimport { Sheets, SheetsProvider, type SheetsConfig } from '@/components/ui/sheets';\n\nexport function ProjectsGrid({ endpoint, userId, getToken }: { endpoint: string; userId: string; getToken: () => string | null }) {\n const config = useMemo(\n () => ({ endpoint, auth: { mode: 'embedded', getToken, getIdentityKey: () => userId }, onAuthError: signOut }),\n [endpoint, getToken, userId],\n );\n return (\n \n
\n \n
\n
\n );\n}\n```\n`tableName` is a table's `name` in `_meta`, its GraphQL type name. To let people pick one, call `useSheetsMeta()` inside the provider, pass `data?._meta?.tables ?? []` to `selectConsoleDataTables` from `@constructive-io/data`, and give the names to ``.\n\n`SheetsProvider` takes `config: SheetsConfig`; keep it referentially stable.\n- `endpoint` (the app GraphQL endpoint) and `auth` are required. `auth` is `{ mode: 'embedded', getToken: () => string | null, getIdentityKey?: () => string | null }`, where the identity key is a non-secret user id that keeps each user's cache apart, or `{ mode: 'standalone' }` with `authEndpoint` and the grid wrapped in `SheetsAuthGate` from `@/components/ui/sheets/auth`.\n- Optional: `databaseId`, `locale` (default en-US), `fieldTypeOverrides: Record`, `upload: { bucketKey? }` (default \"public\"), `queryClient` (reuse yours), `onAuthError()` (on a 401, before the error throws), `onError(error, { source?: 'grid' | 'editor' | 'mutation' | 'upload', tableName? })`, `logger`, `plugins`.\n- To replace the transport: `execute(document, variables?)`, `executeUpload(file, path?, options?) => Promise<{ url }>`, or a whole `adapter: SheetsBackendAdapter` (`fetchMeta`, `listRows`, `createRow`, `updateRow`, `deleteRow`).\n\n`Sheets` props:\n- `tableName` (required), `pageSize` (default 100), `showSelection` and `showPagination` (default true), `infiniteScroll` (default false), `className`.\n- Observers only, since the grid does its own writes: `onRowSelect(rows)`, `onCellEdit(id, field, value)` once an edit is saved (`id` is the primary key, or an object for a composite key), and `onEvent({ type, tableName, at, meta? })` with `type` load:success | load:error | cell:edit | row:create | row:delete | filter:apply | sort:change | page:change.\n- `slots: { toolbar?, empty?, error?({ error, retry }), loading? }` replace those regions; `cellTypes` and `cellSlots` customize cells; `commands`, `keymap`, `interceptors`, and `onCommand(event, result)` extend or observe grid commands; `theme` and `themeMode` ('light' | 'dark' | 'system') style it.\n- A `ref` gets `SheetsHandle`: `scrollToRow(index)`, `submitDrafts()`, `refetch()`, `getSelectedRows()`, `exportCsv({ columns?, filename? })`.", "categories": [ "blocks", "data" diff --git a/packages/sheets/scripts/build-registry.ts b/packages/sheets/scripts/build-registry.ts index 06160462..f68120ab 100644 --- a/packages/sheets/scripts/build-registry.ts +++ b/packages/sheets/scripts/build-registry.ts @@ -16,7 +16,7 @@ buildSourceRegistry({ title: 'Sheets', description: 'A metadata-driven, spreadsheet-grade CRUD grid with accessible DOM cells and adapter-owned data access.', categories: ['blocks', 'data'], - docs: "Install the complete source-owned grid with `pnpm dlx shadcn@latest add @constructive/sheets`, then import `Sheets`, `SheetsProvider`, and the adapter contracts from `@/components/ui/sheets`. The copied source depends on `@constructive-io/data` for the current `_meta` contract and runtime GraphQL operations; your host remains responsible for its endpoint, session, and RLS-aware execution boundary.", + docs: "Import from `@/components/ui/sheets`. A metadata-driven CRUD grid: it reads a table's columns from the endpoint's `_meta`, then loads, edits, creates, and deletes rows itself over GraphQL with the host's session. There's no `data` prop and no save callback. The host owns the endpoint, the session token, and auth errors, and PostgreSQL privileges and RLS at the endpoint decide every read and write.\n\nRender it in a client component. `Sheets` fills its parent, so give the parent a bounded height, and mount `` from `@/components/ui/portal` once as the last child of `` for the overlay editors:\n```tsx\n'use client';\nimport { useMemo } from 'react';\nimport { Sheets, SheetsProvider, type SheetsConfig } from '@/components/ui/sheets';\n\nexport function ProjectsGrid({ endpoint, userId, getToken }: { endpoint: string; userId: string; getToken: () => string | null }) {\n const config = useMemo(\n () => ({ endpoint, auth: { mode: 'embedded', getToken, getIdentityKey: () => userId }, onAuthError: signOut }),\n [endpoint, getToken, userId],\n );\n return (\n \n
\n \n
\n
\n );\n}\n```\n`tableName` is a table's `name` in `_meta`, its GraphQL type name. To let people pick one, call `useSheetsMeta()` inside the provider, pass `data?._meta?.tables ?? []` to `selectConsoleDataTables` from `@constructive-io/data`, and give the names to ``.\n\n`SheetsProvider` takes `config: SheetsConfig`; keep it referentially stable.\n- `endpoint` (the app GraphQL endpoint) and `auth` are required. `auth` is `{ mode: 'embedded', getToken: () => string | null, getIdentityKey?: () => string | null }`, where the identity key is a non-secret user id that keeps each user's cache apart, or `{ mode: 'standalone' }` with `authEndpoint` and the grid wrapped in `SheetsAuthGate` from `@/components/ui/sheets/auth`.\n- Optional: `databaseId`, `locale` (default en-US), `fieldTypeOverrides: Record`, `upload: { bucketKey? }` (default \"public\"), `queryClient` (reuse yours), `onAuthError()` (on a 401, before the error throws), `onError(error, { source?: 'grid' | 'editor' | 'mutation' | 'upload', tableName? })`, `logger`, `plugins`.\n- To replace the transport: `execute(document, variables?)`, `executeUpload(file, path?, options?) => Promise<{ url }>`, or a whole `adapter: SheetsBackendAdapter` (`fetchMeta`, `listRows`, `createRow`, `updateRow`, `deleteRow`).\n\n`Sheets` props:\n- `tableName` (required), `pageSize` (default 100), `showSelection` and `showPagination` (default true), `infiniteScroll` (default false), `className`.\n- Observers only, since the grid does its own writes: `onRowSelect(rows)`, `onCellEdit(id, field, value)` once an edit is saved (`id` is the primary key, or an object for a composite key), and `onEvent({ type, tableName, at, meta? })` with `type` load:success | load:error | cell:edit | row:create | row:delete | filter:apply | sort:change | page:change.\n- `slots: { toolbar?, empty?, error?({ error, retry }), loading? }` replace those regions; `cellTypes` and `cellSlots` customize cells; `commands`, `keymap`, `interceptors`, and `onCommand(event, result)` extend or observe grid commands; `theme` and `themeMode` ('light' | 'dark' | 'system') style it.\n- A `ref` gets `SheetsHandle`: `scrollToRow(index)`, `submitDrafts()`, `refetch()`, `getSelectedRows()`, `exportCsv({ columns?, filename? })`.", }, registrySubdirectory: 'blocks/sheets', targetPrefix: '@ui/sheets', diff --git a/packages/ui/package.json b/packages/ui/package.json index f0cef7d3..05c63c52 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -849,6 +849,7 @@ "@storybook/addon-a11y": "^10.1.11", "@storybook/react-vite": "^10.1.11", "@tailwindcss/postcss": "^4.1.18", + "@tanstack/react-query": "^5.90.16", "@types/node": "^20.19.27", "@types/react": "^19.3.0", "@types/react-dom": "^19.2.3", diff --git a/packages/ui/registry.json b/packages/ui/registry.json index 85522ea3..aa008f99 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -3231,7 +3231,7 @@ "block", "application" ], - "docs": "Import: `import { OrgChart } from '@/components/ui/org-chart'`. Pass controlled `edges` or `defaultEdges` (flat `{ id, parentId }` reporting lines) and persist moves in `onReparent`. Tab into the chart and use the arrow keys to move between people; Enter opens their details.", + "docs": "Import from `@/components/ui/org-chart`. Lays out flat reporting lines as a top-down tree on a pannable, zoomable canvas. The chart owns layout, the viewport, folding, selection, and its details panel; the host owns the people and persists every move, edit, and removal through callbacks.\n\nThe canvas is 600px tall by default; pass `className=\"h-full\"` inside a bounded parent to fill it instead:\n```tsx\n'use client';\nimport { OrgChart, type OrgChartEdge } from '@/components/ui/org-chart';\n\nconst edges: OrgChartEdge[] = [\n { id: 'alex', parentId: null, displayName: 'Alex Morgan', positionTitle: 'CEO', avatarUrl: null },\n { id: 'sam', parentId: 'alex', displayName: 'Sam Rivera', positionTitle: 'VP Product', avatarUrl: null },\n];\n\n
\n saveManager(childId, newParentId)}\n onEditNode={(person) => openPositionEditor(person.id)}\n onRemoveNode={(person) => confirmRemoval(person.id)}\n />\n
\n```\nEvery `OrgChartEdge` key is required: `id`, `parentId` (null for a root), and the nullable `displayName`, `avatarUrl`, and `positionTitle`. Anyone whose manager is missing becomes a root and a cycle is broken, so bad data still shows everyone once.\n\n- Pass exactly one of `edges` or `defaultEdges`. With `edges` the chart is controlled and only changes when you pass new edges, so update them after `onReparent`. With `defaultEdges` it moves the card at once and moves it back if `onReparent` throws or rejects; new `defaultEdges` content resets it.\n- `onReparent(childId, newParentId, preserve: { positionTitle?: string | null }) => void | Promise` fires on a drop onto a new manager or from the Change manager dialog. Moving someone under their own report is refused before it fires.\n- `onReparentSuccess(childName, parentName)` and `onReparentError(message)` are for your toasts; the chart shows none.\n- `onEditNode(node)` and `onRemoveNode(node)` add Edit position and Remove from chart to the card menu and details panel, and receive an `OrgChartNodeData`: the edge plus `childCount`, `isRoot`, and `isCompact`. Removal only reports intent, so confirm before changing data.\n- `onAddToChart()` adds an \"Add first person\" button to the empty state shown when there are no edges.\n- `editable` (default true) enables dragging and the card menu; `isLoading` shows a loading state; `showDetails` (default true) shows the details panel; `onSelectNode(node | null)` reports selection so you can render your own panel; `defaultCollapsedIds` folds those people's teams at first render.\n\nTab into the chart and use the arrow keys to move between people; Enter opens their details.", "dependencies": [ "lucide-react" ], @@ -3518,7 +3518,7 @@ "block", "application" ], - "docs": "Import: `import { StorageBrowser } from '@/components/ui/storage'`", + "docs": "Import from `@/components/ui/storage`. `StorageBrowser` is fully controlled: it shows buckets in a sidebar and the open bucket's files with search, sort, selection, and drop-to-upload, while the host owns the buckets, objects, selection, query, sort, folder path, uploads, and every action. It fetches nothing, checks no permissions, and doesn't filter or sort `objects`, so pass them already matched to `query` and ordered by `sort`.\n\nIt fills its parent, so give the parent a bounded height:\n```tsx\n'use client';\nimport { useState } from 'react';\nimport { StorageBrowser, type ObjectSort, type StorageBucket, type StorageObject } from '@/components/ui/storage';\n\nconst buckets: StorageBucket[] = [{ id: 'b1', key: 'avatars', visibility: 'public', isPublic: true, allowCustomKeys: false }];\nconst objects: StorageObject[] = [{ id: 'o1', bucketId: 'b1', key: 'a.png', filename: 'a.png', mimeType: 'image/png', size: 20480, isPublic: true, createdAt: '2026-10-01T09:00:00Z' }];\n\nexport function Files() {\n const [bucketId, setBucketId] = useState('b1');\n const [selectedIds, setSelectedIds] = useState([]);\n const [sort, setSort] = useState({ column: 'createdAt', direction: 'desc' });\n const [query, setQuery] = useState('');\n return (\n
\n o.bucketId === bucketId && (o.filename ?? o.key).includes(query))}\n selectedIds={selectedIds} onSelectionChange={setSelectedIds}\n sort={sort} onSortChange={setSort} query={query} onQueryChange={setQuery}\n onOpenObject={openDetails} onUpload={pickFiles} onDropFiles={upload}\n onDownload={download} onDelete={(o) => remove([o.id])} onBulkDelete={remove}\n />\n
\n );\n}\n```\n`sort` is `{ column: 'filename' | 'size' | 'createdAt' | 'mimeType', direction: 'asc' | 'desc' }`.\n\n- `selectedBucketId` (string | null): with none chosen the pane asks for a bucket. `onNewBucket()`, `busyBucketId` (spinner on that row), and `bucketsSelectable` (default true) shape the sidebar.\n- Row actions, each `(object) => void` and hidden when omitted: `onOpenObject`, `onDownload`, `onCopyLink`, `onRename`, `onDelete`.\n- Folders: rows with `kind: 'folder'` open through `onOpenFolder(object)`; `segments: { label, path }[]` adds a breadcrumb, and `onNavigate(path | null)` gets null for the bucket root.\n- Upload: `onUpload()` adds the header button, `isUploading` disables it, and `onDropFiles(files: FileList)` makes the list a drop target.\n- Selection: `selectable` (default true), `onBulkDelete(ids)`, `bulkDeleteProgress: { done, total, failed: string[] }`, `onClearSelection()`.\n- States: `isLoading`, `emptyLabel` (empty-list text, e.g. no matches), and `emptyState`: `'empty-bucket'` keeps the header, while `'no-buckets' | 'no-access' | 'not-provisioned'` fill the pane; its buttons call `onEmptyStateAction` / `onEmptyStateSecondaryAction`.\n- `title` (default \"Storage\"), `defaultSidebarCollapsed`, `framed`, `locale`, `className`.\n\nRender these beside it: `ObjectDetailSheet` (`object: StorageObject | null`, `open`, `onOpenChange(open)`, `onDownload?`, `onCopyLink?`, `onRename?(id, newName)`, `onDelete?(id)`; image previews read `object.downloadUrl`), `BucketConfigSheet` (`mode: 'create' | 'edit'`, `initial?`, `open`, `onOpenChange`, `onSubmit(value)` with the bucket's editable fields), and `UploadDropzone` (`onFiles(files)`, `uploads?: { id, filename, size, progress, status: 'queued' | 'uploading' | 'done' | 'error' }[]`, `onCancel?(id)`).", "registryDependencies": [ "storage", "storage-bucket-rail", @@ -3551,7 +3551,7 @@ "block", "application" ], - "docs": "Import: `import { WorkspaceShell, SidebarFrame, NavSection, NavRow, ViewFrame, Panel, CanvasSurface, useCanvasViewport } from '@/components/ui/workspace-kit'`. Render your sidebar through the `sidebar` render prop (it is called once for the rail and once for the drawer) and your views as children; `ViewFrame` and `NavMenuButton` open the drawer below the `3xl` container width.", + "docs": "Import everything from `@/components/ui/workspace-kit`. The shell, views, and surfaces the application templates are built from; all presentational, so the host owns data, routing, and state. Views size themselves to the container they get (container queries), not the viewport.\n\nApp shell with a sidebar and one view:\n```tsx\nimport { Inbox, Rows3 } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { NavIcon, NavRow, NavSection, Panel, SidebarFrame, ToneBadge, ViewFrame, WorkspaceShell } from '@/components/ui/workspace-kit';\n\n
\n (\n Acme} collapsed={collapsed} onCollapsedChange={onCollapsedChange} drawer={mode === 'drawer'} onClose={onNavigate}>\n \n } onClick={onNavigate} />\n \n \n )}\n >\n New order}>\n …\n \n \n
\n```\nThe `sidebar` render prop is called twice: as the rail (from the `3xl` container width) and inside the mobile drawer; call `onNavigate` after a choice so the drawer closes.\n\nShell and navigation:\n- `WorkspaceShell` (`sidebar(props)`, children, `defaultSidebarCollapsed`, `framed` for a rounded app window, `slot`): fills its parent, so give the parent a height.\n- `SidebarFrame` (`label`, `menu` (top-left switcher), `collapsed`, `onCollapsedChange`, `drawer`, `onClose`, `footer`); `NavSection` (`title`, `collapsed`, `action`); `NavRow` (`label`, `collapsed`, `active`, `leading`, `trailing`, `href` + `renderLink(props)` for your router's link, or `onClick`; `busy`, `disabled`, `muted`); `NavIcon` (`icon`, `active`); `NavCount` (`value`, `tone`: muted | warning | danger); `RailTip` labels a rail control.\n- `SwitcherTrigger` (`label`, `name`, `glyph`, `collapsed`) and `monogram(name)` build the top-left switcher; `AppearanceRow` (`value`, `onChange`: light | dark | system) is the theme row for a menu.\n\nViews:\n- `ViewFrame` (`icon`, `title`, `actions`, `label`, `maxWidth` default `max-w-6xl`): a 48px header over a scrolling, centred column. `ViewHeader` (`icon`, `title`, children = actions, `divider`) is the header alone; `NavMenuButton` opens the drawer on narrow widths and is already in both.\n- `SectionHeading` (`title`, `description`, `actions`, `id`) separates groups of panels in a view.\n- `SearchField` (`label` (visually hidden), `trailing`, `inputRef`, plus input props such as `value`, `onChange`, `placeholder`): the compact toolbar search.\n- `FilterGroup` (`label`, `value`, `options: { value, label, count?, icon? }[]`, `onChange`, `compact`): single-choice filter pills that scroll sideways when narrow, e.g. ``.\n- `Segmented` (`label`, `value`, `options: { value, label, icon? }[]`, `onChange`): a two-to-four option switch, e.g. ``.\n- `TooltipIconButton` (`label` (required, also the accessible name), `side`, `variant`: ghost | raised, `size`: sm | md | lg, `onClick`, icon as children).\n\nSurfaces:\n- `Panel` (`title`, `description`, `actions`, `footer`, `bodyClassName` (`p-0` for edge-to-edge content)): the standard card.\n- `TableSurface` (`minWidth` default `40rem`, below which it scrolls sideways): wraps table rows; style them with `tableHeadClass`, `tableRowClass`, and `tableGroupRowClass`, e.g. `NameAda`.\n- `StatTile` (`label`, `value`, `hint`, `icon`): a label over a large figure; lay several in a grid inside a `Panel` or `Bezel`.\n- `KeyValueList` (`items: { label, value, key? }[]`): label-value rows with dashed rules.\n- `EmptyState` (`icon` (required), `title`, `description`, `action`): a centred empty, no-match, or error state.\n- `ToneBadge` (`tone`: neutral | primary | info | success | warning | danger | violet | amber, children, `onClick` makes it a button), e.g. `Paid`; `StatusBadge` (`presentation: { label, tone }`) maps a status to one; `TONE_COLOR` gives each tone's CSS colour.\n- `IconTile` (`icon`, `tone`): a tinted glyph tile for rows and notices. `Bezel` (`muted`, `innerClassName`): a double-framed card for highlighted content.\n- `MiddleTruncate` (`text`, `tail` (characters kept at the end), `title`): shortens long names and keys in the middle, keeping their ending.\n\nHelpers: `formatCount(n, locale?)`, `plural(n, { one, other })`, `nameList(names, { max })`; `useScrollFade()` fades the edges of a sideways scroller; `focusRingClass`, `pressClass`, `hitAreaClass`, `dashedRule`, `nativeSelectClass`, `scrollRowClass`, `surfaceInsetClass` + `SurfaceBody` are the shared class names; `enterClass` + `staggerStyle(i)` stagger entrances; `useReducedMotion()`; `ViewAnimation` and `startViewTransition(update, ...types)` animate view swaps where supported (React 19.3) and fall back to an instant swap.\n\nCanvas (pan and zoom diagrams): `useCanvasViewport(contentWidth, { fit, contentHeight, minZoom, maxZoom })` returns the viewport for `CanvasSurface` (`viewport`, `label`, children in canvas units, `overlay`, `onPaneClick`) and `CanvasZoomControls` (`viewport`, `fit`); draw nodes with `NodeShell` (`tone`: plain | new | target, `selected`) and `NodeIconTile`, and links with `CanvasWire` (`start` and `end` as `[x, y]`, `tone`: rest | live | warn, `axis`, `animated`) or `wirePath(start, end)`.", "dependencies": [ "lucide-react", "motion@^13.4.0", @@ -3623,7 +3623,7 @@ "application", "ai" ], - "docs": "Import: `import { AgentsBuilder, AGENTS_BUILDER_DEMO } from '@/components/ui/agents-builder'`. Pass your own `data` and handle side effects through `onAction`, `onConnectIntegration`, and `onAuthorizeIntegration`.", + "docs": "Import from `@/components/ui/agents-builder`. A complete agent workspace (chat, inbox, schedules, integrations, skills, and an agent view with a live run) that renders only what `data` holds: the block owns navigation between its views, the connect dialog, and scripted playback; the host owns the data, routing, OAuth, persistence, and every action it reports.\n\nIt fills its parent, so give the parent a bounded height:\n```tsx\n'use client';\nimport { AGENTS_BUILDER_DEMO, AgentsBuilder, type AgentsBuilderData } from '@/components/ui/agents-builder';\n\n// The type is large: spread the demo and replace what you have.\nconst data: AgentsBuilderData = {\n ...AGENTS_BUILDER_DEMO,\n workspace: { name: 'Acme', appName: 'Acme' },\n agents: [{ id: 'revenue-analyst', name: 'Revenue Analyst', running: true }],\n};\n\n
\n startOAuth(integration.id)}\n onConnectIntegration={(integration, toolIds) => saveConnection(integration.id, toolIds)}\n onAction={(action) => {\n if (action.type === 'open-agent') router.push(`/agents/${action.agentId}`);\n }}\n />\n
\n```\n`data` holds `workspace` (`{ name, appName }`), `models`, `integrations` (`{ id, name, description, vendor, authDomain, connected, tools: { id, name, access: 'read' | 'write' }[] }`), `categories`, `featuredIntegrationIds`, `agents` (`{ id, name, running? }`), `inbox`, `schedules`, `clock`, `skills`, `starterPacks`, `recommendations`, `fallbackReply`, `followUpReply`, `agent` (the agent view's blueprint), `run`, `heroHighlights`, and `usage: { percent }`.\n\nChat is scripted and sends nothing to the host: a recommendation plays its `reply`, a first typed message plays `fallbackReply`, and later ones play `followUpReply`. For a chat answered by a live model, build it from the `ai` item's transcript and composer instead.\n\n- `view` / `defaultView` / `onViewChange(view)`: `'chat' | 'inbox' | 'schedules' | 'integrations' | 'skills' | 'agent'`, default `'chat'`.\n- `onAuthorizeIntegration(integration) => Promise` runs the connect dialog's sign-in step: resolve to move on to choosing tools, reject to go back. Without it the dialog waits a moment and moves on.\n- `onConnectIntegration(integration, toolIds: string[]) => void | Promise` saves the connection; reject to keep the dialog open. Once it resolves the integration shows as connected (tracked locally, seeded from each `connected` flag).\n- `onAction(action)` gets every control the block doesn't own: `navigate { target: 'usage' | 'settings' | 'more-agents' }`, `open-agent { agentId }` (`data.agent.id` opens the built-in view instead), `new-agent`, `search { query }`, `workspace-menu { item: 'organization' | 'profile' | 'support' | 'whats-new' | 'log-out' }`, `workspace-mode { mode: 'agents' | 'workbench' }`, `chat-history`, `attach-file { surface: 'chat' | 'run' }`, `send-follow-up { surface: 'run', text }`, `stop-run`, `approve-run { agentId, approved }`, `send-reply { threadId, text, drafted }`, `approve-thread-action { threadId }`, `new-schedule`, `toggle-schedule { scheduleId, active }`, `run-schedule { scheduleId }`, `share-agent` / `edit-instructions` / `set-identity { agentId }`, `request-app`, `manage-integration { integrationId }`, `create-skill`, `browse-skill-library`, `add-starter-pack { packId }`, `open-skill { skillId }`, `create-agent { draft: { name, schedule, integrationIds, steps } }`.\n- `theme` / `onThemeChange` (`'light' | 'dark' | 'system'`) only drive the menu's appearance switch; apply the theme yourself.\n- `defaultSidebarCollapsed`, `framed` (a rounded window on a canvas), `autoplayRun` (default true; false opens the run settled), `className`.", "dependencies": [ "lucide-react", "motion@^13.4.0" @@ -3786,7 +3786,7 @@ "block", "application" ], - "docs": "Import: `import { DataRooms, DATA_ROOMS_DEMO } from '@/components/ui/data-rooms'`. Pass your workspace as `data`, apply every change through `onCommand` (reject to keep a dialog open), and handle downloads and view logging through `onAction`. `effectiveAccess` explains who can do what; your server still decides every request.", + "docs": "Import from `@/components/ui/data-rooms`. Document rooms for an organization and its sub-units, driven by one `data` object: the block owns navigation, dialogs, the viewer, and applying changes you accept; the host owns storage, file delivery, authorization, and the activity log. Its access rules only pick which controls appear; your server decides every request.\n\nIt fills its parent, so give the parent a bounded height:\n```tsx\n'use client';\nimport { DataRooms, type DataRoomsData } from '@/components/ui/data-rooms';\n\nconst at = '2026-09-30T10:00:00Z';\nconst data: DataRoomsData = {\n clock: '2026-10-01T09:00:00Z', // \"now\" for dates and expiries\n org: { name: 'Acme' },\n viewerId: 'ada',\n people: [{ id: 'ada', name: 'Ada Park', email: 'ada@acme.com' }],\n units: [{ id: 'acme', parentId: null, name: 'Acme', kind: 'Company' }], // exactly one root\n roles: [{ id: 'viewer', name: 'Viewer', permissions: ['view', 'download'], isDefault: true }],\n memberships: [{ id: 'm1', personId: 'ada', scope: { kind: 'unit', id: 'acme' }, owner: true, status: 'active' }],\n inheritance: { owners: true, admins: true, members: false, allowGuests: true },\n rooms: [{ id: 'board', unitId: 'acme', name: 'Board pack', status: 'active', createdAt: at,\n policies: { viewOnly: false, watermark: true, requireAgreement: false, restricted: false } }],\n documents: [{ id: 'q3', roomId: 'board', folder: '/Financials', name: 'Q3.pdf', kind: 'pdf', size: 482000,\n uploadedBy: 'ada', updatedAt: at, versions: [{ id: 'v1', number: 1, uploadedBy: 'ada', uploadedAt: at, size: 482000 }] }],\n shares: [], invites: [], acceptances: [], activity: [], questions: [],\n};\n\n
\n save(command)}\n onAction={(action) => track(action)}\n />\n
\n```\n`DATA_ROOMS_DEMO` is a complete example.\n\n- `onCommand(command) => Promise | void` makes every change: resolve once saved and the block applies it until you pass new `data`, or reject with an `Error` to keep the dialog open with its message. It isn't called while previewing as someone else. Types: `create-room { room }`, `update-room { roomId, patch }`, `invite { roomId, invites: { email, personId? }[], roleId, expiresAt?, message? }`, `revoke-invite { inviteId }`, `update-membership { membershipId, patch }`, `remove-membership { membershipId }`, `create-share { roomId, folder, personIds, can: { view, edit, delete }, expiresAt? }`, `revoke-share { shareId }`, `upload { roomId, folder, files: { name, size, kind, pages? }[] }` (metadata only, no bytes), `create-folder { roomId, path }`, `move-documents { documentIds, folder }`, `update-document { documentId, patch: { restrictions } }`, `update-inheritance { inheritance }`, `save-role { role }`, `accept-agreement { roomId, version }`, `ask-question { roomId, documentId?, title, body }`, `answer-question { questionId, body, close? }`, `set-question-status { questionId, status }`.\n- `onAction(action)`: `download { documentId, versionId? }`, `view-document { documentId }` (for your access log), `export-activity { roomId? }`, `search { query }`, `contact { personId }`, `org-menu { item: 'organization-settings' | 'profile' | 'support' | 'log-out' }`.\n- `onAskAssistant({ roomId, prompt, viewerId, documentId? })` resolves to `{ text, citations: { documentId, page?, quote? }[] }`; cite only what the asker can open. Without it, `data.assistant` answers.\n- `renderPreview(document, { viewer, watermark, viewOnly }) => ReactNode` draws viewer pages instead of placeholders.\n- `view` (`'home' | 'access-map' | 'activity' | 'room'`), `roomId`, `tab` (`'documents' | 'people' | 'questions' | 'insights' | 'settings'`), and `previewAs` (person id or null) each have a `default…` prop and an `on…Change`; `views` limits navigation.\n- `theme` / `onThemeChange` (menu switch only), `defaultSidebarCollapsed`, `framed`, `locale` / `timeZone` (default en-US, UTC), `className`.", "dependencies": [ "lucide-react", "@base-ui/react" @@ -4231,6 +4231,260 @@ } ] }, + { + "name": "resource-kit", + "type": "registry:block", + "title": "Resource Kit", + "description": "List, detail, and form screens generated from resource definitions: a view with search, sort, pagination, and empty and error states; a table that formats cells by field, shows option badges and relation labels, and folds columns in narrow panes; a create and edit sheet with validation, async relation pickers, and many-to-many links; a record view with has-many lists; and delete confirmation. Backend-neutral: data goes through an injected source, routing through an injected router.", + "categories": [ + "block", + "application" + ], + "docs": "Import: `import { ResourceKitProvider, ResourcePage, RecordDetail, defineResource } from '@/components/ui/resource-kit'`. Describe each table with `defineResource({ name, label, singular, primaryKey, titleField, columns, formFields, fields, source })`, where `source` implements `list`, `get`, `create`, `update`, `remove`, and optionally `link` and `linked` for many-to-many fields. Wrap the app once in `` (it reuses an enclosing QueryClient or makes its own), then render `` on the list route and `` on the detail route. Writes invalidate the resource and every related one. `DataTable`, `EntityFormSheet`, `RelationPicker`, `DeleteDialog`, `ResourceNav`, and `createMemorySource` are exported for custom screens and prototypes.", + "dependencies": [ + "@tanstack/react-query@^5", + "lucide-react" + ], + "registryDependencies": [ + "cn", + "intl", + "use-debounce", + "alert", + "alert-dialog", + "button", + "combobox", + "dropdown-menu", + "field", + "input", + "input-group", + "select", + "sheet", + "spinner", + "switch", + "textarea", + "workspace-kit" + ], + "files": [ + { + "path": "registry/constructive/blocks/resource-kit/context.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/data-table.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/delete-dialog.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/demo.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/entity-form-sheet.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/entity-form.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/failures.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/field-control.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/field-value.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/fields.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/form-values.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/format.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/index.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/labels.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/list-controls.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/memory-router.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/memory-source.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/queries.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/resource-kit/record-detail.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/record-dialogs.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/related-list.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/relation-picker.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/resource-nav.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/resource-page.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/resource-kit/types.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "auth-pages", + "type": "registry:block", + "title": "Auth Pages", + "description": "Public account screens for an app: sign-in, sign-up, check-email, verify-email, forgot-password, and reset-password panels and a centred page layout, with field validation, pending and failure states, and injected handlers that answer `{ ok: true }` or `{ ok: false, message }`, so they work with any router and any auth backend.", + "categories": [ + "block", + "application" + ], + "docs": "Import: `import { AuthLayout, SignInPanel, SignUpPanel, VerifyEmailPanel } from '@/components/ui/auth-pages'`. Render one panel per route inside `AuthLayout`, pass your auth calls as handlers that return `{ ok: true }` or `{ ok: false, message }`, and pass your router's link once as `linkComponent` on the layout. Navigate after sign-in succeeds yourself; sign-up never expects a session and shows the check-email state instead.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "alert", + "button", + "card", + "checkbox", + "field", + "input", + "input-group", + "spinner", + "workspace-kit" + ], + "files": [ + { + "path": "registry/constructive/blocks/auth-pages/auth-card.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/auth-layout.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/check-email-panel.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/forgot-password-panel.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/index.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/auth-pages/password-input.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/reset-password-panel.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/sign-in-panel.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/sign-up-panel.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/auth-pages/types.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/auth-pages/use-auth-form.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/auth-pages/verify-email-panel.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "dashboard-tiles", + "type": "registry:block", + "title": "Dashboard Tiles", + "description": "Dashboard pieces for a resource-kit app: a stat tile on its own card that can link to its source, a count tile that reads a resource total (optionally filtered), the latest records of a resource, and a grid that lays them out by its own width.", + "categories": [ + "block", + "application" + ], + "docs": "Import: `import { DashboardGrid, ResourceCountTile, RecentRecords, StatTile } from '@/components/ui/dashboard-tiles'`. Render inside `ResourceKitProvider`: ``. Count tiles read `totalCount` from one-row `list` calls; `RecentRecords` follows the resource's `defaultSort` (or `createdAt` descending when there is none); pass `sort` to choose. `StatTile` also works without the provider.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "cn", + "intl", + "resource-kit", + "workspace-kit" + ], + "files": [ + { + "path": "registry/constructive/blocks/dashboard-tiles/dashboard-grid.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/dashboard-tiles/index.ts", + "type": "registry:lib" + }, + { + "path": "registry/constructive/blocks/dashboard-tiles/recent-records.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/dashboard-tiles/resource-count-tile.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/dashboard-tiles/stat-tile.tsx", + "type": "registry:component" + } + ] + }, { "name": "stack", "type": "registry:block", @@ -4391,12 +4645,12 @@ "name": "ai", "type": "registry:block", "title": "AI Kit", - "description": "Presentational primitives for AI and agentic chat UIs: composer, messages, reasoning, tools, HITL approval, plan tracking, and streaming surfaces.", + "description": "Presentational primitives for AI chat and agent UIs: a chat transcript of user and assistant messages in a conversation thread, with markdown and streaming, a composer (prompt input), conversation scrolling, reasoning, tool calls, human-in-the-loop approval, plan tracking, sources, and streaming surfaces for an assistant or agent conversation.", "categories": [ "block", "ai" ], - "docs": "Import PromptInput, Message, Tool, Reasoning, and PlanTracker from the installed ai directory under your configured shadcn ui alias.", + "docs": "Import from `@/components/ui/ai` (the `ai` directory under your shadcn ui alias). Presentational only: the host owns the messages, streaming state, tool results, and model calls, and passes them in as props; there is no data hook.\n\nTranscript and composer, in a flex column with a bounded height (ChatContainer scrolls itself and sticks to the bottom while new content arrives, so give it `min-h-0 flex-1`; ScrollButton must sit inside it):\n```tsx\nimport { ArrowUp } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { ChatContainer, ChatContainerContent, Message, MessageAvatar, MessageContent, PromptInput, PromptInputAction, PromptInputActions, PromptInputBody, PromptInputTextarea, ScrollButton } from '@/components/ui/ai';\n\n
\n \n \n {messages.map((m) => (\n \n {m.role === 'assistant' ? : null}\n {m.text}\n \n ))}\n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n```\nPromptInput is controlled with `value`/`onValueChange` (uncontrolled without them); Enter calls `onSubmit`, Shift+Enter inserts a newline, and the textarea grows to `maxHeight` (240px). `isLoading` or `disabled` disables the textarea; render your own Stop button in PromptInputActions while a run streams. MessageContent renders plain text by default; `markdown` renders markdown with code blocks, and `streaming` throttles those re-renders while tokens arrive. MessageActions/MessageAction (`tooltip`) hold per-message buttons; FeedbackBar (`onCopy`, `onRegenerate`, `onFeedback`) is a ready-made row of them. SystemMessage (`title`) is a centred notice in the transcript.\n\nOther families, each a controlled view of host state:\n- Tool (`name`, `status`: pending | running | success | error | aborted, or the input-/output- stream statuses; `summary`, `input`, `output`, `errorText`), grouped in ToolGroup (`label`). ToolTrace (`title`, `steps`) is a compact multi-step trace.\n- Reasoning (`content` or children, `isStreaming`, `durationMs`, `markdown`) is the collapsible thinking block; ThinkingBar (`text`, `onStop`) is the one-line live variant; ThinkingStatus (`label`, `elapsedSeconds`, `tokens`) shows elapsed time and tokens.\n- ThinkingTrace (`rows`, `mode`: steps | reasoning | search | coding, `isStreaming`) lists trace rows; Steps/Step (`title`, `status`: pending | running | done | error) is a collapsible checklist, also exported as ChainOfThought.\n- ApprovalCard (`title`, `description`, `questions`, `preview`, `destructive`, `onConfirm({ answers })`, `onSkip`) asks a person before a tool runs.\n- PlanTracker (`plan: { steps: { label, status: pending | in_progress | done }[] }`, `streaming`) pins the agent's plan above the composer; TaskList/TaskRow (`label`, `status`: pending | running | completed | failed, `progress`, `details`, `onRetry`) lists background tasks.\n- Sources/Source (`href`, `title`, `domain`, `favicon`) cite links; ContextCards/ContextCard (`title`, `body`, `sourceName`) show retrieved context; RecommendationCard (`body`, `confidence`, `alternatives`, `onAccept`) offers a choice.\n- MessageContent with `streaming` is the default for live replies. ResponseStream (`text`, `mode`: typewriter | fade) animates text you already have in full, e.g. a replayed reply; StreamingText (`text` or `tokens`, `streaming`, `sources`, `followUps`) is a self-contained answer with citations and follow-ups. useThrottledText throttles your own token renders.\n- PromptSuggestions/PromptSuggestion (`onClick`) offer starter prompts; FileUpload (`files`, `onFilesChange`, `variant`: dropzone | compact | button) attaches files; ContextRing (`usage: { tokens, contextWindow, percent }`) and UsageNotice (`percent`, `onAction`) show context and quota use.\n- InlineDiff (`source`) and DiffTable (`columns`, `rows`, `addedRows`) show proposed changes; CodeBlock and Markdown render code and markdown on their own; AgentLoader, Loader, and TextShimmer are loading states; AiImage shows a generated image with a fallback.", "dependencies": [ "lucide-react", "sugar-high@^1.2.1" diff --git a/packages/ui/src/components/auth-pages/auth-card.tsx b/packages/ui/src/components/auth-pages/auth-card.tsx new file mode 100644 index 00000000..c928fe21 --- /dev/null +++ b/packages/ui/src/components/auth-pages/auth-card.tsx @@ -0,0 +1,176 @@ +'use client'; + +import * as React from 'react'; +import { ArrowLeftIcon, type LucideIcon } from 'lucide-react'; + +import { cn } from '../../lib/utils'; +import { Alert, AlertDescription } from '../alert'; +import { Button, buttonVariants } from '../button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../card'; +import { Spinner } from '../spinner'; +import { focusRingClass } from '../workspace-kit/primitives'; +import type { AuthLinkComponent, AuthNotice } from './types'; + +/** The link component an `AuthLayout` or `AuthCard` hands down to the links inside it. */ +export const AuthLinkContext = React.createContext(undefined); + +export function AuthLink({ href, className, children }: { href: string; className?: string; children: React.ReactNode }) { + const LinkComponent = React.useContext(AuthLinkContext); + if (LinkComponent) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +/** A quiet footer link, such as "Forgot password?". */ +export const mutedLinkClass = cn('cursor-pointer rounded text-muted-foreground hover:text-foreground', focusRingClass); +/** The footer's call to action, such as "Create account". */ +export const primaryLinkClass = cn('cursor-pointer rounded font-medium text-primary hover:underline', focusRingClass); +/** A footer sentence with a link in it; long translations wrap instead of widening the card. */ +export const footerTextClass = 'text-muted-foreground min-w-0 [overflow-wrap:anywhere]'; + +const TONE_TILE = { + primary: 'bg-primary/10 text-primary ring-primary/20', + success: 'bg-success/10 text-success ring-success/20', + destructive: 'bg-destructive/10 text-destructive ring-destructive/20', +} as const; + +const NOTICE_VARIANT = { info: 'info', success: 'success', error: 'destructive' } as const; + +type AuthCardProps = { + icon: LucideIcon | 'spinner'; + tone?: keyof typeof TONE_TILE; + title: React.ReactNode; + description?: React.ReactNode; + notice?: AuthNotice; + footer?: React.ReactNode; + /** The panel's own link component; falls back to the one from `AuthLayout`. */ + linkComponent?: AuthLinkComponent; + /** Moves focus to the heading on mount, for a state that replaces the one the person was using. */ + focusTitle?: boolean; + className?: string; + children?: React.ReactNode; +}; + +/** The card every auth panel shares: icon tile, heading, description, notice, body, and dashed footer. */ +export function AuthCard({ + icon: Icon, + tone = 'primary', + title, + description, + notice, + footer, + linkComponent, + focusTitle = false, + className, + children, +}: AuthCardProps) { + const inherited = React.useContext(AuthLinkContext); + const titleRef = React.useRef(null); + const hasBody = Boolean(notice) || React.Children.toArray(children).length > 0; + + React.useEffect(() => { + if (focusTitle) titleRef.current?.focus(); + }, [focusTitle]); + + return ( + + + +
+ {Icon === 'spinner' ? :
+ +

+ {title} +

+
+ {description ? {description} : null} +
+ {hasBody ? ( + + {notice ? ( + + {notice.message} + + ) : null} + {children} + + ) : null} + {footer ? ( + + {footer} + + ) : null} +
+
+ ); +} + +/** The failure a handler answered with. */ +export function AuthFailure({ message }: { message?: string }) { + if (!message) return null; + return ( + + {message} + + ); +} + +/** The full-width submit button, disabled with its pending label while the handler runs. */ +export function AuthSubmit({ + pending, + label, + pendingLabel, + disabled = false, + type = 'submit', + variant, + onClick, +}: { + pending: boolean; + label: React.ReactNode; + pendingLabel: React.ReactNode; + disabled?: boolean; + type?: 'submit' | 'button'; + variant?: 'default' | 'outline'; + onClick?: () => void; +}) { + return ( + + ); +} + +/** A primary call to action that navigates, such as "Sign in" after a reset. */ +export function AuthLinkButton({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** The footer of every panel except sign-in: a ghost link back to sign in. */ +export function BackToSignIn({ href }: { href: string }) { + return ( + + + ); +} + +/** The address an email went to, kept left to right inside any sentence. */ +export function EmailAddress({ email }: { email: string }) { + return {email}; +} diff --git a/packages/ui/src/components/auth-pages/auth-layout.tsx b/packages/ui/src/components/auth-pages/auth-layout.tsx new file mode 100644 index 00000000..7b479513 --- /dev/null +++ b/packages/ui/src/components/auth-pages/auth-layout.tsx @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { focusRingClass } from '../workspace-kit/primitives'; +import { AuthLink, AuthLinkContext } from './auth-card'; +import type { AuthBrand, AuthLayoutProps } from './types'; + +const brandClass = 'flex min-w-0 max-w-full items-center justify-center gap-2.5 text-foreground'; + +function BrandMark({ brand }: { brand: AuthBrand }) { + const content = ( + <> + {brand.logo ? ( + + ) : null} + + {brand.name} + + + ); + + if (!brand.href) return
{content}
; + return ( + + {content} + + ); +} + +/** + * The page around an auth panel: a centred column on the page background with + * the brand above the card and a small muted footer below. It adds no + * landmarks, so it can sit inside the host's own `main`. + */ +export function AuthLayout({ children, brand, footer, linkComponent, className }: AuthLayoutProps) { + return ( + +
+
+ {brand ? : null} + {children} + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+
+ ); +} diff --git a/packages/ui/src/components/auth-pages/check-email-panel.tsx b/packages/ui/src/components/auth-pages/check-email-panel.tsx new file mode 100644 index 00000000..1c30d900 --- /dev/null +++ b/packages/ui/src/components/auth-pages/check-email-panel.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { MailCheckIcon } from 'lucide-react'; + +import { Alert, AlertDescription } from '../alert'; +import { AuthCard, AuthFailure, AuthSubmit, BackToSignIn, EmailAddress } from './auth-card'; +import type { CheckEmailPanelProps } from './types'; +import { useAuthForm } from './use-auth-form'; + +function checkEmailDescription(email?: string): React.ReactNode { + if (!email) return 'We sent you a confirmation link. Open it to confirm your address, then sign in with your password.'; + return ( + <> + We sent a confirmation link to . Open it to confirm your address, then sign in with your + password. + + ); +} + +/** + * Tells a new account to confirm its address, then sign in with its password. + * Sign-up renders it in place after `{ ok: true }` with `focusTitle`, so the + * new heading is announced where the form was. + */ +export function CheckEmailPanel({ + email, + onResend, + links, + title = 'Check your email', + description, + notice, + linkComponent, + className, + focusTitle = false, +}: CheckEmailPanelProps & { focusTitle?: boolean }) { + const form = useAuthForm({}); + const [resent, setResent] = React.useState(false); + const trimmed = email?.trim() || undefined; + + return ( + : null} + icon={MailCheckIcon} + linkComponent={linkComponent} + notice={notice} + title={title} + > +

+ The link can take a minute to arrive. If you do not see it, check your spam folder + {onResend ? ' or send it again.' : '.'} +

+ {resent ? ( + + + {trimmed ? ( + <> + We sent a new link to . + + ) : ( + 'We sent a new link.' + )} + + + ) : null} + + {onResend ? ( + { + setResent(false); + void form.submit({ run: onResend, onSuccess: () => setResent(true) }); + }} + pending={form.pending} + pendingLabel="Sending…" + type="button" + variant="outline" + /> + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/forgot-password-panel.tsx b/packages/ui/src/components/auth-pages/forgot-password-panel.tsx new file mode 100644 index 00000000..d9d132eb --- /dev/null +++ b/packages/ui/src/components/auth-pages/forgot-password-panel.tsx @@ -0,0 +1,87 @@ +'use client'; + +import * as React from 'react'; +import { MailCheckIcon, RotateCcwKeyIcon } from 'lucide-react'; + +import { Button } from '../button'; +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthSubmit, BackToSignIn, EmailAddress } from './auth-card'; +import { EmailInput } from './password-input'; +import type { ForgotPasswordPanelProps } from './types'; +import { emailError, useAuthForm } from './use-auth-form'; + +/** + * Asks for a password reset link. On `{ ok: true }` it says a link is on its + * way if an account exists, so the page never reveals which addresses have one. + */ +export function ForgotPasswordPanel({ + onSubmit, + links, + title = 'Reset your password', + description = 'Enter the email address for your account and we will send you a link to choose a new password.', + notice, + linkComponent, + className, +}: ForgotPasswordPanelProps) { + const [sentTo, setSentTo] = React.useState(); + const form = useAuthForm({ email: '' }); + const footer = links?.signIn ? : null; + + if (sentTo !== undefined) { + return ( + + If an account exists for , a reset link is on its way. + + } + focusTitle + footer={footer} + icon={MailCheckIcon} + linkComponent={linkComponent} + title="Check your email" + > +

+ The link can take a minute to arrive. If you do not see it, check your spam folder. +

+ +
+ ); + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = form.values.email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed) }), + run: () => onSubmit({ email: trimmed }), + onSuccess: () => setSentTo(trimmed), + focusOnFailure: 'email', + }); + }; + + return ( + +
+ + + + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/index.ts b/packages/ui/src/components/auth-pages/index.ts new file mode 100644 index 00000000..696cccfb --- /dev/null +++ b/packages/ui/src/components/auth-pages/index.ts @@ -0,0 +1,34 @@ +/** + * Auth Pages: sign-in, sign-up, check-email, verify-email, forgot-password, and + * reset-password panels with a page layout. Every panel takes injected handlers + * that answer `{ ok: true }` or `{ ok: false, message }`; nothing is fetched, + * routed, or stored here. + */ +export type { + AuthBrand, + AuthHandlerResult, + AuthLayoutProps, + AuthLinkComponent, + AuthNotice, + AuthPanelBase, + AuthResult, + CheckEmailPanelProps, + ForgotPasswordPanelProps, + ForgotPasswordValues, + ResetPasswordPanelProps, + ResetPasswordValues, + SignInPanelProps, + SignInValues, + SignUpPanelProps, + SignUpValues, + VerifyEmailPanelProps, +} from './types'; + +export { AuthLayout } from './auth-layout'; +export { SignInPanel } from './sign-in-panel'; +export { SignUpPanel } from './sign-up-panel'; +export { CheckEmailPanel } from './check-email-panel'; +export { VerifyEmailPanel } from './verify-email-panel'; +export { ForgotPasswordPanel } from './forgot-password-panel'; +export { ResetPasswordPanel } from './reset-password-panel'; +export { EmailInput, PasswordInput, type EmailInputProps, type PasswordInputProps } from './password-input'; diff --git a/packages/ui/src/components/auth-pages/password-input.tsx b/packages/ui/src/components/auth-pages/password-input.tsx new file mode 100644 index 00000000..bc31675e --- /dev/null +++ b/packages/ui/src/components/auth-pages/password-input.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import { EyeIcon, EyeOffIcon, LockKeyholeIcon, MailIcon } from 'lucide-react'; + +import { Button } from '../button'; +import type { InputProps } from '../input'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '../input-group'; + +export type PasswordInputProps = Omit; + +/** A password input with a lock icon and a show/hide toggle. */ +export const PasswordInput = React.forwardRef(function PasswordInput(props, ref) { + const [visible, setVisible] = React.useState(false); + return ( + + + + + + + + + ); +}); + +export type EmailInputProps = Omit; + +/** An email input with a mail icon; the password input's sibling on every form. */ +export const EmailInput = React.forwardRef(function EmailInput(props, ref) { + return ( + + + + + + ); +}); diff --git a/packages/ui/src/components/auth-pages/reset-password-panel.tsx b/packages/ui/src/components/auth-pages/reset-password-panel.tsx new file mode 100644 index 00000000..7a897488 --- /dev/null +++ b/packages/ui/src/components/auth-pages/reset-password-panel.tsx @@ -0,0 +1,99 @@ +'use client'; + +import * as React from 'react'; +import { CircleCheckIcon, LockKeyholeIcon } from 'lucide-react'; + +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthLinkButton, AuthSubmit, BackToSignIn } from './auth-card'; +import { PasswordInput } from './password-input'; +import type { ResetPasswordPanelProps } from './types'; +import { passwordError, passwordRule, useAuthForm } from './use-auth-form'; + +function confirmError(password: string, confirm: string): string | undefined { + if (!confirm) return 'Enter the new password again.'; + if (confirm !== password) return 'The passwords do not match.'; + return undefined; +} + +/** Sets a new password from a reset link: the password twice, then a way to sign in. */ +export function ResetPasswordPanel({ + onSubmit, + links, + passwordMinLength, + title = 'Choose a new password', + description = 'Set a new password to finish recovering your account.', + notice, + linkComponent, + className, +}: ResetPasswordPanelProps) { + const [done, setDone] = React.useState(false); + const form = useAuthForm({ password: '', confirm: '' }); + const { minLength, hint } = passwordRule(passwordMinLength); + + if (done) { + return ( + + {links?.signIn ? ( + + Sign in + + ) : null} + + ); + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const { password, confirm } = form.values; + void form.submit({ + validate: () => ({ password: passwordError(password, minLength), confirm: confirmError(password, confirm) }), + run: () => onSubmit({ password }), + onSuccess: () => setDone(true), + focusOnFailure: 'password', + }); + }; + + return ( + : null} + icon={LockKeyholeIcon} + linkComponent={linkComponent} + notice={notice} + title={title} + > +
+ + + + + + + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/sign-in-panel.tsx b/packages/ui/src/components/auth-pages/sign-in-panel.tsx new file mode 100644 index 00000000..fd8ea209 --- /dev/null +++ b/packages/ui/src/components/auth-pages/sign-in-panel.tsx @@ -0,0 +1,106 @@ +'use client'; + +import * as React from 'react'; +import { KeyRoundIcon } from 'lucide-react'; + +import { Checkbox } from '../checkbox'; +import { Field, FieldGroup, FieldLabel } from '../field'; +import { AuthCard, AuthFailure, AuthLink, AuthSubmit, footerTextClass, mutedLinkClass, primaryLinkClass } from './auth-card'; +import { EmailInput, PasswordInput } from './password-input'; +import type { SignInPanelProps } from './types'; +import { emailError, passwordError, useAuthForm } from './use-auth-form'; + +/** Email and password sign-in with a remember-me choice. The host navigates on `{ ok: true }`. */ +export function SignInPanel({ + onSubmit, + links, + defaultEmail = '', + title = 'Sign in', + description = 'Use your account to continue to this application.', + notice, + linkComponent, + className, +}: SignInPanelProps) { + const [rememberMe, setRememberMe] = React.useState(false); + // After `{ ok: true }` the host is navigating away; keep the button down so a second click cannot sign in twice. + const [signedIn, setSignedIn] = React.useState(false); + const form = useAuthForm({ email: defaultEmail, password: '' }); + const rememberId = React.useId(); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const { email, password } = form.values; + const trimmed = email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed), password: passwordError(password) }), + run: () => onSubmit({ email: trimmed, password, rememberMe }), + onSuccess: () => setSignedIn(true), + focusOnFailure: 'password', + }); + }; + + const footer = + links?.forgotPassword || links?.signUp ? ( + <> + {links.forgotPassword ? ( + + Forgot password? + + ) : ( + + )} + {links.signUp ? ( + + New here?{' '} + + Create account + + + ) : null} + + ) : null; + + return ( + +
+ + + + + + + + + setRememberMe(checked === true)} + /> + Keep me signed in on this device + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/sign-up-panel.tsx b/packages/ui/src/components/auth-pages/sign-up-panel.tsx new file mode 100644 index 00000000..2d81a577 --- /dev/null +++ b/packages/ui/src/components/auth-pages/sign-up-panel.tsx @@ -0,0 +1,86 @@ +'use client'; + +import * as React from 'react'; +import { UserRoundPlusIcon } from 'lucide-react'; + +import { Field, FieldGroup } from '../field'; +import { AuthCard, AuthFailure, AuthLink, AuthSubmit, footerTextClass, primaryLinkClass } from './auth-card'; +import { CheckEmailPanel } from './check-email-panel'; +import { EmailInput, PasswordInput } from './password-input'; +import type { SignUpPanelProps } from './types'; +import { emailError, passwordError, passwordRule, useAuthForm } from './use-auth-form'; + +/** + * Email and password sign-up. It never expects a session: on `{ ok: true }` it + * shows the check-email state for the address in place of the form. + */ +export function SignUpPanel({ + onSubmit, + links, + passwordMinLength, + title = 'Create an account', + description = 'Sign up with your email address and a password.', + notice, + linkComponent, + className, +}: SignUpPanelProps) { + const [sentTo, setSentTo] = React.useState(); + const form = useAuthForm({ email: '', password: '' }); + const { minLength, hint } = passwordRule(passwordMinLength); + + if (sentTo !== undefined) { + return ; + } + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const { email, password } = form.values; + const trimmed = email.trim(); + void form.submit({ + validate: () => ({ email: emailError(trimmed), password: passwordError(password, minLength) }), + run: () => onSubmit({ email: trimmed, password }), + onSuccess: () => setSentTo(trimmed), + focusOnFailure: 'email', + }); + }; + + const footer = links?.signIn ? ( + + Already have an account?{' '} + + Sign in + + + ) : null; + + return ( + +
+ + + + + + + + + + + +
+ ); +} diff --git a/packages/ui/src/components/auth-pages/types.ts b/packages/ui/src/components/auth-pages/types.ts new file mode 100644 index 00000000..94e6e874 --- /dev/null +++ b/packages/ui/src/components/auth-pages/types.ts @@ -0,0 +1,90 @@ +import type * as React from 'react'; + +/** What every auth handler answers. A failure message is shown to the person as written. */ +export type AuthResult = { ok: true } | { ok: false; message: string }; + +/** Handlers may answer synchronously or with a promise. */ +export type AuthHandlerResult = AuthResult | Promise; + +/** + * The link a host renders for in-app navigation. Same shape as resource-kit's + * `ResourceRouter.Link`, so one router link can be passed to both. + */ +export type AuthLinkComponent = React.ComponentType<{ + href: string; + className?: string; + children: React.ReactNode; +}>; + +/** A message the host shows above a panel, such as "Your session expired". */ +export type AuthNotice = { tone: 'info' | 'success' | 'error'; message: React.ReactNode }; + +export type AuthPanelBase = { + /** Replaces the panel heading (the first state of the panel). */ + title?: React.ReactNode; + /** Replaces the line under the heading (the first state of the panel). */ + description?: React.ReactNode; + notice?: AuthNotice; + /** Renders the panel's links. Falls back to the `AuthLayout` link, then to a plain ``. */ + linkComponent?: AuthLinkComponent; + className?: string; +}; + +export type SignInValues = { email: string; password: string; rememberMe: boolean }; + +export type SignInPanelProps = AuthPanelBase & { + onSubmit(values: SignInValues): AuthHandlerResult; + links?: { signUp?: string; forgotPassword?: string }; + defaultEmail?: string; +}; + +export type SignUpValues = { email: string; password: string }; + +export type SignUpPanelProps = AuthPanelBase & { + /** On `{ ok: true }` the panel shows the check-email state for the address, unless the host navigated away. */ + onSubmit(values: SignUpValues): AuthHandlerResult; + links?: { signIn?: string }; + /** Defaults to 8. */ + passwordMinLength?: number; +}; + +export type CheckEmailPanelProps = AuthPanelBase & { + email?: string; + /** Shows a resend button when given. */ + onResend?(): AuthHandlerResult; + links?: { signIn?: string }; +}; + +export type VerifyEmailPanelProps = AuthPanelBase & { + /** Called once when the panel mounts, and again from "Try again" after a failure. */ + onVerify(): AuthHandlerResult; + links?: { signIn?: string; continue?: string }; +}; + +export type ForgotPasswordValues = { email: string }; + +export type ForgotPasswordPanelProps = AuthPanelBase & { + /** On `{ ok: true }` the panel shows a neutral message that never says whether the account exists. */ + onSubmit(values: ForgotPasswordValues): AuthHandlerResult; + links?: { signIn?: string }; +}; + +export type ResetPasswordValues = { password: string }; + +export type ResetPasswordPanelProps = AuthPanelBase & { + onSubmit(values: ResetPasswordValues): AuthHandlerResult; + links?: { signIn?: string }; + /** Defaults to 8. */ + passwordMinLength?: number; +}; + +export type AuthBrand = { name: React.ReactNode; logo?: React.ReactNode; href?: string }; + +export type AuthLayoutProps = { + children: React.ReactNode; + brand?: AuthBrand; + footer?: React.ReactNode; + /** Used for the brand link and inherited by every panel inside the layout. */ + linkComponent?: AuthLinkComponent; + className?: string; +}; diff --git a/packages/ui/src/components/auth-pages/use-auth-form.ts b/packages/ui/src/components/auth-pages/use-auth-form.ts new file mode 100644 index 00000000..69b40805 --- /dev/null +++ b/packages/ui/src/components/auth-pages/use-auth-form.ts @@ -0,0 +1,143 @@ +'use client'; + +import * as React from 'react'; + +import type { AuthHandlerResult, AuthResult } from './types'; + +/** Shown for a handler that throws or answers with something that is not an `AuthResult`. */ +export const AUTH_GENERIC_ERROR = 'Something went wrong. Try again.'; + +export const DEFAULT_PASSWORD_MIN_LENGTH = 8; + +// A shape check only: it catches a missing @ or a stray space, and leaves the +// real decision to the server, so `name@localhost` still gets through. +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+$/; + +export type AuthFieldErrors = Partial>; + +export function emailError(email: string): string | undefined { + if (!email) return 'Enter an email address.'; + if (!EMAIL_SHAPE.test(email)) return 'Enter a valid email address, like name@example.com.'; + return undefined; +} + +export function passwordError(password: string, minLength = 1): string | undefined { + if (!password) return 'Enter a password.'; + if (password.length < minLength) return `Password must be at least ${minLength} characters.`; + return undefined; +} + +/** The minimum length for a new password (a panel's `passwordMinLength`, default 8) and the field hint that states it. */ +export function passwordRule(value: number | undefined): { minLength: number; hint?: string } { + const minLength = + value === undefined || !Number.isFinite(value) ? DEFAULT_PASSWORD_MIN_LENGTH : Math.max(1, Math.floor(value)); + return { minLength, hint: minLength > 1 ? `Use at least ${minLength} characters.` : undefined }; +} + +function failureMessage(result: unknown): string | undefined { + if (!result || typeof result !== 'object') return AUTH_GENERIC_ERROR; + const { ok, message } = result as Partial<{ ok: boolean; message: unknown }>; + if (ok === true) return undefined; + return ok === false && typeof message === 'string' && message.trim() ? message : AUTH_GENERIC_ERROR; +} + +type SubmitOptions = { + /** Field errors to show; the handler only runs when this returns none. */ + validate?: () => AuthFieldErrors; + run: () => AuthHandlerResult; + /** Runs on `{ ok: true }`, only while the panel is still mounted. */ + onSuccess?: () => void; + /** The field that takes focus when the handler fails, so the alert is read next to it. */ + focusOnFailure?: K; +}; + +/** + * Field values, submit, validation, and pending state shared by every auth + * panel: field errors wired to `aria-invalid`/`aria-describedby` with focus on + * the first invalid field (in the key order of `initial`), one call at a time, + * and a failure message for the alert. + */ +export function useAuthForm(initial: Record) { + const baseId = React.useId(); + const [values, setValues] = React.useState(initial); + const [errors, setErrors] = React.useState>({}); + const [pending, setPending] = React.useState(false); + const [failure, setFailure] = React.useState(); + const inputs = React.useRef>>({}); + const running = React.useRef(false); + const mounted = React.useRef(false); + + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const ids = (name: K) => ({ + input: `${baseId}-${name}`, + error: `${baseId}-${name}-error`, + description: `${baseId}-${name}-description`, + }); + + /** Props for the `Field` around an input. */ + const field = (name: K, description?: string) => ({ + htmlFor: ids(name).input, + error: errors[name], + errorId: ids(name).error, + description, + descriptionId: description ? ids(name).description : undefined, + }); + + /** Props for the input itself, value included; typing clears the field's error. Pass `described` when its `Field` has a description. */ + const input = (name: K, described = false) => { + const describedBy = [described && ids(name).description, errors[name] && ids(name).error].filter(Boolean).join(' '); + return { + id: ids(name).input, + ref: (node: HTMLInputElement | null) => { + inputs.current[name] = node; + }, + value: values[name], + onChange: (event: React.ChangeEvent) => { + const value = event.currentTarget.value; + setValues((current) => ({ ...current, [name]: value })); + setErrors((current) => (current[name] ? { ...current, [name]: undefined } : current)); + }, + 'aria-invalid': errors[name] ? true : undefined, + 'aria-describedby': describedBy || undefined, + }; + }; + + const submit = async ({ validate, run, onSuccess, focusOnFailure }: SubmitOptions) => { + if (running.current) return; + const next: AuthFieldErrors = validate?.() ?? {}; + setErrors(next); + setFailure(undefined); + const firstInvalid = (Object.keys(values) as K[]).find((name) => next[name]); + if (firstInvalid) { + inputs.current[firstInvalid]?.focus(); + return; + } + + running.current = true; + setPending(true); + let result: AuthResult | undefined; + try { + result = await run(); + } catch { + result = undefined; + } + running.current = false; + if (!mounted.current) return; + setPending(false); + const message = failureMessage(result); + if (message === undefined) { + onSuccess?.(); + return; + } + setFailure(message); + if (focusOnFailure) inputs.current[focusOnFailure]?.focus(); + }; + + return { values, pending, failure, field, input, submit }; +} diff --git a/packages/ui/src/components/auth-pages/verify-email-panel.tsx b/packages/ui/src/components/auth-pages/verify-email-panel.tsx new file mode 100644 index 00000000..3bd1ef8f --- /dev/null +++ b/packages/ui/src/components/auth-pages/verify-email-panel.tsx @@ -0,0 +1,108 @@ +'use client'; + +import * as React from 'react'; +import { CircleCheckIcon, MailWarningIcon, RotateCcwIcon } from 'lucide-react'; + +import { AuthCard, AuthFailure, AuthLinkButton, AuthSubmit, BackToSignIn } from './auth-card'; +import type { VerifyEmailPanelProps } from './types'; +import { useAuthForm } from './use-auth-form'; + +/** + * Confirms an email address from a verification link. It calls `onVerify` once + * on mount (StrictMode's second effect run does not call it again), then shows + * the result: a link onward, or the failure with "Try again". + */ +export function VerifyEmailPanel({ + onVerify, + links, + title = 'Verifying your email', + description = 'This takes a moment. Keep this page open.', + notice, + linkComponent, + className, +}: VerifyEmailPanelProps) { + const form = useAuthForm({}); + const [verified, setVerified] = React.useState(false); + const started = React.useRef(false); + + const verify = () => { + void form.submit({ run: onVerify, onSuccess: () => setVerified(true) }); + }; + + // Mount only: the first render's `verify` is enough, and "Try again" uses the current one. + React.useEffect(() => { + if (started.current) return; + started.current = true; + verify(); + }, []); + + const footer = links?.signIn ? : null; + const onward = links?.continue ?? links?.signIn; + + // Each state is its own card (`key`), so the result heading takes focus and is announced when it replaces the spinner. + if (verified) { + return ( + + {onward ? ( + + {links?.continue ? 'Continue' : 'Sign in'} + + ) : null} + + ); + } + + if (form.failure) { + return ( + + + + + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx b/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx new file mode 100644 index 00000000..642aa2d5 --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/dashboard-grid.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +/** + * The dashboard's layout: two tiles across on a phone or narrow pane, four + * from a 48rem container. Count tiles take one cell; `RecentRecords` takes two. + * Sized by its own width, so it works in a split pane as well as a page. + */ +export function DashboardGrid({ className, children, ...props }: React.ComponentProps<'div'>) { + return ( +
+ {/* Items keep their own height, so a count tile beside a tall list does not stretch to match it. */} +
+ {children} +
+
+ ); +} diff --git a/packages/ui/src/components/dashboard-tiles/index.ts b/packages/ui/src/components/dashboard-tiles/index.ts new file mode 100644 index 00000000..17806192 --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/index.ts @@ -0,0 +1,7 @@ +export { DashboardGrid } from './dashboard-grid'; +export { RecentRecords } from './recent-records'; +export type { RecentRecordsProps } from './recent-records'; +export { ResourceCountTile } from './resource-count-tile'; +export type { ResourceCountTileProps } from './resource-count-tile'; +export { StatTile, tileClass } from './stat-tile'; +export type { StatTileProps } from './stat-tile'; diff --git a/packages/ui/src/components/dashboard-tiles/recent-records.tsx b/packages/ui/src/components/dashboard-tiles/recent-records.tsx new file mode 100644 index 00000000..bb0a74c5 --- /dev/null +++ b/packages/ui/src/components/dashboard-tiles/recent-records.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../lib/utils'; +import { useResourceDefinition, useResourceIcon, useResourceKit } from '../resource-kit/context'; +import { FieldValue, linkClass } from '../resource-kit/field-value'; +import { fieldFormat, inSentence, recordId, visibleFields } from '../resource-kit/fields'; +import { errorMessage } from '../resource-kit/form-values'; +import { formatFieldText } from '../resource-kit/format'; +import { useRelationLabels, useRowTitles } from '../resource-kit/labels'; +import { queryFailure, useResourceList } from '../resource-kit/queries'; +import type { ListQuery, ResourceRef } from '../resource-kit/types'; +import { dashedRule, EmptyState, Panel } from '../workspace-kit/surface'; + +export type RecentRecordsProps = { + resource: ResourceRef; + /** Rows to show. Defaults to 5. */ + limit?: number; + /** Defaults to the resource's default sort, else newest first by `createdAt` when the resource has that field. */ + sort?: ListQuery['sort']; + /** Equality filters, e.g. `{ status: 'open' }`. */ + filters?: Record; + /** Defaults to "Recent
+ {content} + + ); +} diff --git a/packages/ui/src/components/resource-kit/context.tsx b/packages/ui/src/components/resource-kit/context.tsx new file mode 100644 index 00000000..56a2b18d --- /dev/null +++ b/packages/ui/src/components/resource-kit/context.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { QueryClient, QueryClientContext, QueryClientProvider } from '@tanstack/react-query'; +import { type LucideIcon, Rows3 } from 'lucide-react'; +import * as React from 'react'; + +import type { AnyResource, ResourceRef, ResourceRouter } from './types'; + +type ResourceKitContextValue = { + router: ResourceRouter; + resources: readonly AnyResource[]; + byName: ReadonlyMap; + locale: string; + currency: string; + /** Fixed clock for relative dates, in epoch milliseconds; undefined reads the system clock. */ + now: number | undefined; + icons: Readonly>; +}; + +const ResourceKitContext = React.createContext(null); + +export type ResourceKitProviderProps = { + router: ResourceRouter; + resources: readonly AnyResource[]; + /** Reuses the host's client. Without one the provider uses the enclosing client, or makes its own. */ + queryClient?: QueryClient; + /** Number and date formatting. Defaults to en-US. */ + locale?: string; + /** ISO code for `currency` fields. Defaults to USD. */ + currency?: string; + /** A fixed clock for relative dates ("3 days ago"), e.g. from the server; defaults to the reader's clock. */ + now?: string | number | Date; + /** Icon components for the names resources use in `icon`, e.g. `{ calendar: Calendar }`. */ + icons?: Readonly>; + children: React.ReactNode; +}; + +/** `CalendarDays`, `calendar_days`, and `calendar-days` all name the same icon. */ +const iconKey = (name: string) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[\s_]+/g, '-').toLowerCase(); + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } } }); +} + +/** + * Holds the resources, the host router, and the query cache every resource + * screen reads through. Relation targets resolve here by name. + */ +export function ResourceKitProvider({ router, resources, queryClient, locale = 'en-US', currency = 'USD', now, icons, children }: ResourceKitProviderProps) { + const outerClient = React.useContext(QueryClientContext); + const [ownClient] = React.useState(() => (queryClient || outerClient ? null : createQueryClient())); + // A Date prop is a new object each render; its time is what matters. + const nowMs = now === undefined ? undefined : new Date(now).getTime(); + const value = React.useMemo( + () => ({ + router, + resources, + byName: new Map(resources.map((resource) => [resource.name, resource])), + locale, + currency, + now: nowMs, + icons: Object.fromEntries(Object.entries(icons ?? {}).map(([name, icon]) => [iconKey(name), icon])), + }), + [router, resources, locale, currency, nowMs, icons], + ); + const content = {children}; + const client = queryClient ?? ownClient; + return client ? {content} : content; +} + +export function useResourceKit(): ResourceKitContextValue { + const context = React.useContext(ResourceKitContext); + if (!context) throw new Error('Resource Kit components must render inside .'); + return context; +} + +/** The kit context when one encloses the caller, for components that also work alone. */ +export function useOptionalResourceKit(): ResourceKitContextValue | null { + return React.useContext(ResourceKitContext); +} + +/** A resource by name or definition. */ +export function useResourceDefinition(resource: ResourceRef): AnyResource { + const { byName } = useResourceKit(); + if (typeof resource !== 'string') return resource; + const definition = byName.get(resource); + if (!definition) throw new Error(`Unknown resource "${resource}". Pass it to .`); + return definition; +} + +/** The icon a resource names, from the provider's `icons` map, or a generic table glyph. */ +export function useResourceIcon(resource: AnyResource): LucideIcon { + const { icons } = useResourceKit(); + if (!resource.icon) return Rows3; + return icons[iconKey(resource.icon)] ?? Rows3; +} + +/** A resource's icon, sized for a row or heading. */ +export function ResourceIcon({ resource, className }: { resource: ResourceRef; className?: string }) { + const Icon = useResourceIcon(useResourceDefinition(resource)); + return
} + action={ + + } + /> + ); +} diff --git a/packages/ui/src/components/resource-kit/field-control.tsx b/packages/ui/src/components/resource-kit/field-control.tsx new file mode 100644 index 00000000..0aad432f --- /dev/null +++ b/packages/ui/src/components/resource-kit/field-control.tsx @@ -0,0 +1,135 @@ +'use client'; + +import * as React from 'react'; + +import { numberFormat } from '../../lib/intl'; +import { Input } from '../input'; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../input-group'; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from '../select'; +import { Switch } from '../switch'; +import { Textarea } from '../textarea'; +import { useResourceKit } from './context'; +import type { FormValue } from './form-values'; +import { RelationPicker } from './relation-picker'; +import type { ResourceField } from './types'; + +/** Select value for "no choice" on optional selects; Base UI treats null as the placeholder. */ +const NONE = '\u0000none'; + +const HTML_TYPE: Partial> = { + email: 'email', + url: 'url', + date: 'date', + datetime: 'datetime-local', + time: 'time', +}; + +export type FieldControlProps = { + field: ResourceField; + value: FormValue; + onChange: (value: FormValue) => void; + id: string; + invalid?: boolean; + describedBy?: string; + disabled?: boolean; +}; + +/** The currency symbol for the kit's locale and currency, e.g. "$" or "€". */ +function useCurrencySymbol() { + const { locale, currency } = useResourceKit(); + return React.useMemo( + () => numberFormat(locale, { style: 'currency', currency }).formatToParts(0).find((part) => part.type === 'currency')?.value ?? currency, + [locale, currency], + ); +} + +/** The control that edits one field, chosen by its `input`. */ +export function FieldControl({ field, value, onChange, id, invalid, describedBy, disabled }: FieldControlProps) { + const symbol = useCurrencySymbol(); + const aria = { id, 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy, disabled: disabled || field.readOnly }; + const text = typeof value === 'string' ? value : ''; + const placeholder = field.placeholder; + + // Text with a fixed set of options is edited as a choice. + const input = field.options?.length && field.input === 'text' ? 'select' : field.input; + switch (input) { + case 'boolean': + return onChange(checked)} />; + case 'textarea': + case 'json': + return ( +