+ );
+}
+
+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}\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 );\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}\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}\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 );\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}\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}\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 );\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}\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}\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 );\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}\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}\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 );\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}\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}\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 );\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}\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}\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 );\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