feat(ui-library): publish the page and route overlay components as registry components (#426)

* feat(ui-library): publish the page and route overlay components as registry items

Move PageContainer, PageHeader, RouteDialog, RouteDrawer, RouteChildPage and
useRouteOverlay out of the templates' client/components/ and into the UI
Library as two items, page-ui and route-overlay-ui, so applications and
plugins install one maintained source instead of copying template files.

- ui-library: add the items under registry/page/, conforming to the library's
  authoring rules (.js relative imports, explicit export types, tailwind-merge
  instead of @/lib/utils, prefixed translation key routeOverlay.close with
  shipped locales), preview demos, the dialog primitive, and generic preview
  wiring in website/app.tsx.
- templates: ship byte-identical installed copies under
  client/extensions/nocobase-page-ui/ and nocobase-route-overlay-ui/, import
  from there, and add routeOverlay.close to client/locales/.
- tests/scripts/template-ui-library.test.mjs fails when a template copy drifts
  from the library source.
- Point the app development Skill, the plugin development Skill, the template
  AGENTS.md files, the docs site and the library's own guides at the new
  locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui-library): publish one component per item, installed into client/components

The page and route overlay components are single business components, not a
complete feature, so they follow shadcn's convention for components rather
than the block layout auth-ui uses.

- Split page-ui and route-overlay-ui into page-container, page-header,
  route-dialog, route-drawer and route-child-page. route-dialog and
  route-drawer both list the route-overlay.tsx and use-route-overlay.ts they
  share, so installing either brings its implementation and the two keep one
  Context.
- Target client/components/<file>, so templates keep their original paths and
  imports; revert the import rewrites across pages, tests, Skills and docs.
- Document the two kinds of item in MAINTAINING.md and USAGE.md: a component
  installs into client/components/, a block such as auth-ui into
  client/extensions/nocobase-<item>/, with the rules on relative imports,
  translations, targets, READMEs, upgrading and removal that follow.
- Components ship no locale files; routeOverlay.close is listed in the group
  README and each overlay item's docs.
- Rework the preview demos per item and the sync test for component targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui-library): merge class names with cn from @/lib/utils

The components now live in client/components beside the templates' other
components, which all merge class names with cn from @/lib/utils. Importing
tailwind-merge directly made them the odd ones out.

- Import cn from @/lib/utils in page-container, route-child-page and
  route-overlay, and list utils in their registryDependencies instead of
  depending on tailwind-merge.
- Allow @/lib/utils as the second @/ import an item may use, map it in
  tsconfig.registry.json, and give the preview a real cn (the one shadcn's
  utils item generates) so the drawer's overriding classes merge there too.
- Document the rule, the plugin rewrite, and keeping the templates'
  lib/utils.ts (it also exports assetUrl) when an item requests utils.
- Teach the sync test that utils resolves to client/lib/utils.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zeke Zhang
2026-09-25 08:43:05 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c8ddd7d7f2
commit 91cc403007
57 changed files with 1531 additions and 180 deletions
@@ -0,0 +1,8 @@
---
'@nocobase/app-template-default': patch
'@nocobase/app-template-examples': patch
'@nocobase/app-template-hub': patch
'@nocobase/app-skills': patch
---
`PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` now come from the NocoBase UI Library, which publishes them as the `page-container`, `page-header`, `route-dialog`, `route-drawer` and `route-child-page` components, and plugins install those instead of copying template files. They stay in `client/components/` under the same names and import paths. The template copies now match the library: exports carry explicit types, every component merges class names with `cn` from `@/lib/utils`, and the route overlays' close button is translated under `routeOverlay.close` rather than `actions.close`. Existing applications need no change; to adopt the library versions, run `npx shadcn@latest add @nocobase/page-container @nocobase/page-header @nocobase/route-dialog @nocobase/route-drawer @nocobase/route-child-page`, let it overwrite each copy you have not customized, and add `routeOverlay.close` to `client/locales/`.
+1 -1
View File
@@ -6,7 +6,7 @@ keywords: 'NocoBase,组件,样式,shadcn,主题变量,深色模式,图标'
# 界面和样式
NocoBase 应用使用 shadcn/ui 组件和 Tailwind CSS 编写界面。基础组件放在 `client/components/ui/`,应用共享组件放在 `client/components/`,页面及其专用组件放在 `client/pages/` 中对应的页面目录。
NocoBase 应用使用 shadcn/ui 组件和 Tailwind CSS 编写界面。基础组件放在 `client/components/ui/`,应用共享组件放在 `client/components/`,页面及其专用组件放在 `client/pages/` 中对应的页面目录。从 [NocoBase UI Library](http://ui.nocobase.com) 安装的业务组件同样放在 `client/components/`,例如模板预装的 `PageContainer`、`PageHeader` 和路由浮层组件;认证界面 `auth-ui` 这类完整功能则放在 `client/extensions/nocobase-<item>/`。
普通界面样式使用主题变量。这样同一套组件可以适配浅色主题、深色主题和其他主题预设。
@@ -180,7 +180,7 @@ What the components already do:
- **Size**: a dialog is centered, is the screen width minus 2rem wide on narrow screens, and has a maximum height of `100svh - 2rem`; a drawer sits against the right edge at full height and takes the full width on narrow screens. The title area and the bottom button area are fixed and only the content area scrolls, so the bottom buttons stay reachable on narrow screens too (guideline A4); there is no need to add `max-h` or `overflow` to the panel.
- **Content container**: the content area already has `p-4` padding. Do not nest a `PageContainer` inside it, and do not add outer padding of your own.
- **Ways to close**: a close button is built into the top-right corner (its accessible name comes from `actions.close`); Esc and clicking the backdrop close the overlay too. Each overlay layer has its own backdrop, and when layers stack only the topmost one closes.
- **Ways to close**: a close button is built into the top-right corner (its accessible name comes from `routeOverlay.close`); Esc and clicking the backdrop close the overlay too. Each overlay layer has its own backdrop, and when layers stack only the topmost one closes.
- **Closing is navigation**: closing first calls `beforeClose`, then navigates to `closeTo` with `replace`. Because it uses `replace`, pressing the browser's "Forward" after closing does not reopen the overlay.
- **Focus**: on open, focus moves into the overlay; when the focused element inside the overlay disappears (for example, a button is replaced by a skeleton after clicking "Retry"), focus returns to the overlay panel; after closing, focus returns to the element that had focus before opening (usually the link that opened it). When a nested overlay closes, focus returns to the element in the parent layer that opened it, or to the parent layer's panel if that element is gone; when `/projects/12/edit` is opened directly, both layers mount at once, and after the dialog closes, focus is on the drawer panel.
- When the element that focus should return to is no longer on the page after closing (for example, the list row disappears after a delete), the component cannot handle it. Move focus to a stable place yourself; see `afterDelete` above (guideline A6).
@@ -118,6 +118,8 @@ pnpm exec shadcn add card
Build your own components by composing these primitives, and put them in `client/components/`. A few such compositions ship with the template for the shadcn documentation pages that describe a pattern rather than a registry item: `DataTable` with `DataTableColumnHeader`, `DataTablePagination` and `DataTableViewOptions` in `client/components/data-table*.tsx`, `DatePicker` and `DateRangePicker` in `client/components/date-picker.tsx`, and the `Typography*` prose primitives in `client/components/typography.tsx`. Reach for these before writing a table, a date field or long-form text from scratch.
`PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` in `client/components/` come from the NocoBase UI Library, as do the authentication layout and forms in `client/extensions/nocobase-auth-ui/`; like the rest of the source, they belong to the application. `pnpm exec shadcn add @nocobase/<item>` adds another item from the library: a single component lands in `client/components/`, a complete feature in `client/extensions/nocobase-<item>/`.
**Read the reference pages before building a page.** `client/pages/reference/` is worked source, not part of the running application: nothing routes it, so a build never reaches it and no user ever sees it. `examples/` holds eight complete business screens on mock data — a dashboard, orders, customers, a product form, an inbox, a survey, team settings and a schedule — and `components/` holds one page per shadcn/ui primitive showing its variants and a realistic use. Both share the frame in `shared.tsx`. An example is a folder holding its page beside the mock data that page reads — `examples/orders/orders.tsx` and `orders.data.ts` — so the screen and its records move together.
Their wording lives beside them in `client/pages/reference/locales/`, not in `client/locales/`, because these pages are not part of the application and their strings have no reason to reach a browser; kept in the application locale they were 96% of it. A page given a route temporarily therefore shows its key paths until you merge that module into `client/locales/index.ts` by hand. Add reference-page wording to both reference locale files; `tests/logic/locale-coverage.test.ts` checks all referenced keys, including keys completed at runtime.
@@ -1,9 +1,20 @@
import type { ComponentProps } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import { cn } from '@/lib/utils';
export type PageContainerProps = ComponentProps<'section'>;
export function PageContainer({ className, ...props }: PageContainerProps) {
/**
* The frame of a page: the full width, the responsive padding, and the spacing between its sections.
*
* A page renders one. Content that renders inside another page — an inline child route, a tab panel — already sits in
* that page's container and adds none of its own, while a covering `RouteChildPage` is a surface of its own and
* places one inside it.
*/
export function PageContainer({
className,
...props
}: PageContainerProps): ReactElement {
return (
<section
className={cn('w-full space-y-6 p-6 md:p-8', className)}
@@ -11,5 +22,3 @@ export function PageContainer({ className, ...props }: PageContainerProps) {
/>
);
}
PageContainer.displayName = 'PageContainer';
@@ -1,12 +1,18 @@
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** Page-level actions, aligned to the right of the title from the `sm` breakpoint up. */
readonly actions?: ReactNode;
}
export function PageHeader({ actions, description, title }: PageHeaderProps) {
/** The heading of a page: its only `h1`, an optional description, and the actions that apply to the whole page. */
export function PageHeader({
actions,
description,
title,
}: PageHeaderProps): ReactElement {
return (
<header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='min-w-0'>
@@ -25,5 +31,3 @@ export function PageHeader({ actions, description, title }: PageHeaderProps) {
</header>
);
}
PageHeader.displayName = 'PageHeader';
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
import { cn } from '../lib/utils';
import { cn } from '@/lib/utils';
export interface RouteChildPageProps {
readonly children?: ReactNode;
@@ -64,5 +64,3 @@ export function RouteChildPage({
</div>
);
}
RouteChildPage.displayName = 'RouteChildPage';
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDialogProps = RouteOverlayProps;
export function RouteDialog(props: RouteDialogProps) {
/** A child route presented as a centered modal dialog. */
export function RouteDialog(props: RouteDialogProps): ReactElement {
return <RouteOverlay {...props} />;
}
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDrawerProps = RouteOverlayProps;
export function RouteDrawer(props: RouteDrawerProps) {
/** A child route presented as a modal panel docked to the right edge of the viewport. */
export function RouteDrawer(props: RouteDrawerProps): ReactElement {
return <RouteOverlay {...props} drawer />;
}
@@ -1,38 +1,43 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import {
createContext,
useContext,
type RefObject,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import { useLocation, useNavigate, type To } from 'react-router';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogOverlay,
DialogClose,
DialogPortal,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
} from './ui/dialog';
import { RouteOverlayContext } from './use-route-overlay';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { RouteOverlayContext } from './use-route-overlay.js';
export interface RouteOverlayProps {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
closeTo?: To;
beforeClose?: () => boolean | Promise<boolean>;
className?: string;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly children?: ReactNode;
readonly footer?: ReactNode;
/** Where closing navigates. Defaults to the parent route, keeping the current search string. */
readonly closeTo?: To;
/** Runs before every close; resolving `false` keeps the overlay open, for example to guard unsaved changes. */
readonly beforeClose?: () => boolean | Promise<boolean>;
readonly className?: string;
}
// Lets an overlay rendered through another overlay's outlet return focus into
@@ -42,7 +47,10 @@ export interface RouteOverlayProps {
const ParentPopupContext =
createContext<RefObject<HTMLDivElement | null> | null>(null);
/** Application-owned presentation; route registration stays unchanged. */
/**
* The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
* and closes by navigating away from it. Route registration stays with the application.
*/
export function RouteOverlay({
title,
description,
@@ -52,7 +60,7 @@ export function RouteOverlay({
beforeClose,
className,
drawer = false,
}: RouteOverlayProps & { drawer?: boolean }) {
}: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
const { t } = useTranslation();
const parentPopup = useContext(ParentPopupContext);
const popupRef = useRef<HTMLDivElement>(null);
@@ -169,7 +177,9 @@ export function RouteOverlay({
}
>
<XIcon />
<span className='sr-only'>{t('actions.close')}</span>
<span className='sr-only'>
{t('routeOverlay.close', { defaultValue: 'Close' })}
</span>
</DialogClose>
</DialogPrimitive.Popup>
</DialogPortal>
@@ -1,14 +1,20 @@
import { createContext, useContext } from 'react';
import { createContext, useContext, type Context } from 'react';
interface RouteOverlayContextValue {
close: () => Promise<void>;
isClosing: boolean;
export interface RouteOverlayContextValue {
/** Runs `beforeClose`, then navigates to the parent route unless it declined. Concurrent calls share one request. */
readonly close: () => Promise<void>;
/** True while a close request for the current location is pending. */
readonly isClosing: boolean;
}
/** Internal context shared by both route overlay components. */
export const RouteOverlayContext =
export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
createContext<RouteOverlayContextValue | null>(null);
/**
* Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
* body or its footer — never from the page that returns the overlay, which sits outside the provider.
*/
export function useRouteOverlay(): RouteOverlayContextValue {
const value = useContext(RouteOverlayContext);
if (!value) {
@@ -62,6 +62,7 @@ const enUS = {
'navigation.brandHome': 'NocoBase home',
'navigation.brandApps': 'NocoBase applications',
'auth.passwordMismatch': "Passwords don't match.",
'routeOverlay.close': 'Close',
'status.deniedDescription': 'You do not have permission to access {{label}}.',
'status.routeFailedDescription':
'Route {{label}} from {{packageName}} could not be loaded.',
@@ -58,6 +58,7 @@ const zhCN: AppResource = {
'navigation.brandHome': 'NocoBase 首页',
'navigation.brandApps': 'NocoBase 应用',
'auth.passwordMismatch': '两次输入的密码不一致。',
'routeOverlay.close': '关闭',
'status.deniedDescription': '你没有访问 {{label}} 的权限。',
'status.routeFailedDescription':
'无法加载 {{packageName}} 的路由 {{label}}。',
@@ -96,7 +96,7 @@ describe('route overlays', () => {
it('uses the explicit target without merging query', async () => {
const router = setup(undefined, '/other');
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(router.state.location.pathname).toBe('/main/other'),
@@ -113,7 +113,7 @@ describe('route overlays', () => {
);
const router = setup(check);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'actions.close' }));
fireEvent.click(screen.getByRole('button', { name: 'routeOverlay.close' }));
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
await act(async () => {
@@ -273,7 +273,7 @@ describe('route overlay interactions', () => {
try {
const router = setup(() => Promise.reject(failure));
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(log).toHaveBeenCalledWith(
@@ -119,6 +119,8 @@ pnpm exec shadcn add card
Build your own components by composing these primitives, and put them in `client/components/`. A few such compositions ship with the template for the shadcn documentation pages that describe a pattern rather than a registry item: `DataTable` with `DataTableColumnHeader`, `DataTablePagination` and `DataTableViewOptions` in `client/components/data-table*.tsx`, `DatePicker` and `DateRangePicker` in `client/components/date-picker.tsx`, and the `Typography*` prose primitives in `client/components/typography.tsx`. Reach for these before writing a table, a date field or long-form text from scratch.
`PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` in `client/components/` come from the NocoBase UI Library, as do the authentication layout and forms in `client/extensions/nocobase-auth-ui/`; like the rest of the source, they belong to the application. `pnpm exec shadcn add @nocobase/<item>` adds another item from the library: a single component lands in `client/components/`, a complete feature in `client/extensions/nocobase-<item>/`.
**Read the reference pages before building a page.** `client/pages/reference/` is worked source, not part of the running application: nothing routes it, so a build never reaches it and no user ever sees it. `examples/` holds eight complete business screens on mock data — a dashboard, orders, customers, a product form, an inbox, a survey, team settings and a schedule — and `components/` holds one page per shadcn/ui primitive showing its variants and a realistic use. Both share the frame in `shared.tsx`. An example is a folder holding its page beside the mock data that page reads — `examples/orders/orders.tsx` and `orders.data.ts` — so the screen and its records move together.
Their wording lives beside them in `client/pages/reference/locales/`, not in `client/locales/`, because these pages are not part of the application and their strings have no reason to reach a browser; kept in the application locale they were 96% of it. A page given a route temporarily therefore shows its key paths until you merge that module into `client/locales/index.ts` by hand. Add reference-page wording to both reference locale files; `tests/logic/locale-coverage.test.ts` checks all referenced keys, including keys completed at runtime.
@@ -1,9 +1,20 @@
import type { ComponentProps } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import { cn } from '@/lib/utils';
export type PageContainerProps = ComponentProps<'section'>;
export function PageContainer({ className, ...props }: PageContainerProps) {
/**
* The frame of a page: the full width, the responsive padding, and the spacing between its sections.
*
* A page renders one. Content that renders inside another page — an inline child route, a tab panel — already sits in
* that page's container and adds none of its own, while a covering `RouteChildPage` is a surface of its own and
* places one inside it.
*/
export function PageContainer({
className,
...props
}: PageContainerProps): ReactElement {
return (
<section
className={cn('w-full space-y-6 p-6 md:p-8', className)}
@@ -11,5 +22,3 @@ export function PageContainer({ className, ...props }: PageContainerProps) {
/>
);
}
PageContainer.displayName = 'PageContainer';
@@ -1,12 +1,18 @@
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** Page-level actions, aligned to the right of the title from the `sm` breakpoint up. */
readonly actions?: ReactNode;
}
export function PageHeader({ actions, description, title }: PageHeaderProps) {
/** The heading of a page: its only `h1`, an optional description, and the actions that apply to the whole page. */
export function PageHeader({
actions,
description,
title,
}: PageHeaderProps): ReactElement {
return (
<header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='min-w-0'>
@@ -25,5 +31,3 @@ export function PageHeader({ actions, description, title }: PageHeaderProps) {
</header>
);
}
PageHeader.displayName = 'PageHeader';
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
import { cn } from '../lib/utils';
import { cn } from '@/lib/utils';
export interface RouteChildPageProps {
readonly children?: ReactNode;
@@ -64,5 +64,3 @@ export function RouteChildPage({
</div>
);
}
RouteChildPage.displayName = 'RouteChildPage';
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDialogProps = RouteOverlayProps;
export function RouteDialog(props: RouteDialogProps) {
/** A child route presented as a centered modal dialog. */
export function RouteDialog(props: RouteDialogProps): ReactElement {
return <RouteOverlay {...props} />;
}
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDrawerProps = RouteOverlayProps;
export function RouteDrawer(props: RouteDrawerProps) {
/** A child route presented as a modal panel docked to the right edge of the viewport. */
export function RouteDrawer(props: RouteDrawerProps): ReactElement {
return <RouteOverlay {...props} drawer />;
}
@@ -1,38 +1,43 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import {
createContext,
useContext,
type RefObject,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import { useLocation, useNavigate, type To } from 'react-router';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogOverlay,
DialogClose,
DialogPortal,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
} from './ui/dialog';
import { RouteOverlayContext } from './use-route-overlay';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { RouteOverlayContext } from './use-route-overlay.js';
export interface RouteOverlayProps {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
closeTo?: To;
beforeClose?: () => boolean | Promise<boolean>;
className?: string;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly children?: ReactNode;
readonly footer?: ReactNode;
/** Where closing navigates. Defaults to the parent route, keeping the current search string. */
readonly closeTo?: To;
/** Runs before every close; resolving `false` keeps the overlay open, for example to guard unsaved changes. */
readonly beforeClose?: () => boolean | Promise<boolean>;
readonly className?: string;
}
// Lets an overlay rendered through another overlay's outlet return focus into
@@ -42,7 +47,10 @@ export interface RouteOverlayProps {
const ParentPopupContext =
createContext<RefObject<HTMLDivElement | null> | null>(null);
/** Application-owned presentation; route registration stays unchanged. */
/**
* The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
* and closes by navigating away from it. Route registration stays with the application.
*/
export function RouteOverlay({
title,
description,
@@ -52,7 +60,7 @@ export function RouteOverlay({
beforeClose,
className,
drawer = false,
}: RouteOverlayProps & { drawer?: boolean }) {
}: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
const { t } = useTranslation();
const parentPopup = useContext(ParentPopupContext);
const popupRef = useRef<HTMLDivElement>(null);
@@ -169,7 +177,9 @@ export function RouteOverlay({
}
>
<XIcon />
<span className='sr-only'>{t('actions.close')}</span>
<span className='sr-only'>
{t('routeOverlay.close', { defaultValue: 'Close' })}
</span>
</DialogClose>
</DialogPrimitive.Popup>
</DialogPortal>
@@ -1,14 +1,20 @@
import { createContext, useContext } from 'react';
import { createContext, useContext, type Context } from 'react';
interface RouteOverlayContextValue {
close: () => Promise<void>;
isClosing: boolean;
export interface RouteOverlayContextValue {
/** Runs `beforeClose`, then navigates to the parent route unless it declined. Concurrent calls share one request. */
readonly close: () => Promise<void>;
/** True while a close request for the current location is pending. */
readonly isClosing: boolean;
}
/** Internal context shared by both route overlay components. */
export const RouteOverlayContext =
export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
createContext<RouteOverlayContextValue | null>(null);
/**
* Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
* body or its footer — never from the page that returns the overlay, which sits outside the provider.
*/
export function useRouteOverlay(): RouteOverlayContextValue {
const value = useContext(RouteOverlayContext);
if (!value) {
@@ -114,6 +114,7 @@ const enUS = {
'navigation.brandHome': 'NocoBase home',
'navigation.brandApps': 'NocoBase applications',
'auth.passwordMismatch': "Passwords don't match.",
'routeOverlay.close': 'Close',
'status.deniedDescription': 'You do not have permission to access {{label}}.',
'status.routeFailedDescription':
'Route {{label}} from {{packageName}} could not be loaded.',
@@ -110,6 +110,7 @@ const zhCN: AppResource = {
'navigation.brandHome': 'NocoBase 首页',
'navigation.brandApps': 'NocoBase 应用',
'auth.passwordMismatch': '两次输入的密码不一致。',
'routeOverlay.close': '关闭',
'status.deniedDescription': '你没有访问 {{label}} 的权限。',
'status.routeFailedDescription':
'无法加载 {{packageName}} 的路由 {{label}}。',
@@ -96,7 +96,7 @@ describe('route overlays', () => {
it('uses the explicit target without merging query', async () => {
const router = setup(undefined, '/other');
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(router.state.location.pathname).toBe('/main/other'),
@@ -113,7 +113,7 @@ describe('route overlays', () => {
);
const router = setup(check);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'actions.close' }));
fireEvent.click(screen.getByRole('button', { name: 'routeOverlay.close' }));
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
await act(async () => {
@@ -273,7 +273,7 @@ describe('route overlay interactions', () => {
try {
const router = setup(() => Promise.reject(failure));
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(log).toHaveBeenCalledWith(
@@ -114,6 +114,8 @@ pnpm exec shadcn add card
Build your own components by composing these primitives, and put them in `client/components/`. A few such compositions ship with the template for the shadcn documentation pages that describe a pattern rather than a registry item: `DataTable` with `DataTableColumnHeader`, `DataTablePagination` and `DataTableViewOptions` in `client/components/data-table*.tsx`, `DatePicker` and `DateRangePicker` in `client/components/date-picker.tsx`, and the `Typography*` prose primitives in `client/components/typography.tsx`. Reach for these before writing a table, a date field or long-form text from scratch.
`PageContainer`, `PageHeader`, `RouteDialog`, `RouteDrawer`, `RouteChildPage` and `useRouteOverlay` in `client/components/` come from the NocoBase UI Library, as do the authentication layout and forms in `client/extensions/nocobase-auth-ui/`; like the rest of the source, they belong to the application. `pnpm exec shadcn add @nocobase/<item>` adds another item from the library: a single component lands in `client/components/`, a complete feature in `client/extensions/nocobase-<item>/`.
**Read the reference pages before building a page.** `client/pages/reference/` is worked source, not part of the running application: nothing routes it, so a build never reaches it and no user ever sees it. `examples/` holds eight complete business screens on mock data — a dashboard, orders, customers, a product form, an inbox, a survey, team settings and a schedule — and `components/` holds one page per shadcn/ui primitive showing its variants and a realistic use. Both share the frame in `shared.tsx`. An example is a folder holding its page beside the mock data that page reads — `examples/orders/orders.tsx` and `orders.data.ts` — so the screen and its records move together.
Their wording lives beside them in `client/pages/reference/locales/`, not in `client/locales/`, because these pages are not part of the application and their strings have no reason to reach a browser; kept in the application locale they were 96% of it. A page given a route temporarily therefore shows its key paths until you merge that module into `client/locales/index.ts` by hand. Add reference-page wording to both reference locale files; `tests/logic/locale-coverage.test.ts` checks all referenced keys, including keys completed at runtime.
@@ -1,9 +1,20 @@
import type { ComponentProps } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import { cn } from '@/lib/utils';
export type PageContainerProps = ComponentProps<'section'>;
export function PageContainer({ className, ...props }: PageContainerProps) {
/**
* The frame of a page: the full width, the responsive padding, and the spacing between its sections.
*
* A page renders one. Content that renders inside another page — an inline child route, a tab panel — already sits in
* that page's container and adds none of its own, while a covering `RouteChildPage` is a surface of its own and
* places one inside it.
*/
export function PageContainer({
className,
...props
}: PageContainerProps): ReactElement {
return (
<section
className={cn('w-full space-y-6 p-6 md:p-8', className)}
@@ -11,5 +22,3 @@ export function PageContainer({ className, ...props }: PageContainerProps) {
/>
);
}
PageContainer.displayName = 'PageContainer';
@@ -1,12 +1,18 @@
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** Page-level actions, aligned to the right of the title from the `sm` breakpoint up. */
readonly actions?: ReactNode;
}
export function PageHeader({ actions, description, title }: PageHeaderProps) {
/** The heading of a page: its only `h1`, an optional description, and the actions that apply to the whole page. */
export function PageHeader({
actions,
description,
title,
}: PageHeaderProps): ReactElement {
return (
<header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='min-w-0'>
@@ -25,5 +31,3 @@ export function PageHeader({ actions, description, title }: PageHeaderProps) {
</header>
);
}
PageHeader.displayName = 'PageHeader';
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
import { cn } from '../lib/utils';
import { cn } from '@/lib/utils';
export interface RouteChildPageProps {
readonly children?: ReactNode;
@@ -64,5 +64,3 @@ export function RouteChildPage({
</div>
);
}
RouteChildPage.displayName = 'RouteChildPage';
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDialogProps = RouteOverlayProps;
export function RouteDialog(props: RouteDialogProps) {
/** A child route presented as a centered modal dialog. */
export function RouteDialog(props: RouteDialogProps): ReactElement {
return <RouteOverlay {...props} />;
}
@@ -1,7 +1,10 @@
import { RouteOverlay, type RouteOverlayProps } from './route-overlay';
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDrawerProps = RouteOverlayProps;
export function RouteDrawer(props: RouteDrawerProps) {
/** A child route presented as a modal panel docked to the right edge of the viewport. */
export function RouteDrawer(props: RouteDrawerProps): ReactElement {
return <RouteOverlay {...props} drawer />;
}
@@ -1,38 +1,43 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import {
createContext,
useContext,
type RefObject,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import { useLocation, useNavigate, type To } from 'react-router';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogOverlay,
DialogClose,
DialogPortal,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
} from './ui/dialog';
import { RouteOverlayContext } from './use-route-overlay';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { RouteOverlayContext } from './use-route-overlay.js';
export interface RouteOverlayProps {
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
closeTo?: To;
beforeClose?: () => boolean | Promise<boolean>;
className?: string;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly children?: ReactNode;
readonly footer?: ReactNode;
/** Where closing navigates. Defaults to the parent route, keeping the current search string. */
readonly closeTo?: To;
/** Runs before every close; resolving `false` keeps the overlay open, for example to guard unsaved changes. */
readonly beforeClose?: () => boolean | Promise<boolean>;
readonly className?: string;
}
// Lets an overlay rendered through another overlay's outlet return focus into
@@ -42,7 +47,10 @@ export interface RouteOverlayProps {
const ParentPopupContext =
createContext<RefObject<HTMLDivElement | null> | null>(null);
/** Application-owned presentation; route registration stays unchanged. */
/**
* The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
* and closes by navigating away from it. Route registration stays with the application.
*/
export function RouteOverlay({
title,
description,
@@ -52,7 +60,7 @@ export function RouteOverlay({
beforeClose,
className,
drawer = false,
}: RouteOverlayProps & { drawer?: boolean }) {
}: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
const { t } = useTranslation();
const parentPopup = useContext(ParentPopupContext);
const popupRef = useRef<HTMLDivElement>(null);
@@ -169,7 +177,9 @@ export function RouteOverlay({
}
>
<XIcon />
<span className='sr-only'>{t('actions.close')}</span>
<span className='sr-only'>
{t('routeOverlay.close', { defaultValue: 'Close' })}
</span>
</DialogClose>
</DialogPrimitive.Popup>
</DialogPortal>
@@ -1,14 +1,20 @@
import { createContext, useContext } from 'react';
import { createContext, useContext, type Context } from 'react';
interface RouteOverlayContextValue {
close: () => Promise<void>;
isClosing: boolean;
export interface RouteOverlayContextValue {
/** Runs `beforeClose`, then navigates to the parent route unless it declined. Concurrent calls share one request. */
readonly close: () => Promise<void>;
/** True while a close request for the current location is pending. */
readonly isClosing: boolean;
}
/** Internal context shared by both route overlay components. */
export const RouteOverlayContext =
export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
createContext<RouteOverlayContextValue | null>(null);
/**
* Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
* body or its footer — never from the page that returns the overlay, which sits outside the provider.
*/
export function useRouteOverlay(): RouteOverlayContextValue {
const value = useContext(RouteOverlayContext);
if (!value) {
@@ -62,6 +62,7 @@ const enUS = {
'navigation.brandHome': 'NocoBase home',
'navigation.brandApps': 'NocoBase applications',
'auth.passwordMismatch': "Passwords don't match.",
'routeOverlay.close': 'Close',
'status.deniedDescription': 'You do not have permission to access {{label}}.',
'status.routeFailedDescription':
'Route {{label}} from {{packageName}} could not be loaded.',
@@ -58,6 +58,7 @@ const zhCN: AppResource = {
'navigation.brandHome': 'NocoBase 首页',
'navigation.brandApps': 'NocoBase 应用',
'auth.passwordMismatch': '两次输入的密码不一致。',
'routeOverlay.close': '关闭',
'status.deniedDescription': '你没有访问 {{label}} 的权限。',
'status.routeFailedDescription':
'无法加载 {{packageName}} 的路由 {{label}}。',
@@ -96,7 +96,7 @@ describe('route overlays', () => {
it('uses the explicit target without merging query', async () => {
const router = setup(undefined, '/other');
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(router.state.location.pathname).toBe('/main/other'),
@@ -113,7 +113,7 @@ describe('route overlays', () => {
);
const router = setup(check);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'actions.close' }));
fireEvent.click(screen.getByRole('button', { name: 'routeOverlay.close' }));
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
await act(async () => {
@@ -273,7 +273,7 @@ describe('route overlay interactions', () => {
try {
const router = setup(() => Promise.reject(failure));
fireEvent.click(
await screen.findByRole('button', { name: 'actions.close' }),
await screen.findByRole('button', { name: 'routeOverlay.close' }),
);
await waitFor(() =>
expect(log).toHaveBeenCalledWith(
@@ -98,17 +98,18 @@ Use the shared theme contract for all plugin UI: [theme tokens](theme-tokens.md)
## Copy page and route components into the plugin
When a plugin needs the template's page structure or route overlays, copy the required source into `<plugin>/client/components/` and maintain it as plugin-owned code. Reuse an existing plugin copy before adding another. These template files are source references, not runtime imports from the host App:
When a plugin needs the application's page structure or route overlays, copy the UI Library components that provide them into `<plugin>/client/components/` and maintain them as plugin-owned code. Inside this repository, copy each file an item lists in `ui-library/registry/components/registry.json` from `ui-library/registry/components/<path>` to its `target`, as `ui-library/USAGE.md` describes, rather than running `shadcn add`. These are source references, not runtime imports from the host App. Reuse an existing plugin copy before adding another.
| Need | Source to copy |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Page spacing | `packages/templates/app-template-default/client/components/page-container.tsx` |
| Page heading and actions | `packages/templates/app-template-default/client/components/page-header.tsx` |
| Route dialog or drawer | `packages/templates/app-template-default/client/components/route-dialog.tsx`, `packages/templates/app-template-default/client/components/route-drawer.tsx`, `packages/templates/app-template-default/client/components/route-overlay.tsx`, and `packages/templates/app-template-default/client/components/use-route-overlay.ts` |
| Need | Item | Installs into `client/components/` |
| ------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------- |
| Page spacing | `page-container` | `page-container.tsx` |
| Page heading and actions | `page-header` | `page-header.tsx` |
| Route dialog or drawer | `route-dialog`, `route-drawer` | `route-dialog.tsx` or `route-drawer.tsx`, with `route-overlay.tsx` and `use-route-overlay.ts` |
| Covering child page | `route-child-page` | `route-child-page.tsx` |
Copy the overlay implementation and its hook together, retaining one plugin-local Context instance for both wrappers. Include only the wrappers needed by the feature. Resolve their transitive imports against plugin-owned shadcn primitives and utilities, including `ui/dialog`, `ui/button`, and `lib/utils`; generate missing primitives using the workflow above. Change internal imports to relative `.js` paths and add explicit declaration-safe types as described above. Declare imported Client runtime packages as peers. Keep these copies private unless an approved public export is required.
Copy every file an item lists: `route-dialog` and `route-drawer` share `route-overlay.tsx` and the one Context `use-route-overlay.ts` declares, so a plugin that uses both keeps a single copy of each. `page-header` compiles as copied. The others import `cn` from `@/lib/utils`, and the overlays also import the `button` and `dialog` primitives as `@/components/ui/<name>`; rewrite those to relative `.js` paths to the plugin's own `client/lib/utils.ts` and primitives, generating missing primitives with the workflow above. Declare the packages each item lists in `dependencies` as peers. Keep these copies private unless an approved public export is required.
Copy the components' translation keys into the plugin's own `client/locales/` resources and register the lazy locale manifest in its Client declaration; see [internationalization](i18n.md). In particular, `route-overlay.tsx` uses `actions.close`: supply this key in every supported plugin language (for example, `Close` in English and `关闭` in Chinese). Do not rely on the host App providing the same key. Under the plugin's own route, the copied overlay inherits the plugin namespace; if it is intentionally exported for another owner to render, bind that namespace explicitly as described in the internationalization guide.
The overlays' close button reads `routeOverlay.close`. Add it to the plugin's own `client/locales/` resources in every supported language (`Close` in English and `关闭` in Chinese, for example) and register the lazy locale manifest in its Client declaration; see [internationalization](i18n.md). Do not rely on the host App providing the key. Under the plugin's own route, the copied overlay inherits the plugin namespace; if it is intentionally exported for another owner to render, bind that namespace explicitly as described in the internationalization guide.
The page examples in this Skill assume these copies already exist. Nested pages adjust the relative path to the same plugin-owned components. The host App's private breadcrumb component is excluded from this copy workflow because it reads an App-owned route Context; do not copy that Context or import the host's routing internals.
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const libraryRoot = path.join(repoRoot, 'ui-library');
const templates = ['default', 'examples', 'hub'];
// The UI Library items every template preinstalls. The library is the source of truth, so each template carries
// exactly the files `shadcn add` would install today, at their targets; a change to one of these items is carried into
// all three templates in the same pull request. `auth-ui` is not listed: its template copies diverged from the
// library before this check existed and are due to be resynchronized.
const preinstalled = [
{ group: 'components', item: 'page-container' },
{ group: 'components', item: 'page-header' },
{ group: 'components', item: 'route-dialog' },
{ group: 'components', item: 'route-drawer' },
{ group: 'components', item: 'route-child-page' },
];
function registryItem(group, name) {
const registry = JSON.parse(
fs.readFileSync(
path.join(libraryRoot, 'registry', group, 'registry.json'),
'utf8',
),
);
const item = registry.items.find((candidate) => candidate.name === name);
assert.ok(item, `ui-library/registry/${group} must declare ${name}`);
return item;
}
for (const kind of templates) {
const templateRoot = path.join(
repoRoot,
'packages/templates',
`app-template-${kind}`,
);
const manifest = JSON.parse(
fs.readFileSync(path.join(templateRoot, 'package.json'), 'utf8'),
);
for (const { group, item: name } of preinstalled) {
test(`${kind} ships the current ${name} from the UI Library`, () => {
const item = registryItem(group, name);
for (const file of item.files) {
const installed = path.join(templateRoot, file.target);
assert.ok(
fs.existsSync(installed),
`${kind}: ${name} installs ${file.target}`,
);
assert.equal(
fs.readFileSync(installed, 'utf8'),
fs.readFileSync(
path.join(libraryRoot, 'registry', group, file.path),
'utf8',
),
`${kind}: refresh ${file.target} from ui-library/registry/${group}/${file.path}`,
);
}
for (const dependency of item.registryDependencies ?? []) {
// `utils` is the `cn` helper at `@/lib/utils`; every other registry dependency is a shadcn primitive.
if (dependency === 'utils') {
assert.match(
fs.readFileSync(
path.join(templateRoot, 'client/lib/utils.ts'),
'utf8',
),
/export function cn\(/u,
`${kind}: ${name} needs cn from client/lib/utils.ts`,
);
continue;
}
assert.ok(
fs.existsSync(
path.join(templateRoot, `client/components/ui/${dependency}.tsx`),
),
`${kind}: ${name} needs the ${dependency} primitive`,
);
}
for (const specifier of item.dependencies ?? []) {
const dependency = specifier.slice(0, specifier.lastIndexOf('@'));
assert.ok(
manifest.dependencies?.[dependency] ??
manifest.devDependencies?.[dependency],
`${kind}: ${name} imports ${dependency}, which package.json must declare`,
);
}
});
}
}
+23 -15
View File
@@ -6,6 +6,11 @@ The UI Library is a [shadcn registry](https://ui.shadcn.com/docs/registry) of No
Put a component here when the applications and plugins that use it should own and edit it after installation, and it builds on a plugin's public exports rather than its internals. `auth-ui` is the model: pages, forms, and layout that an application reshapes freely, over the headless actions that `@nocobase/app-plugin-authentication/client/actions` keeps stable.
An item is one of two kinds, and its kind decides where it installs:
- **A component** is a single business component that pages are composed from, such as `page-header` or `route-dialog`. It installs beside the consumer's own components in `client/components/`, the way shadcn installs its primitives into `client/components/ui/`, and is imported like one of them: `@/components/page-header`. Keep it to one component — its own file plus only the files it cannot work without, such as the `route-overlay.tsx` that `route-dialog` and `route-drawer` share. A consumer that wants two components installs two items.
- **A block** is a complete feature assembled from several components, such as `auth-ui`: pages, forms, layout and translations that only make sense together. It installs into a directory of its own, `client/extensions/nocobase-<item>/`, which is where consumers find it and the directory they delete to remove it.
Two neighbours look similar and are not the same thing:
- Behavior a plugin must keep consistent across upgrades belongs in the plugin, as a runtime component, route, or public export. Nothing updates installed source, so it cannot carry anything the plugin has to control.
@@ -17,13 +22,15 @@ Two neighbours look similar and are not the same thing:
ui-library/
├── registry.json registry index: name, homepage, and one include per group
├── registry/
│ └── auth/ a group
│ ├── registry.json the group's items; file paths are relative to this directory
│ └── auth-ui/ an item: its source files, locales/, and README
│ ├── auth/ a group of blocks
│ │ ├── registry.json the group's items; file paths are relative to this directory
│ │ └── auth-ui/ a block: its source files, locales/, and README
│ └── components/ the group of components: registry.json, a README, and every
│ component's files side by side, as they land in client/components/
├── website/ the preview site (Vite and React)
│ ├── app.tsx index page, and routing to each item's demo
│ ├── components/ui/ shadcn primitives the preview and the items render with
│ └── demo/auth/auth-ui/ the item's demo routes and the mocks they run against
│ └── demo/<group>/ each item's demo routes and the mocks they run against
├── public/ static files; public/r/ is generated by registry:build
├── components.json shadcn configuration for adding primitives under website/
├── tsconfig.json the preview together with the item sources
@@ -39,21 +46,22 @@ ui-library/
These rules follow from where the files end up: in an application, compiled by Vite, and in a plugin, compiled by `tsc` with NodeNext resolution and published as `dist/`. An item has to work in both.
- **Relative imports carry the `.js` extension.** Write `import { FormStatus } from '../components/form-status.js';`, not `'../components/form-status'`. NodeNext requires the extension, so a plugin cannot compile the item without it (`TS2835`), while an application's bundler resolves `.js` to the `.tsx` source just as well. `tsconfig.registry.json` enforces this in `typecheck`.
- **Relative imports stay inside the item's directory.** Only the item's own files are installed, each at its `target`, so a path that leaves the item points at nothing in the consumer.
- **Import shadcn primitives as `@/components/ui/<name>` and list them in `registryDependencies`.** shadcn installs each listed primitive into the consumer's own `components/ui/`, where the alias resolves. Never copy a primitive into an item. The primitive also has to exist under `website/components/ui/` for the item to type-check here; add a missing one with `pnpm exec shadcn add <name>` from `ui-library/` and review what it changes.
- **Relative imports reach only files the item installs.** Only the item's own files are installed, each at its `target`, so a path to anything else points at nothing in the consumer. Keep every source file in the same position relative to the others as its `target`: a block's files sit in the block's directory, and the components sit side by side in `registry/components/`, as they do in `client/components/`, which is also what lets two components install one shared file.
- **Import shadcn primitives as `@/components/ui/<name>` and the `cn` helper from `@/lib/utils`, and list them in `registryDependencies`.** These are the only `@/` imports an item may use. shadcn installs each listed primitive into the consumer's own `components/ui/`, and the `utils` dependency provides `lib/utils.ts` to a project that has none, so both aliases resolve. Merge class names with `cn`, as the consumer's own components do, rather than importing `clsx` or `tailwind-merge` directly. Never copy a primitive into an item. The primitive also has to exist under `website/components/ui/` for the item to type-check here; add a missing one with `pnpm exec shadcn add <name>` from `ui-library/` and review what it changes.
- **Reach a plugin only through its published exports**, such as `@nocobase/app-plugin-authentication/client/actions`. A consumer has the installed package and nothing else.
- **Declare every package the item imports in `dependencies`, with a version range.** `shadcn add` installs exactly this list; nothing derives it from the imports. `react` is the one exception, since every consumer already has it. Take the floor of each range from what is published on `https://npm.nocobase.ai`, not from the workspace; see [Changing an item](#changing-an-item).
- **Annotate every export.** Give functions a return type, props an interface, and exported constants a type. A plugin builds declarations with `isolatedDeclarations`, which rejects an inferred export. No check in this package enforces it, because the preview's shadcn copies do not satisfy it and `tsconfig.registry.json` therefore skips declaration checks, so review it, or install the item into a plugin as described in [Trying a build before merging](#trying-a-build-before-merging).
- **Translate user-facing text.** Call `useTranslation()` from `@nocobase/i18n/client` without naming a namespace, so the item translates in whichever namespace renders it. Use a key under the item's prefix, such as `auth.signIn`, and pass the English wording as `defaultValue`, which is what renders wherever the key is missing. Keep the prop that lets a consumer replace the text.
- **Put every key in the item's `locales/`.** `locales/en-US.ts` holds each key the components look up, with the same wording as its `defaultValue`, and exports its shape as a type; every other locale, such as `locales/zh-CN.ts`, is annotated with that type, so `typecheck` fails on a key one of them lacks or adds. List both files in `files` like any other source, and add a key to all of them in the same change that starts using it. Nothing checks that a key the components use appears in `en-US`, so review that. `registry/auth/auth-ui/locales/` is the model, and its README shows consumers how to merge the files into their own locale resources, which installing does not do.
- **Put every key of a block in its `locales/`.** `locales/en-US.ts` holds each key the components look up, with the same wording as its `defaultValue`, and exports its shape as a type; every other locale, such as `locales/zh-CN.ts`, is annotated with that type, so `typecheck` fails on a key one of them lacks or adds. List both files in `files` like any other source, and add a key to all of them in the same change that starts using it. Nothing checks that a key the components use appears in `en-US`, so review that. `registry/auth/auth-ui/locales/` is the model, and its README shows consumers how to merge the files into their own locale resources, which installing does not do.
- **List a component's keys in its README.** A component ships no locale files, since `client/components/` has no place for them. Name each key it looks up in `docs`, list it with its English and Chinese wording in the group's README, and keep such keys few; the consumer adds them to its own locale resources.
- **Style with the shared theme tokens.** Use the utilities in [theme.md](../packages/app/app-skills/skills/nocobase-app-development/references/frontend/references/theme.md) so the item follows the consumer's theme. `website/styles.css` loads the default template's theme, so the preview renders the tokens the way an application does.
- **Install each item into one directory.** Give every file a `target` under `client/extensions/nocobase-<item>/`. That is where consumers find the item, and the directory they delete to remove it.
- **Write `docs` and the README for different readers.** shadcn prints the item's `docs` after installing it, but does not install the README, which is not listed in `files`. Put what a consumer needs before using the item in `docs`, in a sentence or two. The README documents the item in this repository: its entry points, prerequisites, how to merge its translations, and what a consumer is expected to customize.
- **Install into the place the item's kind decides.** Declare a component as `registry:component` and give each of its files the `target` `client/components/<file>`; declare a block as `registry:block` and give every file a `target` under `client/extensions/nocobase-<item>/`. A component's file names share `client/components/` with the consumer's own files, so name the file after the component it exports, as the application would.
- **Write `docs` and the README for different readers.** shadcn prints the item's `docs` after installing it, but does not install the README, which is not listed in `files`. Put what a consumer needs before using the item in `docs`, in a sentence or two. The README — one per block, and one for the whole group of components — documents the item in this repository: its entry points, prerequisites, how to merge its translations, and what a consumer is expected to customize.
## Adding an item
1. Create the item's directory under its group, such as `registry/auth/<item>/`, with its source files, its `locales/`, and a `README.md`. For a new group, create `registry/<group>/registry.json` and add it to `include` in the root `registry.json`.
2. Declare the item in the group's `registry.json`, where each `path` is relative to that file's directory:
1. For a block, create its directory under its group, such as `registry/auth/<item>/`, with its source files, its `locales/`, and a `README.md`. For a component, add its file to `registry/components/` beside the others, and document it and any translation keys in that group's `README.md`. For a new group of blocks, create `registry/<group>/registry.json` and add it to `include` in the root `registry.json`.
2. Declare the item in the group's `registry.json`, where each `path` is relative to that file's directory. A block looks like this; a component is declared the same way with `registry:component` and a `target` of `client/components/<file>`, as in `registry/components/registry.json`:
```json
{
@@ -78,9 +86,9 @@ These rules follow from where the files end up: in an application, compiled by V
Only the preview site reads `meta`: `group` names the item's sidebar section and `iframeHeight` sets the height of its preview in pixels.
3. Add each package the item imports to `ui-library/package.json` as well, using `workspace:^` for a NocoBase package and `catalog:` for one the workspace catalog pins, and run `CI=true pnpm install --no-frozen-lockfile`. This is what lets the checks resolve the real modules. A package the preview needs only for its types, such as a plugin whose exports are mocked, goes in `devDependencies`.
4. Mock whatever needs a server. The preview is a static site, so it cannot run a plugin's headless actions. Put a stand-in module in `website/demo/<group>/<item>/`, type it with `import type` from the real export, and point the export at it from `resolve.alias` in `vite.config.ts`. Do not add the mapping to `tsconfig.json`: TypeScript has to keep resolving the real export, which is what checks both the item and the mock against the plugin's contract. `website/demo/auth/auth-ui/mock-actions.tsx` is the model.
5. Add demo routes under `website/demo/<group>/<item>/` that render the item the way an application would, importing its sources by relative path.
6. Wire the demo into `website/app.tsx`, which does not discover items yet. Route the demo's root path to its component in `AppContent`, and give the item a preview path in `RegistryPreviewSection`; an item without one shows "Preview is not available". Set `meta.group` as well, since `groupItems` files an ungrouped `registry:block` under Authentication. The fallback entry `authUiItem`, the sidebar icon in `RegistrySidebarItem`, and the iframe title in `PreviewCanvas` are still written for `auth-ui` alone.
4. Mock whatever needs a server. The preview is a static site, so it cannot run a plugin's headless actions. Put a stand-in module in `website/demo/<group>/`, type it with `import type` from the real export, and point the export at it from `resolve.alias` in `vite.config.ts`. Do not add the mapping to `tsconfig.json`: TypeScript has to keep resolving the real export, which is what checks both the item and the mock against the plugin's contract. `website/demo/auth/auth-ui/mock-actions.tsx` is the model.
5. Add demo routes under `website/demo/<group>/` that render the item the way an application would, importing its sources by relative path.
6. Wire the demo into `website/app.tsx`, which does not discover items yet. Route the demo's root path to its component in `AppContent`, and give the item a preview path and a sidebar icon in `itemPreviews`; an item without an entry shows "Preview is not available". Set `meta.group` as well, since `groupItems` files an ungrouped `registry:block` under Authentication. The fallback entry `authUiItem`, shown when `public/r/registry.json` has not been built, is still written for `auth-ui` alone.
7. Run the [checks](#checks), then install the local build into a scratch application, and into a plugin if the item is meant for plugins, as described in [Trying a build before merging](#trying-a-build-before-merging).
A pull request that changes only `ui-library/` needs no changeset. The package is private and published to OSS rather than npm, and `scripts/require-changesets.mjs` looks only at `packages/`. Merging it to `develop` publishes it.
@@ -94,7 +102,7 @@ Every installed copy belongs to its project, and nothing in this repository can
- A merge to `develop` publishes the item at once, but packages are published only when someone runs a release. An item that relies on a package change is therefore live before that package is, and installing it in between compiles against the old published version. When you can, land and release the package change first, then change the item and raise the floor of its range to the released version.
- A plugin change that breaks an export an item uses fails `typecheck` here, in the plugin's own pull request, because both TypeScript programs resolve the real workspace package. Prefer a compatible change: add the new export, and remove the old one once the items have moved. When the break is unavoidable, update the item in the same pull request, release the plugin soon after merging, and then raise the item's range floor.
The three application templates still ship an installed copy of `auth-ui` under `client/extensions/nocobase-auth-ui/`. This library is the source of truth, and those copies are due to be removed. Until they are, a change the templates should also get has to be carried into all three by hand, and that part of the pull request changes published packages, so it needs a changeset.
The three application templates ship installed copies of some items, and this library is the source of truth for all of them. The components their pages are built from — `page-container`, `page-header`, `route-dialog`, `route-drawer` and `route-child-page` — are preinstalled in `client/components/`: each template carries exactly the files those items install, and `tests/scripts/template-ui-library.test.mjs` fails until a change to one of them has been copied into all three templates. That part of the pull request changes published packages, so it needs a changeset. The templates also carry the `auth-ui` block in `client/extensions/nocobase-auth-ui/`; those copies diverged before that check existed and are due to be resynchronized, so until then a change the templates should also get has to be carried into them by hand.
## Local development
+11 -9
View File
@@ -39,32 +39,34 @@ npx shadcn@latest add @nocobase/auth-ui
The dry run lists every file shadcn would create or overwrite and every dependency it would install; add `--diff <file>` to see the change to one file. Then:
1. **Keep your primitives.** An item lists the shadcn primitives it uses, such as `button` and `input`, and shadcn fetches the upstream version of each from the shadcn registry. When your project already has one with different content, shadcn asks whether to overwrite it, and the default is no. Keep that answer unless you want upstream's version in place of yours, and never pass `--overwrite` when adding an item.
1. **Keep your primitives.** An item lists the shadcn primitives it uses, such as `button` and `input`, and shadcn fetches the upstream version of each from the shadcn registry. When your project already has one with different content, shadcn asks whether to overwrite it, and the default is no. Keep that answer unless you want upstream's version in place of yours, and never pass `--overwrite` when adding an item. The same goes for `lib/utils.ts`, which an item that merges class names lists as `utils`: the templates' copy also exports `assetUrl`, which upstream's does not.
2. **Review `package.json`.** shadcn runs `pnpm add` for the item's dependencies before it writes any file. It adds every dependency the item pins to a range again, even one you already have, so it may rewrite that range. A package that only the primitives you declined needed, currently `cn`, is added all the same; remove it if nothing imports it. Packages that only `client/` imports belong in the application's `devDependencies`, as its `AGENTS.md` explains, while shadcn adds new ones to `dependencies`.
3. **Use the files.** They land in `client/extensions/nocobase-<item>/`. Import from there in your own pages, and read the item's README in this repository, such as the one for [auth-ui](registry/auth/auth-ui/README.md), for its entry points and what it expects you to customize.
4. **Merge the translations.** An item ships its translations in its `locales/` directory, and shadcn does not add them to your locale resources. Spread each file into the matching file in `client/locales/`, before your own keys so that yours can reword them; the item's README shows how. Without this step the item renders its English defaults in every language.
3. **Use the files.** A component lands in `client/components/` beside your own and is imported like them, as `@/components/page-header`; a block lands in `client/extensions/nocobase-<item>/`. Read the README in this repository — the [components README](registry/components/README.md), or a block's own such as [auth-ui's](registry/auth/auth-ui/README.md) — for the entry points and what the item expects you to customize.
4. **Add the translations.** shadcn does not touch your locale resources. A block ships its translations in its `locales/` directory: spread each file into the matching file in `client/locales/`, before your own keys so that yours can reword them, as its README shows. A component ships none; add the keys its README lists. Without this step the item renders its English defaults in every language.
5. **Run the application's checks**: `pnpm typecheck`, `pnpm lint`, `pnpm test`, and `pnpm build`.
An application created from one of the templates already contains `auth-ui` in `client/extensions/nocobase-auth-ui/`. Do not add it again; to take a newer version, see [Upgrading an item](#upgrading-an-item).
An application created from one of the templates already contains the `page-container`, `page-header`, `route-dialog`, `route-drawer` and `route-child-page` components in `client/components/`, and the `auth-ui` block in `client/extensions/nocobase-auth-ui/`. Do not add them again; to take a newer version, see [Upgrading an item](#upgrading-an-item).
## Add an item to a plugin
A plugin compiles `client/` with `tsc` using NodeNext resolution and publishes the output as `dist/`, which the installing application resolves again. An installed item therefore needs more work in a plugin than in an application.
1. **Install it.** Declare the registry in the plugin's `components.json` and run the same command from the plugin's directory. For a plugin in this repository, copy the item instead, as [Inside this repository](#inside-this-repository) describes. The item lands in the plugin's `client/extensions/nocobase-<item>/`, and any missing primitive in its `client/components/ui/`. You may move the item's directory, for example to `client/components/<item>/`, as long as it stays under `client/`.
1. **Install it.** Declare the registry in the plugin's `components.json` and run the same command from the plugin's directory. For a plugin in this repository, copy the item instead, as [Inside this repository](#inside-this-repository) describes. A component lands in the plugin's `client/components/` and a block in its `client/extensions/nocobase-<item>/`, with any missing primitive in `client/components/ui/`. You may move a block's directory, for example to `client/components/<item>/`, as long as it stays under `client/`.
2. **Replace the `@/` imports with relative `.js` paths.** TypeScript does not rewrite `paths` aliases in the JavaScript it emits, and in the application that installs the plugin, `@/` resolves to the application's own `client/`, so an `@/` import either fails there or binds to the wrong file. From `client/extensions/nocobase-auth-ui/forms/`:
```ts
// As installed:
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// In a plugin:
import { Button } from '../../../components/ui/button.js';
import { cn } from '../../../lib/utils.js';
```
The item's own relative imports already carry the `.js` extension.
The item's own relative imports already carry the `.js` extension. `cn` resolves to the plugin's own `client/lib/utils.ts`, which shadcn creates from the item's `utils` dependency when the plugin has none.
3. **Declare the dependencies as peers.** shadcn adds packages to `dependencies`, but a plugin's client imports belong in `peerDependencies`: the installing application resolves them and provides one shared copy. For `@nocobase/app-plugin-*` packages and `@nocobase/i18n` this is also a matter of correctness, since a second copy of either breaks at runtime, and `pnpm peers:check` rejects them in `dependencies`.
4. **Merge the translations into the plugin's own locales.** Under a plugin's route, keys resolve in the plugin's namespace first and fall back to the application's, so spread the item's `locales/` files into the plugin's `client/locales/` rather than relying on the application to have the keys. The item's own files compile under the plugin's declaration build as they are, but the plugin's merged `en-US.ts` needs the explicit type the item's README shows: `isolatedDeclarations` cannot infer an object built with a spread and fails with `TS9015`. A plugin without `client/locales/` yet sets them up as the [plugin i18n reference](../.agents/skills/nocobase-plugin-development/references/i18n.md) describes.
4. **Add the translations to the plugin's own locales.** Under a plugin's route, keys resolve in the plugin's namespace first and fall back to the application's, so put the item's keys in the plugin's `client/locales/` rather than relying on the application to have them: spread a block's `locales/` files, and add the keys a component's README lists. The item's own files compile under the plugin's declaration build as they are, but the plugin's merged `en-US.ts` needs the explicit type the item's README shows: `isolatedDeclarations` cannot infer an object built with a spread and fails with `TS9015`. A plugin without `client/locales/` yet sets them up as the [plugin i18n reference](../.agents/skills/nocobase-plugin-development/references/i18n.md) describes.
5. **Check it.** Keep the component private unless the plugin deliberately exports it. Run the plugin's `lint`, `typecheck`, `test`, and `build`, which also check the explicit export types that declaration builds require, then render the item in an application that installs the plugin.
## Inside this repository
@@ -81,13 +83,13 @@ Copy the item from its source instead:
Nothing updates an installed copy, and installing the item again over it would discard your changes, so an upgrade is a merge you make by hand:
1. Find what changed upstream. The item's history in this repository is the reliable record: `git log -p -- ui-library/registry/<group>/<item> ui-library/registry/<group>/registry.json`. `npx shadcn@latest add @nocobase/<item> --dry-run --diff <file>` compares your copy with the current published version, which mixes upstream changes with your own edits.
1. Find what changed upstream. The item's history in this repository is the reliable record: `git log -p -- ui-library/registry/<group>/registry.json` followed by the item's sources — `ui-library/registry/<group>/<item>` for a block, or the files a component lists, such as `ui-library/registry/components/page-header.tsx`. `npx shadcn@latest add @nocobase/<item> --dry-run --diff <file>` compares your copy with the current published version, which mixes upstream changes with your own edits.
2. Apply the upstream changes to your copy, keeping your customizations.
3. Install any dependency or primitive the new version adds, and run your checks.
Record the date, or the nocobase3 commit, in the commit that installs or upgrades an item. It is where the next upgrade starts.
To remove an item, delete its directory and the imports of it, then drop the dependencies nothing else imports. Leave the primitives, which other code may use.
To remove an item, delete a block's directory, or a component's files, and the imports of them, then drop the dependencies nothing else imports. A file two components share, such as `route-overlay.tsx`, stays until neither is installed. Leave the primitives, which other code may use.
## Troubleshooting
+4 -1
View File
@@ -2,5 +2,8 @@
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "nocobase",
"homepage": "https://www.nocobase.com",
"include": ["registry/auth/registry.json"]
"include": [
"registry/auth/registry.json",
"registry/components/registry.json"
]
}
+83
View File
@@ -0,0 +1,83 @@
# NocoBase business components
Single components that pages are built from. Each is its own item, installs into the consumer's `client/components/` beside the components it already owns, and belongs to the consumer from then on. The application templates ship all of them preinstalled there.
| Item | Installs | Exports |
| ------------------ | ----------------------------------------------------------------------- | -------------------------------- |
| `page-container` | `page-container.tsx` | `PageContainer` |
| `page-header` | `page-header.tsx` | `PageHeader` |
| `route-dialog` | `route-dialog.tsx`, with `route-overlay.tsx` and `use-route-overlay.ts` | `RouteDialog`, `useRouteOverlay` |
| `route-drawer` | `route-drawer.tsx`, with `route-overlay.tsx` and `use-route-overlay.ts` | `RouteDrawer`, `useRouteOverlay` |
| `route-child-page` | `route-child-page.tsx` | `RouteChildPage` |
`route-dialog` and `route-drawer` both install `route-overlay.tsx`, the implementation they share, and `use-route-overlay.ts`, the Context it provides. Installing the second of them finds both files already in place.
## Page layout
`PageContainer` renders a `section` with the full width, the responsive padding (`p-6 md:p-8`) and the spacing between sections (`space-y-6`). It accepts every `section` prop, and `className` is merged with `cn`, so it can override the defaults. `PageHeader` renders the page's only `h1`, an optional `description`, and `actions` aligned to the right from the `sm` breakpoint up.
```tsx
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
export default function OrdersPage() {
return (
<PageContainer>
<PageHeader
title='Orders'
description='Track every order from checkout to delivery.'
actions={<Button>New order</Button>}
/>
{/* sections */}
</PageContainer>
);
}
```
A page renders one `PageContainer`. Content that renders inside another page — an inline child route, a tab panel — already sits in that page's container and adds none of its own. A covering `RouteChildPage` is a surface of its own and places a `PageContainer` inside it; a dialog or drawer brings its own padding.
## Route overlays
The three ways a child route presents itself over the page that opened it, each at a URL of its own. The application owns the routes: declare each overlay as a child route of its page, render an `Outlet` in that page, and return the overlay from the child route's component.
```tsx
import { RouteDialog } from '@/components/route-dialog';
import { useRouteOverlay } from '@/components/use-route-overlay';
function CancelButton() {
const { close, isClosing } = useRouteOverlay();
return (
<Button variant='outline' disabled={isClosing} onClick={() => void close()}>
Cancel
</Button>
);
}
export default function NewOrderDialog() {
return (
<RouteDialog title='New order' footer={<CancelButton />}>
{/* form */}
</RouteDialog>
);
}
```
`RouteDialog` and `RouteDrawer` take the same props: `title`, `description`, `children`, `footer`, `className`, `closeTo`, where closing navigates and which defaults to the parent route with the current search string, and `beforeClose`, which runs before every close — the close button, Escape, the backdrop and `useRouteOverlay().close()` alike — and keeps the overlay open when it resolves `false`. An overlay opened from another overlay renders at the outer one's `Outlet`, gets its own backdrop, and returns focus into the outer panel when it closes. To restyle both, edit `route-overlay.tsx`.
Call `useRouteOverlay()` from a component rendered inside the overlay, such as a footer button. The page component that returns `<RouteDialog>` sits outside the overlay's provider, and the hook throws there.
`RouteChildPage` is not modal. It positions itself with `absolute inset-0`, so the element that contains it must be positioned; an application's content area is. Render its `Outlet` beside the page's `PageContainer` rather than inside it, and give the child page a `PageContainer` of its own. While it is mounted, the siblings it covers are `inert`. It has no close button: the breadcrumb above it, or the browser's back button, returns to the page beneath.
## Translations
The overlays' close button names itself with `useTranslation()` from `@nocobase/i18n/client` under `routeOverlay.close`, falling back to `Close`. A component ships no locale file, so add the key to the locale resources of the namespace that renders it:
| Key | `en-US` | `zh-CN` |
| -------------------- | ------- | ------- |
| `routeOverlay.close` | Close | 关闭 |
`page-container`, `page-header` and `route-child-page` render no text of their own.
## In a plugin
`page-header` has no `@/` imports and compiles in a plugin as installed. The others import `cn` from `@/lib/utils`, and `route-dialog` and `route-drawer` also import the `button` and `dialog` primitives as `@/components/ui/<name>`; rewrite those imports to relative `.js` paths, as [USAGE.md](../../USAGE.md#add-an-item-to-a-plugin) describes.
@@ -0,0 +1,24 @@
import type { ComponentProps, ReactElement } from 'react';
import { cn } from '@/lib/utils';
export type PageContainerProps = ComponentProps<'section'>;
/**
* The frame of a page: the full width, the responsive padding, and the spacing between its sections.
*
* A page renders one. Content that renders inside another page — an inline child route, a tab panel — already sits in
* that page's container and adds none of its own, while a covering `RouteChildPage` is a surface of its own and
* places one inside it.
*/
export function PageContainer({
className,
...props
}: PageContainerProps): ReactElement {
return (
<section
className={cn('w-full space-y-6 p-6 md:p-8', className)}
{...props}
/>
);
}
@@ -0,0 +1,33 @@
import type { ReactElement, ReactNode } from 'react';
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
/** Page-level actions, aligned to the right of the title from the `sm` breakpoint up. */
readonly actions?: ReactNode;
}
/** The heading of a page: its only `h1`, an optional description, and the actions that apply to the whole page. */
export function PageHeader({
actions,
description,
title,
}: PageHeaderProps): ReactElement {
return (
<header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
<div className='min-w-0'>
<h1 className='font-heading text-3xl font-semibold tracking-[-0.035em]'>
{title}
</h1>
{description ? (
<p className='mt-2 max-w-2xl text-sm leading-6 text-muted-foreground'>
{description}
</p>
) : null}
</div>
{actions ? (
<div className='flex shrink-0 items-center gap-2'>{actions}</div>
) : null}
</header>
);
}
@@ -0,0 +1,130 @@
{
"items": [
{
"name": "page-container",
"type": "registry:component",
"title": "Page Container",
"description": "The frame of a page: full width, responsive padding, and the spacing between its sections.",
"meta": {
"group": "Page layout",
"iframeHeight": 560
},
"registryDependencies": ["utils"],
"docs": "Wrap each page in PageContainer. Content rendered inside another page, such as an inline child route or a tab panel, adds none of its own.",
"files": [
{
"path": "page-container.tsx",
"type": "registry:component",
"target": "client/components/page-container.tsx"
}
]
},
{
"name": "page-header",
"type": "registry:component",
"title": "Page Header",
"description": "The heading of a page: its title, an optional description, and page-level actions.",
"meta": {
"group": "Page layout",
"iframeHeight": 420
},
"docs": "Render PageHeader once per page, as the first child of its PageContainer.",
"files": [
{
"path": "page-header.tsx",
"type": "registry:component",
"target": "client/components/page-header.tsx"
}
]
},
{
"name": "route-dialog",
"type": "registry:component",
"title": "Route Dialog",
"description": "A child route presented as a centered modal dialog, opened and closed by its URL.",
"meta": {
"group": "Route overlays",
"iframeHeight": 640
},
"dependencies": [
"@base-ui/react@^1.6.0",
"@nocobase/i18n@^1.0.0-beta.4",
"lucide-react@^0.487.0",
"react-router@^7.0.2"
],
"registryDependencies": ["button", "dialog", "utils"],
"docs": "Return RouteDialog from the component of a child route, and call useRouteOverlay() only from inside it. The close button is translated under routeOverlay.close; add it to your locale resources (Close, 关闭), or it reads Close in every language.",
"files": [
{
"path": "route-dialog.tsx",
"type": "registry:component",
"target": "client/components/route-dialog.tsx"
},
{
"path": "route-overlay.tsx",
"type": "registry:component",
"target": "client/components/route-overlay.tsx"
},
{
"path": "use-route-overlay.ts",
"type": "registry:component",
"target": "client/components/use-route-overlay.ts"
}
]
},
{
"name": "route-drawer",
"type": "registry:component",
"title": "Route Drawer",
"description": "A child route presented as a modal panel at the right edge, opened and closed by its URL.",
"meta": {
"group": "Route overlays",
"iframeHeight": 640
},
"dependencies": [
"@base-ui/react@^1.6.0",
"@nocobase/i18n@^1.0.0-beta.4",
"lucide-react@^0.487.0",
"react-router@^7.0.2"
],
"registryDependencies": ["button", "dialog", "utils"],
"docs": "Return RouteDrawer from the component of a child route, and call useRouteOverlay() only from inside it. The close button is translated under routeOverlay.close; add it to your locale resources (Close, 关闭), or it reads Close in every language.",
"files": [
{
"path": "route-drawer.tsx",
"type": "registry:component",
"target": "client/components/route-drawer.tsx"
},
{
"path": "route-overlay.tsx",
"type": "registry:component",
"target": "client/components/route-overlay.tsx"
},
{
"path": "use-route-overlay.ts",
"type": "registry:component",
"target": "client/components/use-route-overlay.ts"
}
]
},
{
"name": "route-child-page",
"type": "registry:component",
"title": "Route Child Page",
"description": "A child route presented as a non-modal page covering the content area, keeping the page beneath mounted.",
"meta": {
"group": "Route overlays",
"iframeHeight": 560
},
"registryDependencies": ["utils"],
"docs": "Return RouteChildPage from the component of a child route. It covers the nearest positioned ancestor, so render its outlet beside the parent page's container rather than inside it.",
"files": [
{
"path": "route-child-page.tsx",
"type": "registry:component",
"target": "client/components/route-child-page.tsx"
}
]
}
]
}
@@ -0,0 +1,66 @@
import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface RouteChildPageProps {
readonly children?: ReactNode;
readonly className?: string;
}
/**
* A child page, laid over the page that opened it.
*
* It is the third way a child route can present itself, beside `RouteDialog` and `RouteDrawer`: those float in the
* middle or at the side, this one covers the content area. Because it covers rather than replaces, the page beneath
* keeps its DOM — a draft being typed, a scroll position — and gets it back when this layer closes.
*
* Unlike the other two it is deliberately **not** modal. It does not portal out of the content area or trap focus,
* because the user is still on a page of the application and must be able to reach the sidebar. What closes it is
* the breadcrumb above it, or the browser's back button — not an X or Escape.
*
* It is a single element, which both positions and scrolls. Anything absolutely positioned inside it therefore
* moves with its scrolling — which is why the outlet for a deeper layer belongs *beside* this component rather
* than within it. Nested inside, a deeper layer would resolve `inset-0` against this one and scroll out of sight
* as soon as the user had scrolled this page. Kept as siblings, both layers anchor to the content area, and the
* DOM gains no level for each level of routing.
*/
export function RouteChildPage({
children,
className,
}: RouteChildPageProps): ReactElement {
const layerRef = useRef<HTMLDivElement>(null);
// Covering a page is not the same as closing it: the siblings underneath keep their DOM, and with it their place
// in the tab order and the accessibility tree. Left alone they are reachable behind opaque paint — a keyboard
// user tabs through controls nothing on screen shows, and focusing one scrolls the covered page into view while
// the layer, anchored to the content area, does not move. `inert` switches off exactly what this layer covers.
// The sidebar and the header are outside the content area and so outside this list, which is what keeps the
// component non-modal. A sibling already marked belongs to the layer below and is left for it to restore.
useEffect(() => {
const covered: Element[] = [];
for (
let sibling = layerRef.current?.previousElementSibling ?? null;
sibling;
sibling = sibling.previousElementSibling
) {
if (sibling.hasAttribute('inert')) continue;
sibling.setAttribute('inert', '');
covered.push(sibling);
}
return () => {
for (const sibling of covered) sibling.removeAttribute('inert');
};
});
return (
<div
className={cn(
'absolute inset-0 overflow-hidden overflow-y-auto bg-background',
className,
)}
ref={layerRef}
>
{children}
</div>
);
}
@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDialogProps = RouteOverlayProps;
/** A child route presented as a centered modal dialog. */
export function RouteDialog(props: RouteDialogProps): ReactElement {
return <RouteOverlay {...props} />;
}
@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
export type RouteDrawerProps = RouteOverlayProps;
/** A child route presented as a modal panel docked to the right edge of the viewport. */
export function RouteDrawer(props: RouteDrawerProps): ReactElement {
return <RouteOverlay {...props} drawer />;
}
@@ -0,0 +1,190 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { useTranslation } from '@nocobase/i18n/client';
import { XIcon } from 'lucide-react';
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import { useLocation, useNavigate, type To } from 'react-router';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { RouteOverlayContext } from './use-route-overlay.js';
export interface RouteOverlayProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly children?: ReactNode;
readonly footer?: ReactNode;
/** Where closing navigates. Defaults to the parent route, keeping the current search string. */
readonly closeTo?: To;
/** Runs before every close; resolving `false` keeps the overlay open, for example to guard unsaved changes. */
readonly beforeClose?: () => boolean | Promise<boolean>;
readonly className?: string;
}
// Lets an overlay rendered through another overlay's outlet return focus into
// the enclosing panel. Two overlays reached by one URL mount in the same
// commit, so the nested one never observes the parent panel taking focus and
// would otherwise fall back to `document.body`.
const ParentPopupContext =
createContext<RefObject<HTMLDivElement | null> | null>(null);
/**
* The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
* and closes by navigating away from it. Route registration stays with the application.
*/
export function RouteOverlay({
title,
description,
children,
footer,
closeTo,
beforeClose,
className,
drawer = false,
}: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
const { t } = useTranslation();
const parentPopup = useContext(ParentPopupContext);
const popupRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}, []);
const navigate = useNavigate();
const location = useLocation();
const [closingLocation, setClosingLocation] = useState<
typeof location | null
>(null);
const isClosing = closingLocation === location;
const pendingRef = useRef<Promise<void> | null>(null);
const generationRef = useRef(0);
// A still-mounted parent can change location while its confirmation is pending.
// Invalidate that request as well as requests from an unmounted route.
useLayoutEffect(() => {
generationRef.current += 1;
pendingRef.current = null;
return () => {
generationRef.current += 1;
};
}, [location]);
const close = useCallback((): Promise<void> => {
if (pendingRef.current) return pendingRef.current;
const requestGeneration = generationRef.current;
setClosingLocation(location);
const request = Promise.resolve()
.then(async () => {
const allowed = beforeClose ? await beforeClose() : true;
if (allowed && generationRef.current === requestGeneration) {
await navigate(
closeTo ?? { pathname: '..', search: location.search, hash: '' },
{ relative: 'route', replace: true },
);
}
})
.finally(() => {
if (generationRef.current === requestGeneration) {
pendingRef.current = null;
setClosingLocation(null);
}
});
pendingRef.current = request;
return request;
}, [beforeClose, closeTo, location, navigate]);
const value = useMemo(() => ({ close, isClosing }), [close, isClosing]);
return (
<RouteOverlayContext.Provider value={value}>
{/* Covers the panel body too, so an overlay placed at this page's outlet
finds the enclosing panel without knowing where the outlet lives. */}
<ParentPopupContext.Provider value={popupRef}>
<Dialog
open
onOpenChange={(open) => {
if (!open)
void close().catch((error: unknown) => {
console.error('Failed to close route overlay', error);
});
}}
>
<DialogPortal>
{/* Each nested panel needs its own backdrop above its parent panel. */}
<DialogOverlay forceRender />
<DialogPrimitive.Popup
ref={popupRef}
finalFocus={() => {
const previous = previousFocusRef.current;
if (parentPopup?.current) {
return previous?.isConnected &&
parentPopup.current.contains(previous)
? previous
: parentPopup.current;
}
return true;
}}
className={cn(
'fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-popover text-popover-foreground shadow-lg outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',
'flex max-h-[calc(100svh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl',
// The viewport constraints are deliberate; all ordinary styling uses theme tokens.
drawer &&
'top-0 right-0 left-auto h-svh max-h-svh w-full max-w-full translate-x-0 translate-y-0 rounded-none sm:max-w-xl data-open:slide-in-from-right data-open:zoom-in-100',
className,
)}
>
<header className='shrink-0 space-y-2 border-b p-4 pr-12'>
<DialogTitle>{title}</DialogTitle>
{description != null && (
<DialogDescription>{description}</DialogDescription>
)}
</header>
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
{children}
</div>
{footer != null && (
<footer className='flex shrink-0 flex-wrap justify-end gap-2 border-t p-4'>
{footer}
</footer>
)}
<DialogClose
render={
<Button
variant='ghost'
size='icon-sm'
className='absolute top-2 right-2'
/>
}
>
<XIcon />
<span className='sr-only'>
{t('routeOverlay.close', { defaultValue: 'Close' })}
</span>
</DialogClose>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>
</ParentPopupContext.Provider>
</RouteOverlayContext.Provider>
);
}
@@ -0,0 +1,26 @@
import { createContext, useContext, type Context } from 'react';
export interface RouteOverlayContextValue {
/** Runs `beforeClose`, then navigates to the parent route unless it declined. Concurrent calls share one request. */
readonly close: () => Promise<void>;
/** True while a close request for the current location is pending. */
readonly isClosing: boolean;
}
/** Internal context shared by both route overlay components. */
export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
createContext<RouteOverlayContextValue | null>(null);
/**
* Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
* body or its footer — never from the page that returns the overlay, which sits outside the provider.
*/
export function useRouteOverlay(): RouteOverlayContextValue {
const value = useContext(RouteOverlayContext);
if (!value) {
throw new Error(
'useRouteOverlay must be used inside RouteDialog or RouteDrawer',
);
}
return value;
}
+4 -3
View File
@@ -16,9 +16,10 @@
"isolatedDeclarations": false,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.registry.tsbuildinfo",
"paths": {
// Items import shadcn primitives the way an application provides them. The preview's copies stand in for
// them; the explicit extension is what lets an extensionless alias resolve under NodeNext.
"@/components/ui/*": ["./website/components/ui/*.tsx"]
// Items import shadcn primitives and the `cn` helper the way an application provides them. The preview's copies
// stand in for them; the explicit extension is what lets an extensionless alias resolve under NodeNext.
"@/components/ui/*": ["./website/components/ui/*.tsx"],
"@/lib/utils": ["./website/lib/utils.ts"]
}
},
"include": ["registry/**/*.ts", "registry/**/*.tsx"]
+57 -5
View File
@@ -1,14 +1,21 @@
import {
AppWindow,
Blocks,
Check,
Copy,
Heading,
Layers,
LayoutTemplate,
Monitor,
Moon,
PanelRight,
RefreshCw,
Search,
ShieldCheck,
Smartphone,
Sun,
Tablet,
type LucideIcon,
} from 'lucide-react';
import {
createContext,
@@ -44,6 +51,9 @@ import {
import { Separator } from './components/ui/separator';
import { TooltipProvider } from './components/ui/tooltip';
import { AuthenticationUiDemo } from './demo/auth/auth-ui';
import { PageContainerDemo } from './demo/components/page-container';
import { PageHeaderDemo } from './demo/components/page-header';
import { RouteOverlaysDemo } from './demo/components/route-overlays';
interface RegistryItem {
name: string;
@@ -67,6 +77,35 @@ const authUiItem: RegistryItem = {
type: 'registry:block',
};
interface ItemPreview {
/** The demo route rendered in the item's preview frame. */
readonly path: string;
readonly icon: LucideIcon;
}
// Items are not discovered: each one is wired here, and routed to its demo in `AppContent`.
const itemPreviews: Record<string, ItemPreview> = {
'auth-ui': { path: '/demo/auth/auth-ui/login', icon: ShieldCheck },
'page-container': {
path: '/demo/components/page-container',
icon: LayoutTemplate,
},
'page-header': { path: '/demo/components/page-header', icon: Heading },
// The three route overlays share one demo; each preview opens the route that presents its component.
'route-dialog': {
path: '/demo/components/route-overlays/new',
icon: AppWindow,
},
'route-drawer': {
path: '/demo/components/route-overlays/SO-1043',
icon: PanelRight,
},
'route-child-page': {
path: '/demo/components/route-overlays/report',
icon: Layers,
},
};
type ThemePreference = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
@@ -105,9 +144,19 @@ export function App(): ReactElement {
}
function AppContent(): ReactElement {
if (window.location.pathname.startsWith('/demo/auth/auth-ui')) {
const { pathname } = window.location;
if (pathname.startsWith('/demo/auth/auth-ui')) {
return <AuthenticationUiDemo />;
}
if (pathname.startsWith('/demo/components/page-container')) {
return <PageContainerDemo />;
}
if (pathname.startsWith('/demo/components/page-header')) {
return <PageHeaderDemo />;
}
if (pathname.startsWith('/demo/components/route-overlays')) {
return <RouteOverlaysDemo />;
}
return <RegistryDocs />;
}
@@ -343,6 +392,7 @@ function RegistrySidebarItem({
}): ReactElement {
const { setOpenMobile } = useSidebar();
const label = item.title ?? item.name;
const Icon = itemPreviews[item.name]?.icon ?? Blocks;
return (
<SidebarMenuItem>
@@ -361,7 +411,7 @@ function RegistrySidebarItem({
}
tooltip={label}
>
<ShieldCheck aria-hidden='true' />
<Icon aria-hidden='true' />
<span className='group-data-[collapsible=icon]:hidden'>{label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -373,8 +423,7 @@ function RegistryPreviewSection({
}: {
item: RegistryItem;
}): ReactElement {
const previewPath =
item.name === 'auth-ui' ? '/demo/auth/auth-ui/login' : undefined;
const previewPath = itemPreviews[item.name]?.path;
return (
<section className='scroll-mt-4' data-registry-item='true' id={item.name}>
@@ -534,6 +583,7 @@ function RegistryPreview({
path={previewPath}
reloadKey={refreshKey}
theme={resolved}
title={`${item.title ?? item.name} preview`}
viewport={viewport}
/>
</CardContent>
@@ -583,12 +633,14 @@ function PreviewCanvas({
path,
reloadKey,
theme,
title,
viewport,
}: {
height: number;
path: string;
reloadKey: number;
theme: ResolvedTheme;
title: string;
viewport: PreviewViewport;
}): ReactElement {
const viewportStyle: CSSProperties =
@@ -620,7 +672,7 @@ function PreviewCanvas({
}}
src={`${path}?theme=${theme}&preview=${theme}-${reloadKey}`}
style={{ ...viewportStyle, height }}
title='Password authentication preview'
title={title}
/>
</div>
);
+154
View File
@@ -0,0 +1,154 @@
import * as React from 'react';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { cn } from 'cn';
import { Button } from '@/components/ui/button';
import { XIcon } from 'lucide-react';
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot='dialog-overlay'
className={cn(
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot='dialog-content'
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot='dialog-close'
render={
<Button
variant='ghost'
className='absolute top-2 right-2'
size='icon-sm'
/>
}
>
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot='dialog-footer'
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant='outline' />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-base leading-none font-medium', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,61 @@
import { Plus } from 'lucide-react';
import type { ReactElement } from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { PageContainer } from '../../../registry/components/page-container';
import { PageHeader } from '../../../registry/components/page-header';
const summaries = [
{ label: 'Open orders', value: '128', detail: '12 awaiting payment' },
{ label: 'Shipped this week', value: '342', detail: 'Up 8% on last week' },
{ label: 'Returns', value: '6', detail: '2 need review' },
] as const;
export function PageContainerDemo(): ReactElement {
return (
<div className='min-h-svh bg-background text-foreground'>
<PageContainer>
<PageHeader
actions={
<Button type='button'>
<Plus data-icon='inline-start' />
New order
</Button>
}
description='Track every order from checkout to delivery, and act on the ones that need attention.'
title='Orders'
/>
<div className='grid gap-4 sm:grid-cols-3'>
{summaries.map((summary) => (
<Card key={summary.label}>
<CardHeader>
<CardDescription>{summary.label}</CardDescription>
<CardTitle className='text-2xl'>{summary.value}</CardTitle>
</CardHeader>
<CardContent className='text-sm text-muted-foreground'>
{summary.detail}
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
<CardDescription>
Each section of a page sits directly in the container, which
spaces them evenly.
</CardDescription>
</CardHeader>
</Card>
</PageContainer>
</div>
);
}
@@ -0,0 +1,49 @@
import { Download, Plus } from 'lucide-react';
import type { ReactElement } from 'react';
import { Button } from '@/components/ui/button';
import { PageHeader } from '../../../registry/components/page-header';
/** Each variant in its own frame, since a page renders exactly one header. */
export function PageHeaderDemo(): ReactElement {
return (
<div className='min-h-svh space-y-6 bg-background p-6 text-foreground md:p-8'>
<Variant>
<PageHeader title='Settings' />
</Variant>
<Variant>
<PageHeader
description='Everyone who can sign in to this application, and the roles they hold.'
title='Users'
/>
</Variant>
<Variant>
<PageHeader
actions={
<>
<Button type='button' variant='outline'>
<Download data-icon='inline-start' />
Export
</Button>
<Button type='button'>
<Plus data-icon='inline-start' />
New order
</Button>
</>
}
description='Track every order from checkout to delivery.'
title='Orders'
/>
</Variant>
</div>
);
}
function Variant({
children,
}: {
readonly children: ReactElement;
}): ReactElement {
return <div className='rounded-lg border border-dashed p-4'>{children}</div>;
}
@@ -0,0 +1,239 @@
import { ChevronRight, FileBarChart, Plus } from 'lucide-react';
import type { ReactElement } from 'react';
import {
BrowserRouter,
Link,
Outlet,
Route,
Routes,
useNavigate,
useParams,
} from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PageContainer } from '../../../registry/components/page-container';
import { PageHeader } from '../../../registry/components/page-header';
import { RouteChildPage } from '../../../registry/components/route-child-page';
import { RouteDialog } from '../../../registry/components/route-dialog';
import { RouteDrawer } from '../../../registry/components/route-drawer';
import { useRouteOverlay } from '../../../registry/components/use-route-overlay';
interface Order {
readonly id: string;
readonly customer: string;
readonly total: string;
readonly status: string;
}
const orders: readonly Order[] = [
{ id: 'SO-1042', customer: 'Acme Corp', total: '$1,280.00', status: 'Paid' },
{
id: 'SO-1043',
customer: 'Globex',
total: '$342.50',
status: 'Awaiting payment',
},
{ id: 'SO-1044', customer: 'Initech', total: '$96.00', status: 'Shipped' },
];
/**
* Every overlay is a child route of the orders page, so each state has a URL: `new` opens a dialog, an order id opens
* a drawer, `edit` stacks a dialog on that drawer, and `report` covers the page with a child page.
*/
export function RouteOverlaysDemo(): ReactElement {
return (
<BrowserRouter>
<Routes>
<Route element={<OrdersPage />} path='/demo/components/route-overlays'>
<Route element={<NewOrderDialog />} path='new' />
<Route element={<ReportChildPage />} path='report' />
<Route element={<OrderDrawer />} path=':orderId'>
<Route element={<EditOrderDialog />} path='edit' />
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
function OrdersPage(): ReactElement {
const navigate = useNavigate();
return (
// The content area positions a covering child page, which is why the outlet sits beside the page's container.
<main className='relative min-h-svh overflow-hidden bg-background text-foreground'>
<PageContainer>
<PageHeader
actions={
<>
<Button
onClick={() => void navigate('report')}
type='button'
variant='outline'
>
<FileBarChart data-icon='inline-start' />
Report
</Button>
<Button onClick={() => void navigate('new')} type='button'>
<Plus data-icon='inline-start' />
New order
</Button>
</>
}
description='Open an order to see it in a drawer. Each dialog, drawer and child page has its own URL.'
title='Orders'
/>
<ul className='divide-y rounded-lg border'>
{orders.map((order) => (
<li key={order.id}>
<Link
className='flex items-center gap-4 px-4 py-3 text-sm transition-colors hover:bg-muted/50'
to={order.id}
>
<span className='w-20 font-medium'>{order.id}</span>
<span className='min-w-0 flex-1 truncate'>
{order.customer}
</span>
<span className='hidden text-muted-foreground sm:inline'>
{order.status}
</span>
<span className='tabular-nums'>{order.total}</span>
<ChevronRight
aria-hidden='true'
className='size-4 text-muted-foreground'
/>
</Link>
</li>
))}
</ul>
</PageContainer>
<Outlet />
</main>
);
}
function CloseButton({ label }: { readonly label: string }): ReactElement {
// Rendered in the overlay's footer, which is inside the overlay, so the hook finds it.
const { close, isClosing } = useRouteOverlay();
return (
<Button
disabled={isClosing}
onClick={() => void close()}
type='button'
variant='outline'
>
{label}
</Button>
);
}
function NewOrderDialog(): ReactElement {
return (
<RouteDialog
description='A child route of the list, opened at its own URL; closing it returns to the list.'
footer={
<>
<CloseButton label='Cancel' />
<CloseButton label='Create order' />
</>
}
title='New order'
>
<div className='grid gap-4'>
<div className='grid gap-2'>
<Label htmlFor='customer'>Customer</Label>
<Input id='customer' placeholder='Acme Corp' />
</div>
<div className='grid gap-2'>
<Label htmlFor='total'>Total</Label>
<Input id='total' inputMode='decimal' placeholder='0.00' />
</div>
</div>
</RouteDialog>
);
}
function OrderDrawer(): ReactElement {
const { orderId } = useParams();
const navigate = useNavigate();
const order = orders.find((candidate) => candidate.id === orderId);
return (
<RouteDrawer
description={order ? order.customer : 'This order does not exist.'}
footer={
order ? (
<>
<CloseButton label='Close' />
<Button onClick={() => void navigate('edit')} type='button'>
Edit
</Button>
</>
) : undefined
}
title={orderId ?? 'Order'}
>
{order ? (
<dl className='grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm'>
<dt className='text-muted-foreground'>Status</dt>
<dd>{order.status}</dd>
<dt className='text-muted-foreground'>Total</dt>
<dd className='tabular-nums'>{order.total}</dd>
</dl>
) : null}
{/* A dialog opened from the drawer is its child route, rendered at the drawer's outlet. */}
<Outlet />
</RouteDrawer>
);
}
function EditOrderDialog(): ReactElement {
const { orderId } = useParams();
const order = orders.find((candidate) => candidate.id === orderId);
return (
<RouteDialog
description='Stacked on the drawer; closing it returns to the drawer.'
footer={
<>
<CloseButton label='Cancel' />
<CloseButton label='Save' />
</>
}
title={`Edit ${orderId ?? 'order'}`}
>
<div className='grid gap-2'>
<Label htmlFor='edit-customer'>Customer</Label>
<Input defaultValue={order?.customer} id='edit-customer' />
</div>
</RouteDialog>
);
}
function ReportChildPage(): ReactElement {
return (
<RouteChildPage>
<PageContainer>
<Link
className='text-sm text-muted-foreground transition-colors hover:text-foreground'
to='..'
>
← Orders
</Link>
<PageHeader
description='A covering child page keeps the list beneath it mounted, so returning restores its scroll position.'
title='Quarterly report'
/>
<p className='text-sm text-muted-foreground'>
Unlike a dialog or a drawer it is not modal: the application around
the content area stays reachable, and the breadcrumb or the browser's
back button closes it.
</p>
</PageContainer>
</RouteChildPage>
);
}
+1 -5
View File
@@ -1,5 +1 @@
export function cn(
...values: Array<string | false | null | undefined>
): string {
return values.filter(Boolean).join(' ');
}
export { cn } from 'cn';