From 93506e41c9d08adc71b0b7e3103f6786a47bef4d Mon Sep 17 00:00:00 2001 From: Caleb Panza <45076058+calebpanza@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:20:32 -0400 Subject: [PATCH 1/2] feat: add Modal compound component to registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Modal compound-component family to the crescent-ui shadcn registry, generalized from the Post for Me dashboard and DXLogic app-level modal implementations into registry source (cn-* tokens, icon-agnostic, dual-base). - registry/bases/{base,radix}/ui/modal.tsx — Modal layout (Header/Body/ Columns/Aside/Footer) + replace-style ModalViews navigation, composed on the consumer's dialog + button (registryDependencies) - app/ui/dialog.tsx — Base UI Dialog chrome the showcase renders Modal on - cn-dialog-content radius token across all 8 styles - modal-view keyframes shipped via the registry item css field (+ app.css for the live showcase) - build-registry.ts: emit registryDependencies + css; registry.json + demo Carousel slides are a planned follow-up (needs embla + a Carousel primitive). --- app/app.css | 41 +++ app/showcase/components/demos.tsx | 9 + app/showcase/components/modal-demo.tsx | 145 +++++++++++ app/ui/dialog.tsx | 147 +++++++++++ registry-dist/base-luma/modal.json | 56 ++++ registry-dist/base-lyra/modal.json | 56 ++++ registry-dist/base-maia/modal.json | 56 ++++ registry-dist/base-mira/modal.json | 56 ++++ registry-dist/base-nova/modal.json | 56 ++++ registry-dist/base-rhea/modal.json | 56 ++++ registry-dist/base-sera/modal.json | 56 ++++ registry-dist/base-vega/modal.json | 56 ++++ registry-dist/radix-luma/modal.json | 56 ++++ registry-dist/radix-lyra/modal.json | 56 ++++ registry-dist/radix-maia/modal.json | 56 ++++ registry-dist/radix-mira/modal.json | 56 ++++ registry-dist/radix-nova/modal.json | 56 ++++ registry-dist/radix-rhea/modal.json | 56 ++++ registry-dist/radix-sera/modal.json | 56 ++++ registry-dist/radix-vega/modal.json | 56 ++++ registry.json | 15 ++ registry/bases/base/ui/modal.tsx | 345 +++++++++++++++++++++++++ registry/bases/radix/ui/modal.tsx | 345 +++++++++++++++++++++++++ registry/styles/style-luma.css | 3 + registry/styles/style-lyra.css | 3 + registry/styles/style-maia.css | 3 + registry/styles/style-mira.css | 3 + registry/styles/style-nova.css | 3 + registry/styles/style-rhea.css | 3 + registry/styles/style-sera.css | 3 + registry/styles/style-vega.css | 3 + scripts/build-registry.ts | 65 +++++ vite.config.ts | 1 + 33 files changed, 2033 insertions(+) create mode 100644 app/showcase/components/modal-demo.tsx create mode 100644 app/ui/dialog.tsx create mode 100644 registry-dist/base-luma/modal.json create mode 100644 registry-dist/base-lyra/modal.json create mode 100644 registry-dist/base-maia/modal.json create mode 100644 registry-dist/base-mira/modal.json create mode 100644 registry-dist/base-nova/modal.json create mode 100644 registry-dist/base-rhea/modal.json create mode 100644 registry-dist/base-sera/modal.json create mode 100644 registry-dist/base-vega/modal.json create mode 100644 registry-dist/radix-luma/modal.json create mode 100644 registry-dist/radix-lyra/modal.json create mode 100644 registry-dist/radix-maia/modal.json create mode 100644 registry-dist/radix-mira/modal.json create mode 100644 registry-dist/radix-nova/modal.json create mode 100644 registry-dist/radix-rhea/modal.json create mode 100644 registry-dist/radix-sera/modal.json create mode 100644 registry-dist/radix-vega/modal.json create mode 100644 registry/bases/base/ui/modal.tsx create mode 100644 registry/bases/radix/ui/modal.tsx diff --git a/app/app.css b/app/app.css index 11fb9cd..a543de3 100644 --- a/app/app.css +++ b/app/app.css @@ -139,3 +139,44 @@ cursor: pointer; } } + +/* + * Modal — replace-style inner navigation (ModalViews). The active ModalView + * animates in place on push/pop; `data-direction` flips the offset so forward + * and back read differently. Shipped to consumers via the modal registry item's + * `css` field (scripts/build-registry.ts); kept here so the showcase matches. + */ +@keyframes modal-view-forward { + from { + opacity: 0; + transform: translateY(0.5rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes modal-view-back { + from { + opacity: 0; + transform: translateY(-0.375rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +[data-slot="modal-view"] { + animation: modal-view-forward 200ms ease-out; +} +[data-slot="modal-view"][data-direction="back"] { + animation-name: modal-view-back; +} +[data-slot="modal-view"][data-direction="none"] { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + [data-slot="modal-view"] { + animation: none; + } +} diff --git a/app/showcase/components/demos.tsx b/app/showcase/components/demos.tsx index a567204..e2ea1b3 100644 --- a/app/showcase/components/demos.tsx +++ b/app/showcase/components/demos.tsx @@ -1,4 +1,5 @@ import { ChoiceboxExamples, ChoiceboxPreview } from "./choicebox-demo"; +import { ModalExamples, ModalPreview } from "./modal-demo"; // The gallery's source of truth: one entry per published crescent-ui component. // `preview` is the hero shown in the preview frame; `examples` are the extra @@ -22,6 +23,14 @@ export const demos: Record< preview: , examples: , }, + modal: { + title: "Modal", + description: + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation, and a pinned footer.", + sourceFile: "ui/modal.tsx", + preview: , + examples: , + }, }; export const demoOrder = Object.keys(demos); diff --git a/app/showcase/components/modal-demo.tsx b/app/showcase/components/modal-demo.tsx new file mode 100644 index 0000000..b794c25 --- /dev/null +++ b/app/showcase/components/modal-demo.tsx @@ -0,0 +1,145 @@ +import { + Modal, + ModalAside, + ModalBody, + ModalClose, + ModalColumn, + ModalColumns, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + ModalTrigger, + ModalView, + ModalViews, + ModalViewsBack, + useModalViews, +} from "@registry/bases/base/ui/modal"; +import { Button } from "~/ui/button"; + +import { Example } from "./example"; + +const PARAGRAPHS = Array.from( + { length: 8 }, + (_, i) => + `Paragraph ${i + 1}. The body is the single scroll region between the pinned header and footer — long content scrolls here while the chrome stays put.`, +); + +// The hero — a framed dialog: pinned header + footer, the body owns the scroll. +export function ModalPreview() { + return ( + + Open dialog} /> + + + Framed dialog + + Header and footer pin; the body owns the only scroll. + + + + {PARAGRAPHS.map((p) => ( +

+ {p} +

+ ))} +
+ + Cancel} /> + Save} /> + +
+
+ ); +} + +export function ModalExamples() { + return ( +
+ + + Open} /> + + + Choose an option + + + + {Array.from({ length: 6 }, (_, i) => ( +

+ Primary column item {i + 1}. This column scrolls + independently of the aside. +

+ ))} +
+ +

+ Summary +

+

+ The trailing column is a distinguished muted panel — for a + value-prop, a preview, or a running summary. +

+
+
+ + Continue} /> + +
+
+
+ + + + Open} /> + + + + + Settings + + + + + + +

+ Profile settings. The previous view is replaced in place; + Back pops the stack. +

+
+ +

+ Billing settings. Each destination is its own view, mounted + only when active. +

+
+
+
+
+
+
+
+ ); +} + +function ViewsHome() { + const { push } = useModalViews(); + return ( +
+ + +
+ ); +} diff --git a/app/ui/dialog.tsx b/app/ui/dialog.tsx new file mode 100644 index 0000000..4ba1f2b --- /dev/null +++ b/app/ui/dialog.tsx @@ -0,0 +1,147 @@ +"use client"; + +import * as React from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "~/ui/button"; + +// Dialog — the Base UI Dialog primitive dressed in crescent chrome. It is the +// raw popup the higher-level `Modal` family (registry/bases/*/ui/modal) composes +// on top of; on its own it's the standard centered dialog box. +// +// Style-tunable shape lives in the `cn-dialog-content` token (radius, per style +// — see registry/styles/*.css); colors/spacing/animation stay inline since they +// track the theme, not the style. + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton ? ( + + } + > + + Close + + ) : null} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/registry-dist/base-luma/modal.json b/registry-dist/base-luma/modal.json new file mode 100644 index 0000000..94fe1cd --- /dev/null +++ b/registry-dist/base-luma/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-luma/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-lyra/modal.json b/registry-dist/base-lyra/modal.json new file mode 100644 index 0000000..ecc43a6 --- /dev/null +++ b/registry-dist/base-lyra/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-lyra/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-maia/modal.json b/registry-dist/base-maia/modal.json new file mode 100644 index 0000000..1341315 --- /dev/null +++ b/registry-dist/base-maia/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-maia/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-mira/modal.json b/registry-dist/base-mira/modal.json new file mode 100644 index 0000000..ff113b6 --- /dev/null +++ b/registry-dist/base-mira/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-mira/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-nova/modal.json b/registry-dist/base-nova/modal.json new file mode 100644 index 0000000..572daba --- /dev/null +++ b/registry-dist/base-nova/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-nova/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-rhea/modal.json b/registry-dist/base-rhea/modal.json new file mode 100644 index 0000000..fcc425a --- /dev/null +++ b/registry-dist/base-rhea/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-rhea/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-sera/modal.json b/registry-dist/base-sera/modal.json new file mode 100644 index 0000000..4f29337 --- /dev/null +++ b/registry-dist/base-sera/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-sera/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-vega/modal.json b/registry-dist/base-vega/modal.json new file mode 100644 index 0000000..51b5d47 --- /dev/null +++ b/registry-dist/base-vega/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/base-vega/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-luma/modal.json b/registry-dist/radix-luma/modal.json new file mode 100644 index 0000000..12b6a8e --- /dev/null +++ b/registry-dist/radix-luma/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-luma/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-lyra/modal.json b/registry-dist/radix-lyra/modal.json new file mode 100644 index 0000000..9229776 --- /dev/null +++ b/registry-dist/radix-lyra/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-lyra/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-maia/modal.json b/registry-dist/radix-maia/modal.json new file mode 100644 index 0000000..2fbc507 --- /dev/null +++ b/registry-dist/radix-maia/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-maia/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-mira/modal.json b/registry-dist/radix-mira/modal.json new file mode 100644 index 0000000..353230d --- /dev/null +++ b/registry-dist/radix-mira/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-mira/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-nova/modal.json b/registry-dist/radix-nova/modal.json new file mode 100644 index 0000000..85cb1a9 --- /dev/null +++ b/registry-dist/radix-nova/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-nova/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-rhea/modal.json b/registry-dist/radix-rhea/modal.json new file mode 100644 index 0000000..b1f6670 --- /dev/null +++ b/registry-dist/radix-rhea/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-rhea/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-sera/modal.json b/registry-dist/radix-sera/modal.json new file mode 100644 index 0000000..e58c788 --- /dev/null +++ b/registry-dist/radix-sera/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-sera/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-vega/modal.json b/registry-dist/radix-vega/modal.json new file mode 100644 index 0000000..97f9446 --- /dev/null +++ b/registry-dist/radix-vega/modal.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button" + ], + "files": [ + { + "path": "registry/radix-vega/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry.json b/registry.json index fa227f3..219e0f6 100644 --- a/registry.json +++ b/registry.json @@ -30,6 +30,21 @@ "target": "components/ui/choicebox.tsx" } ] + }, + { + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable. Base-agnostic — it composes the consumer's dialog + button.", + "dependencies": [], + "registryDependencies": ["dialog", "button"], + "files": [ + { + "path": "registry/bases/base/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx" + } + ] } ] } diff --git a/registry/bases/base/ui/modal.tsx b/registry/bases/base/ui/modal.tsx new file mode 100644 index 0000000..d5d3a67 --- /dev/null +++ b/registry/bases/base/ui/modal.tsx @@ -0,0 +1,345 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { IconPlaceholder } from "@/ui/icon-placeholder"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/ui/dialog"; + +// Modal — the composable modal-layout system: a namespaced compound family that +// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes +// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds +// the cohesive *layout* every dialog should share: a pinned header, a single +// scrolling body, an optional muted-aside second column, replace-style inner +// navigation (ModalViews) — and a pinned footer, all combinable. +// +// This is the registry SOURCE: it is intentionally icon-library agnostic +// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track +// the theme and the dialog's corner radius is the one style-tunable token +// (cn-dialog-content). The replace-style view transition rides the +// `[data-slot=modal-view]` animation shipped in this item's `css`. +// +// References (the resolved, app-level implementations this is generalized from): +// - Post for Me · post-for-me-dashboard (app modal-layout system) +// - DXLogic · web/app/components/modal (Modal / ModalViews family) +// +// Anatomy (compound): +// +// Open} /> +// +// +// +// +// +// +// +// +// + +// `layout="framed"` (default) makes the popup a bounded flex column so the +// header/footer pin and the body owns the only scroll; `layout="simple"` keeps +// the plain dialog box. The layout rides ModalLayoutContext so the header/footer +// self-pad only when framed. +type ModalLayout = "simple" | "framed"; + +const ModalLayoutContext = React.createContext("framed"); + +function useModalLayout() { + return React.useContext(ModalLayoutContext); +} + +// Root + trigger + close + a11y title/description are the Dialog primitives, +// re-exported under the Modal namespace so a consumer assembles one family. +const Modal = Dialog; +const ModalTrigger = DialogTrigger; +const ModalClose = DialogClose; +const ModalTitle = DialogTitle; +const ModalDescription = DialogDescription; + +function ModalContent({ + layout = "framed", + className, + children, + ...props +}: React.ComponentProps & { layout?: ModalLayout }) { + return ( + + + {children} + + + ); +} + +function ModalHeader({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +function ModalBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// The body split into two columns: a primary ModalColumn and a distinguished +// ModalAside (muted panel). Container-query responsive — side-by-side when the +// modal is wide, stacked when narrow. +// +// Flex (not grid) so the columns stay bounded to the available height and scroll +// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the +// height is definite via flexbox. `items-stretch` makes both columns full-height, +// so the aside's panel background fills the whole side. +function ModalColumns({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+ {children} +
+
+ ); +} + +function ModalColumn({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalAside({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +// ModalViews — the replace-style inner navigation: a push/pop view stack that +// swaps the active view *in place* (a subtle transition, NOT a horizontal +// track). Use it when a dialog drills into sub-views and back (a settings panel, +// a branching wizard). +// +// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a +// destination rendered only when active; `useModalViews` drives navigation +// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when +// there's nothing to pop. +type ModalViewsDirection = "forward" | "back" | "none"; + +type ModalViewsContextValue = { + active: string; + stack: string[]; + canGoBack: boolean; + direction: ModalViewsDirection; + push: (view: string) => void; + pop: () => void; + replace: (view: string) => void; + reset: (view?: string) => void; +}; + +const ModalViewsContext = React.createContext( + null +); + +function useModalViews() { + const ctx = React.useContext(ModalViewsContext); + if (!ctx) { + throw new Error("useModalViews must be used within "); + } + return ctx; +} + +function ModalViews({ + defaultView, + className, + children, + ...props +}: React.ComponentProps<"div"> & { defaultView: string }) { + const [stack, setStack] = React.useState([defaultView]); + const [direction, setDirection] = React.useState("none"); + const active = stack[stack.length - 1]; + + const push = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s, view]); + }, []); + const pop = React.useCallback(() => { + setDirection("back"); + setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + }, []); + const replace = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s.slice(0, -1), view]); + }, []); + const reset = React.useCallback( + (view?: string) => { + setDirection("back"); + setStack([view ?? defaultView]); + }, + [defaultView] + ); + + return ( + 1, + direction, + push, + pop, + replace, + reset, + }} + > +
+ {children} +
+
+ ); +} + +function ModalView({ + value, + className, + ...props +}: React.ComponentProps<"div"> & { value: string }) { + const { active, direction } = useModalViews(); + if (active !== value) return null; + return ( + // `key` remounts on view change so the enter animation replays; only the + // active view is mounted (a true replace, not a track). `data-direction` + // (see this item's css) gives push vs pop a slightly different in-place + // motion. +
+ ); +} + +function ModalViewsBack({ + className, + label = "Back", + ...props +}: React.ComponentProps & { label?: string }) { + const { canGoBack, pop } = useModalViews(); + if (!canGoBack) return null; + return ( + + ); +} + +export { + Modal, + ModalTrigger, + ModalClose, + ModalContent, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalColumns, + ModalColumn, + ModalAside, + ModalFooter, + ModalViews, + ModalView, + ModalViewsBack, + useModalLayout, + useModalViews, + type ModalLayout, +}; diff --git a/registry/bases/radix/ui/modal.tsx b/registry/bases/radix/ui/modal.tsx new file mode 100644 index 0000000..d5d3a67 --- /dev/null +++ b/registry/bases/radix/ui/modal.tsx @@ -0,0 +1,345 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { IconPlaceholder } from "@/ui/icon-placeholder"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/ui/dialog"; + +// Modal — the composable modal-layout system: a namespaced compound family that +// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes +// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds +// the cohesive *layout* every dialog should share: a pinned header, a single +// scrolling body, an optional muted-aside second column, replace-style inner +// navigation (ModalViews) — and a pinned footer, all combinable. +// +// This is the registry SOURCE: it is intentionally icon-library agnostic +// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track +// the theme and the dialog's corner radius is the one style-tunable token +// (cn-dialog-content). The replace-style view transition rides the +// `[data-slot=modal-view]` animation shipped in this item's `css`. +// +// References (the resolved, app-level implementations this is generalized from): +// - Post for Me · post-for-me-dashboard (app modal-layout system) +// - DXLogic · web/app/components/modal (Modal / ModalViews family) +// +// Anatomy (compound): +// +// Open} /> +// +// +// +// +// +// +// +// +// + +// `layout="framed"` (default) makes the popup a bounded flex column so the +// header/footer pin and the body owns the only scroll; `layout="simple"` keeps +// the plain dialog box. The layout rides ModalLayoutContext so the header/footer +// self-pad only when framed. +type ModalLayout = "simple" | "framed"; + +const ModalLayoutContext = React.createContext("framed"); + +function useModalLayout() { + return React.useContext(ModalLayoutContext); +} + +// Root + trigger + close + a11y title/description are the Dialog primitives, +// re-exported under the Modal namespace so a consumer assembles one family. +const Modal = Dialog; +const ModalTrigger = DialogTrigger; +const ModalClose = DialogClose; +const ModalTitle = DialogTitle; +const ModalDescription = DialogDescription; + +function ModalContent({ + layout = "framed", + className, + children, + ...props +}: React.ComponentProps & { layout?: ModalLayout }) { + return ( + + + {children} + + + ); +} + +function ModalHeader({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +function ModalBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// The body split into two columns: a primary ModalColumn and a distinguished +// ModalAside (muted panel). Container-query responsive — side-by-side when the +// modal is wide, stacked when narrow. +// +// Flex (not grid) so the columns stay bounded to the available height and scroll +// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the +// height is definite via flexbox. `items-stretch` makes both columns full-height, +// so the aside's panel background fills the whole side. +function ModalColumns({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+ {children} +
+
+ ); +} + +function ModalColumn({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalAside({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +// ModalViews — the replace-style inner navigation: a push/pop view stack that +// swaps the active view *in place* (a subtle transition, NOT a horizontal +// track). Use it when a dialog drills into sub-views and back (a settings panel, +// a branching wizard). +// +// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a +// destination rendered only when active; `useModalViews` drives navigation +// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when +// there's nothing to pop. +type ModalViewsDirection = "forward" | "back" | "none"; + +type ModalViewsContextValue = { + active: string; + stack: string[]; + canGoBack: boolean; + direction: ModalViewsDirection; + push: (view: string) => void; + pop: () => void; + replace: (view: string) => void; + reset: (view?: string) => void; +}; + +const ModalViewsContext = React.createContext( + null +); + +function useModalViews() { + const ctx = React.useContext(ModalViewsContext); + if (!ctx) { + throw new Error("useModalViews must be used within "); + } + return ctx; +} + +function ModalViews({ + defaultView, + className, + children, + ...props +}: React.ComponentProps<"div"> & { defaultView: string }) { + const [stack, setStack] = React.useState([defaultView]); + const [direction, setDirection] = React.useState("none"); + const active = stack[stack.length - 1]; + + const push = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s, view]); + }, []); + const pop = React.useCallback(() => { + setDirection("back"); + setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + }, []); + const replace = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s.slice(0, -1), view]); + }, []); + const reset = React.useCallback( + (view?: string) => { + setDirection("back"); + setStack([view ?? defaultView]); + }, + [defaultView] + ); + + return ( + 1, + direction, + push, + pop, + replace, + reset, + }} + > +
+ {children} +
+
+ ); +} + +function ModalView({ + value, + className, + ...props +}: React.ComponentProps<"div"> & { value: string }) { + const { active, direction } = useModalViews(); + if (active !== value) return null; + return ( + // `key` remounts on view change so the enter animation replays; only the + // active view is mounted (a true replace, not a track). `data-direction` + // (see this item's css) gives push vs pop a slightly different in-place + // motion. +
+ ); +} + +function ModalViewsBack({ + className, + label = "Back", + ...props +}: React.ComponentProps & { label?: string }) { + const { canGoBack, pop } = useModalViews(); + if (!canGoBack) return null; + return ( + + ); +} + +export { + Modal, + ModalTrigger, + ModalClose, + ModalContent, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalColumns, + ModalColumn, + ModalAside, + ModalFooter, + ModalViews, + ModalView, + ModalViewsBack, + useModalLayout, + useModalViews, + type ModalLayout, +}; diff --git a/registry/styles/style-luma.css b/registry/styles/style-luma.css index 3f93fad..b5e331f 100644 --- a/registry/styles/style-luma.css +++ b/registry/styles/style-luma.css @@ -51,6 +51,9 @@ .cn-select-item { @apply rounded-2xl; } + .cn-dialog-content { + @apply rounded-3xl; + } .cn-card { @apply rounded-3xl; } diff --git a/registry/styles/style-lyra.css b/registry/styles/style-lyra.css index 05ddbe7..ada5707 100644 --- a/registry/styles/style-lyra.css +++ b/registry/styles/style-lyra.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-none; } + .cn-dialog-content { + @apply rounded-none; + } .cn-card { @apply rounded-none; } diff --git a/registry/styles/style-maia.css b/registry/styles/style-maia.css index c1d8683..a0c2b47 100644 --- a/registry/styles/style-maia.css +++ b/registry/styles/style-maia.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-lg; } + .cn-dialog-content { + @apply rounded-2xl; + } .cn-card { @apply rounded-2xl; } diff --git a/registry/styles/style-mira.css b/registry/styles/style-mira.css index e16d88f..60a7989 100644 --- a/registry/styles/style-mira.css +++ b/registry/styles/style-mira.css @@ -63,6 +63,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-lg; + } .cn-card { @apply rounded-xl; } diff --git a/registry/styles/style-nova.css b/registry/styles/style-nova.css index f1f30f4..f7e1755 100644 --- a/registry/styles/style-nova.css +++ b/registry/styles/style-nova.css @@ -49,6 +49,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-xl; + } .cn-card { @apply rounded-xl; } diff --git a/registry/styles/style-rhea.css b/registry/styles/style-rhea.css index cbff236..9c2719e 100644 --- a/registry/styles/style-rhea.css +++ b/registry/styles/style-rhea.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-xl; } + .cn-dialog-content { + @apply rounded-2xl; + } .cn-card { @apply rounded-2xl; } diff --git a/registry/styles/style-sera.css b/registry/styles/style-sera.css index 80fac8c..0b17edf 100644 --- a/registry/styles/style-sera.css +++ b/registry/styles/style-sera.css @@ -58,6 +58,9 @@ .cn-select-item { @apply rounded-none; } + .cn-dialog-content { + @apply rounded-none; + } .cn-card { @apply rounded-none; } diff --git a/registry/styles/style-vega.css b/registry/styles/style-vega.css index 4ef7507..e09ac49 100644 --- a/registry/styles/style-vega.css +++ b/registry/styles/style-vega.css @@ -49,6 +49,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-xl; + } .cn-card { @apply rounded-xl; } diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index f2cc629..f57d8d8 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -30,6 +30,12 @@ type Item = { title: string; description: string; dependencies: string[]; + // Other registry items this one installs alongside (shadcn `shadcn add` + // resolves these too) — e.g. modal builds on the consumer's dialog + button. + registryDependencies?: string[]; + // Arbitrary CSS the item injects into the consumer's stylesheet (keyframes, + // data-slot rules). Same shape shadcn's registry-item `css` field expects. + css?: Record; // Source path relative to registry/bases//, and install target. file: string; target: string; @@ -61,6 +67,39 @@ const CHOICEBOX_BASE_DESCRIPTION = const CHOICEBOX_RADIX_DESCRIPTION = "The Radix variant of Choicebox, built on the Radix Toggle Group (type=\"single\" | \"multiple\")."; +const MODAL_DESCRIPTION = + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable."; + +// Modal source is base-agnostic (it composes the consumer's dialog + button via +// `@/ui/*`, never a base primitive), so both variants ship the same file and +// pull in whichever dialog the consumer's base provides. +const MODAL_REGISTRY_DEPENDENCIES = ["dialog", "button"]; + +// The ModalViews replace-style transition. Injected into the consumer's +// stylesheet on install; the showcase mirrors it in app/app.css. +const MODAL_CSS: Record = { + "@keyframes modal-view-forward": { + from: { opacity: "0", transform: "translateY(0.5rem)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "@keyframes modal-view-back": { + from: { opacity: "0", transform: "translateY(-0.375rem)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "[data-slot=modal-view]": { + animation: "modal-view-forward 200ms ease-out", + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back", + }, + "[data-slot=modal-view][data-direction=none]": { + animation: "none", + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { animation: "none" }, + }, +}; + const BASES: Base[] = [ { name: "base", @@ -74,6 +113,17 @@ const BASES: Base[] = [ file: "ui/choicebox.tsx", target: "components/ui/choicebox.tsx", }, + { + name: "modal", + type: "registry:ui", + title: "Modal", + description: MODAL_DESCRIPTION, + dependencies: [], + registryDependencies: MODAL_REGISTRY_DEPENDENCIES, + css: MODAL_CSS, + file: "ui/modal.tsx", + target: "components/ui/modal.tsx", + }, ], }, { @@ -88,6 +138,17 @@ const BASES: Base[] = [ file: "ui/choicebox.tsx", target: "components/ui/choicebox.tsx", }, + { + name: "modal", + type: "registry:ui", + title: "Modal", + description: MODAL_DESCRIPTION, + dependencies: [], + registryDependencies: MODAL_REGISTRY_DEPENDENCIES, + css: MODAL_CSS, + file: "ui/modal.tsx", + target: "components/ui/modal.tsx", + }, ], }, ]; @@ -147,6 +208,9 @@ async function build() { title: item.title, description: item.description, dependencies: item.dependencies, + ...(item.registryDependencies + ? { registryDependencies: item.registryDependencies } + : {}), files: [ { path: `registry/${base.name}-${style}/${item.file}`, @@ -155,6 +219,7 @@ async function build() { content, }, ], + ...(item.css ? { css: item.css } : {}), }; const dir = path.join(outRoot, `${base.name}-${style}`); diff --git a/vite.config.ts b/vite.config.ts index 3417b75..cd29268 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "@base-ui/react/toggle-group", "@base-ui/react/select", "@base-ui/react/tooltip", + "@base-ui/react/dialog", ], }, }); From e65037cff4680dfd0617842087fa776a2f2ee0c6 Mon Sep 17 00:00:00 2001 From: Caleb Panza <45076058+calebpanza@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:02:57 -0400 Subject: [PATCH 2/2] feat: add ModalCarousel slice to complete the Modal family Add the slidable carousel variation (ModalCarousel, ModalCarouselViewport, ModalSlide, ModalCarouselDots, ModalCarouselNav, useModalCarousel) to the Modal registry source, completing the full family reconciled from the Post for Me and DXLogic references. - Merge carousel parts into the single registry modal.tsx (base + radix), composing the consumer's stock shadcn carousel via @/ui/carousel - Add carousel to modal's registryDependencies (alongside dialog + button) - Add app/ui/carousel.tsx (embla) so the showcase renders the carousel - Add a Slidable carousel showcase example - Rebuild registry-dist (32 items, no unresolved cn-*) --- app/showcase/components/demos.tsx | 2 +- app/showcase/components/modal-demo.tsx | 51 +++++ app/ui/carousel.tsx | 248 +++++++++++++++++++++++++ bun.lock | 7 + package.json | 1 + registry-dist/base-luma/modal.json | 7 +- registry-dist/base-lyra/modal.json | 7 +- registry-dist/base-maia/modal.json | 7 +- registry-dist/base-mira/modal.json | 7 +- registry-dist/base-nova/modal.json | 7 +- registry-dist/base-rhea/modal.json | 7 +- registry-dist/base-sera/modal.json | 7 +- registry-dist/base-vega/modal.json | 7 +- registry-dist/radix-luma/modal.json | 7 +- registry-dist/radix-lyra/modal.json | 7 +- registry-dist/radix-maia/modal.json | 7 +- registry-dist/radix-mira/modal.json | 7 +- registry-dist/radix-nova/modal.json | 7 +- registry-dist/radix-rhea/modal.json | 7 +- registry-dist/radix-sera/modal.json | 7 +- registry-dist/radix-vega/modal.json | 7 +- registry.json | 4 +- registry/bases/base/ui/modal.tsx | 200 +++++++++++++++++++- registry/bases/radix/ui/modal.tsx | 200 +++++++++++++++++++- scripts/build-registry.ts | 10 +- 25 files changed, 773 insertions(+), 62 deletions(-) create mode 100644 app/ui/carousel.tsx diff --git a/app/showcase/components/demos.tsx b/app/showcase/components/demos.tsx index e2ea1b3..1af06ef 100644 --- a/app/showcase/components/demos.tsx +++ b/app/showcase/components/demos.tsx @@ -26,7 +26,7 @@ export const demos: Record< modal: { title: "Modal", description: - "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation, and a pinned footer.", + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel, replace-style inner navigation, and a pinned footer.", sourceFile: "ui/modal.tsx", preview: , examples: , diff --git a/app/showcase/components/modal-demo.tsx b/app/showcase/components/modal-demo.tsx index b794c25..b9af196 100644 --- a/app/showcase/components/modal-demo.tsx +++ b/app/showcase/components/modal-demo.tsx @@ -2,6 +2,10 @@ import { Modal, ModalAside, ModalBody, + ModalCarousel, + ModalCarouselDots, + ModalCarouselNav, + ModalCarouselViewport, ModalClose, ModalColumn, ModalColumns, @@ -9,6 +13,7 @@ import { ModalDescription, ModalFooter, ModalHeader, + ModalSlide, ModalTitle, ModalTrigger, ModalView, @@ -26,6 +31,21 @@ const PARAGRAPHS = Array.from( `Paragraph ${i + 1}. The body is the single scroll region between the pinned header and footer — long content scrolls here while the chrome stays put.`, ); +const SLIDES = [ + { + title: "Welcome", + body: "The carousel steps through ordered slides — drag is off, so progress is deliberate.", + }, + { + title: "One step at a time", + body: "The footer's nav drives the track; the dots track which slide is active.", + }, + { + title: "All set", + body: "On the last slide Next becomes Finish — wire onFinish to close or advance.", + }, +]; + // The hero — a framed dialog: pinned header + footer, the body owns the scroll. export function ModalPreview() { return ( @@ -93,6 +113,37 @@ export function ModalExamples() { + + + Open tour} /> + + + + {SLIDES.map((slide) => ( + +
+

+ {slide.title} +

+

+ {slide.body} +

+
+
+ ))} +
+ + + + +
+
+
+
+ ; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/bun.lock b/bun.lock index 883653b..926b149 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@tabler/icons-react": "^3.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "isbot": "^5", "lucide-react": "^1.21.0", "motion": "^12.40.0", @@ -641,6 +642,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], diff --git a/package.json b/package.json index 11b1558..680d6b4 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tabler/icons-react": "^3.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-react": "^8.6.0", "isbot": "^5", "lucide-react": "^1.21.0", "motion": "^12.40.0", diff --git a/registry-dist/base-luma/modal.json b/registry-dist/base-luma/modal.json index 94fe1cd..ed7eef5 100644 --- a/registry-dist/base-luma/modal.json +++ b/registry-dist/base-luma/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-luma/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-lyra/modal.json b/registry-dist/base-lyra/modal.json index ecc43a6..6facda6 100644 --- a/registry-dist/base-lyra/modal.json +++ b/registry-dist/base-lyra/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-lyra/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n
\n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-maia/modal.json b/registry-dist/base-maia/modal.json index 1341315..267a373 100644 --- a/registry-dist/base-maia/modal.json +++ b/registry-dist/base-maia/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-maia/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-mira/modal.json b/registry-dist/base-mira/modal.json index ff113b6..14d1225 100644 --- a/registry-dist/base-mira/modal.json +++ b/registry-dist/base-mira/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-mira/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-nova/modal.json b/registry-dist/base-nova/modal.json index 572daba..efe76a1 100644 --- a/registry-dist/base-nova/modal.json +++ b/registry-dist/base-nova/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-nova/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-rhea/modal.json b/registry-dist/base-rhea/modal.json index fcc425a..a210ae4 100644 --- a/registry-dist/base-rhea/modal.json +++ b/registry-dist/base-rhea/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-rhea/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-sera/modal.json b/registry-dist/base-sera/modal.json index 4f29337..892fd08 100644 --- a/registry-dist/base-sera/modal.json +++ b/registry-dist/base-sera/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-sera/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/base-vega/modal.json b/registry-dist/base-vega/modal.json index 51b5d47..ebe9c01 100644 --- a/registry-dist/base-vega/modal.json +++ b/registry-dist/base-vega/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/base-vega/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-luma/modal.json b/registry-dist/radix-luma/modal.json index 12b6a8e..7932c99 100644 --- a/registry-dist/radix-luma/modal.json +++ b/registry-dist/radix-luma/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-luma/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-lyra/modal.json b/registry-dist/radix-lyra/modal.json index 9229776..70b6257 100644 --- a/registry-dist/radix-lyra/modal.json +++ b/registry-dist/radix-lyra/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-lyra/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-maia/modal.json b/registry-dist/radix-maia/modal.json index 2fbc507..6344923 100644 --- a/registry-dist/radix-maia/modal.json +++ b/registry-dist/radix-maia/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-maia/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-mira/modal.json b/registry-dist/radix-mira/modal.json index 353230d..4cd5179 100644 --- a/registry-dist/radix-mira/modal.json +++ b/registry-dist/radix-mira/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-mira/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-nova/modal.json b/registry-dist/radix-nova/modal.json index 85cb1a9..5fccbf3 100644 --- a/registry-dist/radix-nova/modal.json +++ b/registry-dist/radix-nova/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-nova/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-rhea/modal.json b/registry-dist/radix-rhea/modal.json index b1f6670..6419ed2 100644 --- a/registry-dist/radix-rhea/modal.json +++ b/registry-dist/radix-rhea/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-rhea/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-sera/modal.json b/registry-dist/radix-sera/modal.json index e58c788..c27d7cb 100644 --- a/registry-dist/radix-sera/modal.json +++ b/registry-dist/radix-sera/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-sera/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry-dist/radix-vega/modal.json b/registry-dist/radix-vega/modal.json index 97f9446..43f289a 100644 --- a/registry-dist/radix-vega/modal.json +++ b/registry-dist/radix-vega/modal.json @@ -3,18 +3,19 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", "dependencies": [], "registryDependencies": [ "dialog", - "button" + "button", + "carousel" ], "files": [ { "path": "registry/radix-vega/ui/modal.tsx", "type": "registry:ui", "target": "components/ui/modal.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, replace-style inner\n// navigation (ModalViews) — and a pinned footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`.\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalViews,\n type ModalLayout,\n};\n" + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" } ], "css": { diff --git a/registry.json b/registry.json index 219e0f6..86d8deb 100644 --- a/registry.json +++ b/registry.json @@ -35,9 +35,9 @@ "name": "modal", "type": "registry:ui", "title": "Modal", - "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable. Base-agnostic — it composes the consumer's dialog + button.", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable. Base-agnostic — it composes the consumer's dialog + button + carousel.", "dependencies": [], - "registryDependencies": ["dialog", "button"], + "registryDependencies": ["dialog", "button", "carousel"], "files": [ { "path": "registry/bases/base/ui/modal.tsx", diff --git a/registry/bases/base/ui/modal.tsx b/registry/bases/base/ui/modal.tsx index d5d3a67..bb7334b 100644 --- a/registry/bases/base/ui/modal.tsx +++ b/registry/bases/base/ui/modal.tsx @@ -5,6 +5,12 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { IconPlaceholder } from "@/ui/icon-placeholder"; import { Button } from "@/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + type CarouselApi, +} from "@/ui/carousel"; import { Dialog, DialogClose, @@ -18,14 +24,16 @@ import { // sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes // internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds // the cohesive *layout* every dialog should share: a pinned header, a single -// scrolling body, an optional muted-aside second column, replace-style inner -// navigation (ModalViews) — and a pinned footer, all combinable. +// scrolling body, an optional muted-aside second column, a slidable carousel +// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned +// footer, all combinable. // // This is the registry SOURCE: it is intentionally icon-library agnostic // (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track // the theme and the dialog's corner radius is the one style-tunable token // (cn-dialog-content). The replace-style view transition rides the -// `[data-slot=modal-view]` animation shipped in this item's `css`. +// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel +// track is the consumer's `carousel` primitive (a registry dependency). // // References (the resolved, app-level implementations this is generalized from): // - Post for Me · post-for-me-dashboard (app modal-layout system) @@ -184,6 +192,186 @@ function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { ); } +// ModalCarousel — the slidable variation: ordered horizontal slides with a +// deliberate (button-driven) step, generalizing onboarding / tour carousels. +// Drag is off — stepping is via ModalCarouselNav. +// +// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track +// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with +// ModalCarouselDots + ModalCarouselNav all read the same carousel state: +// +// +// +// +// +// +// +// +// +// +// +// +// +// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down +// within a step. +type ModalCarouselContextValue = { + index: number; + total: number; + isFirst: boolean; + isLast: boolean; + setApi: (api: CarouselApi) => void; + scrollNext: () => void; + scrollPrev: () => void; +}; + +const ModalCarouselContext = + React.createContext(null); + +function useModalCarousel() { + const ctx = React.useContext(ModalCarouselContext); + if (!ctx) { + throw new Error("useModalCarousel must be used within "); + } + return ctx; +} + +function ModalCarousel({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const [api, setApi] = React.useState(); + const [index, setIndex] = React.useState(0); + const [total, setTotal] = React.useState(0); + + React.useEffect(() => { + if (!api) return; + const update = () => { + setIndex(api.selectedScrollSnap()); + setTotal(api.scrollSnapList().length); + }; + update(); + api.on("select", update); + api.on("reInit", update); + return () => { + api.off("select", update); + api.off("reInit", update); + }; + }, [api]); + + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + + return ( + +
+ {children} +
+
+ ); +} + +// The embla track. Lives inside ModalCarousel; holds ModalSlides. +function ModalCarouselViewport({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const { setApi } = useModalCarousel(); + return ( + + {children} + + ); +} + +function ModalSlide({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function ModalCarouselDots({ className }: { className?: string }) { + const { index, total } = useModalCarousel(); + return ( +
+ {Array.from({ length: total }, (_, i) => ( + + ))} +
+ ); +} + +function ModalCarouselNav({ + backLabel = "Back", + nextLabel = "Next", + finishLabel = "Finish", + onFinish, + className, +}: { + backLabel?: string; + nextLabel?: string; + finishLabel?: string; + onFinish?: () => void; + className?: string; +}) { + const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel(); + return ( +
+ {!isFirst ? ( + + ) : null} + {isLast ? ( + + ) : ( + + )} +
+ ); +} + // ModalViews — the replace-style inner navigation: a push/pop view stack that // swaps the active view *in place* (a subtle transition, NOT a horizontal // track). Use it when a dialog drills into sub-views and back (a settings panel, @@ -336,10 +524,16 @@ export { ModalColumn, ModalAside, ModalFooter, + ModalCarousel, + ModalCarouselViewport, + ModalSlide, + ModalCarouselDots, + ModalCarouselNav, ModalViews, ModalView, ModalViewsBack, useModalLayout, + useModalCarousel, useModalViews, type ModalLayout, }; diff --git a/registry/bases/radix/ui/modal.tsx b/registry/bases/radix/ui/modal.tsx index d5d3a67..bb7334b 100644 --- a/registry/bases/radix/ui/modal.tsx +++ b/registry/bases/radix/ui/modal.tsx @@ -5,6 +5,12 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { IconPlaceholder } from "@/ui/icon-placeholder"; import { Button } from "@/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + type CarouselApi, +} from "@/ui/carousel"; import { Dialog, DialogClose, @@ -18,14 +24,16 @@ import { // sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes // internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds // the cohesive *layout* every dialog should share: a pinned header, a single -// scrolling body, an optional muted-aside second column, replace-style inner -// navigation (ModalViews) — and a pinned footer, all combinable. +// scrolling body, an optional muted-aside second column, a slidable carousel +// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned +// footer, all combinable. // // This is the registry SOURCE: it is intentionally icon-library agnostic // (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track // the theme and the dialog's corner radius is the one style-tunable token // (cn-dialog-content). The replace-style view transition rides the -// `[data-slot=modal-view]` animation shipped in this item's `css`. +// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel +// track is the consumer's `carousel` primitive (a registry dependency). // // References (the resolved, app-level implementations this is generalized from): // - Post for Me · post-for-me-dashboard (app modal-layout system) @@ -184,6 +192,186 @@ function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { ); } +// ModalCarousel — the slidable variation: ordered horizontal slides with a +// deliberate (button-driven) step, generalizing onboarding / tour carousels. +// Drag is off — stepping is via ModalCarouselNav. +// +// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track +// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with +// ModalCarouselDots + ModalCarouselNav all read the same carousel state: +// +// +// +// +// +// +// +// +// +// +// +// +// +// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down +// within a step. +type ModalCarouselContextValue = { + index: number; + total: number; + isFirst: boolean; + isLast: boolean; + setApi: (api: CarouselApi) => void; + scrollNext: () => void; + scrollPrev: () => void; +}; + +const ModalCarouselContext = + React.createContext(null); + +function useModalCarousel() { + const ctx = React.useContext(ModalCarouselContext); + if (!ctx) { + throw new Error("useModalCarousel must be used within "); + } + return ctx; +} + +function ModalCarousel({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const [api, setApi] = React.useState(); + const [index, setIndex] = React.useState(0); + const [total, setTotal] = React.useState(0); + + React.useEffect(() => { + if (!api) return; + const update = () => { + setIndex(api.selectedScrollSnap()); + setTotal(api.scrollSnapList().length); + }; + update(); + api.on("select", update); + api.on("reInit", update); + return () => { + api.off("select", update); + api.off("reInit", update); + }; + }, [api]); + + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + + return ( + +
+ {children} +
+
+ ); +} + +// The embla track. Lives inside ModalCarousel; holds ModalSlides. +function ModalCarouselViewport({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const { setApi } = useModalCarousel(); + return ( + + {children} + + ); +} + +function ModalSlide({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function ModalCarouselDots({ className }: { className?: string }) { + const { index, total } = useModalCarousel(); + return ( +
+ {Array.from({ length: total }, (_, i) => ( + + ))} +
+ ); +} + +function ModalCarouselNav({ + backLabel = "Back", + nextLabel = "Next", + finishLabel = "Finish", + onFinish, + className, +}: { + backLabel?: string; + nextLabel?: string; + finishLabel?: string; + onFinish?: () => void; + className?: string; +}) { + const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel(); + return ( +
+ {!isFirst ? ( + + ) : null} + {isLast ? ( + + ) : ( + + )} +
+ ); +} + // ModalViews — the replace-style inner navigation: a push/pop view stack that // swaps the active view *in place* (a subtle transition, NOT a horizontal // track). Use it when a dialog drills into sub-views and back (a settings panel, @@ -336,10 +524,16 @@ export { ModalColumn, ModalAside, ModalFooter, + ModalCarousel, + ModalCarouselViewport, + ModalSlide, + ModalCarouselDots, + ModalCarouselNav, ModalViews, ModalView, ModalViewsBack, useModalLayout, + useModalCarousel, useModalViews, type ModalLayout, }; diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index f57d8d8..07eaf83 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -68,12 +68,12 @@ const CHOICEBOX_RADIX_DESCRIPTION = "The Radix variant of Choicebox, built on the Radix Toggle Group (type=\"single\" | \"multiple\")."; const MODAL_DESCRIPTION = - "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, replace-style inner navigation (ModalViews), and a pinned footer — all combinable."; + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable."; -// Modal source is base-agnostic (it composes the consumer's dialog + button via -// `@/ui/*`, never a base primitive), so both variants ship the same file and -// pull in whichever dialog the consumer's base provides. -const MODAL_REGISTRY_DEPENDENCIES = ["dialog", "button"]; +// Modal source is base-agnostic (it composes the consumer's dialog + button + +// carousel via `@/ui/*`, never a base primitive), so both variants ship the same +// file and pull in whichever primitives the consumer's base provides. +const MODAL_REGISTRY_DEPENDENCIES = ["dialog", "button", "carousel"]; // The ModalViews replace-style transition. Injected into the consumer's // stylesheet on install; the showcase mirrors it in app/app.css.