diff --git a/app/app.css b/app/app.css index 11fb9cd..a543de3 100644 --- a/app/app.css +++ b/app/app.css @@ -139,3 +139,44 @@ cursor: pointer; } } + +/* + * Modal — replace-style inner navigation (ModalViews). The active ModalView + * animates in place on push/pop; `data-direction` flips the offset so forward + * and back read differently. Shipped to consumers via the modal registry item's + * `css` field (scripts/build-registry.ts); kept here so the showcase matches. + */ +@keyframes modal-view-forward { + from { + opacity: 0; + transform: translateY(0.5rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes modal-view-back { + from { + opacity: 0; + transform: translateY(-0.375rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} +[data-slot="modal-view"] { + animation: modal-view-forward 200ms ease-out; +} +[data-slot="modal-view"][data-direction="back"] { + animation-name: modal-view-back; +} +[data-slot="modal-view"][data-direction="none"] { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + [data-slot="modal-view"] { + animation: none; + } +} diff --git a/app/showcase/components/demos.tsx b/app/showcase/components/demos.tsx index a567204..1af06ef 100644 --- a/app/showcase/components/demos.tsx +++ b/app/showcase/components/demos.tsx @@ -1,4 +1,5 @@ import { ChoiceboxExamples, ChoiceboxPreview } from "./choicebox-demo"; +import { ModalExamples, ModalPreview } from "./modal-demo"; // The gallery's source of truth: one entry per published crescent-ui component. // `preview` is the hero shown in the preview frame; `examples` are the extra @@ -22,6 +23,14 @@ export const demos: Record< preview: , examples: , }, + modal: { + title: "Modal", + description: + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel, replace-style inner navigation, and a pinned footer.", + sourceFile: "ui/modal.tsx", + preview: , + examples: , + }, }; export const demoOrder = Object.keys(demos); diff --git a/app/showcase/components/modal-demo.tsx b/app/showcase/components/modal-demo.tsx new file mode 100644 index 0000000..b9af196 --- /dev/null +++ b/app/showcase/components/modal-demo.tsx @@ -0,0 +1,196 @@ +import { + Modal, + ModalAside, + ModalBody, + ModalCarousel, + ModalCarouselDots, + ModalCarouselNav, + ModalCarouselViewport, + ModalClose, + ModalColumn, + ModalColumns, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalSlide, + ModalTitle, + ModalTrigger, + ModalView, + ModalViews, + ModalViewsBack, + useModalViews, +} from "@registry/bases/base/ui/modal"; +import { Button } from "~/ui/button"; + +import { Example } from "./example"; + +const PARAGRAPHS = Array.from( + { length: 8 }, + (_, i) => + `Paragraph ${i + 1}. The body is the single scroll region between the pinned header and footer — long content scrolls here while the chrome stays put.`, +); + +const SLIDES = [ + { + title: "Welcome", + body: "The carousel steps through ordered slides — drag is off, so progress is deliberate.", + }, + { + title: "One step at a time", + body: "The footer's nav drives the track; the dots track which slide is active.", + }, + { + title: "All set", + body: "On the last slide Next becomes Finish — wire onFinish to close or advance.", + }, +]; + +// The hero — a framed dialog: pinned header + footer, the body owns the scroll. +export function ModalPreview() { + return ( + + Open dialog} /> + + + Framed dialog + + Header and footer pin; the body owns the only scroll. + + + + {PARAGRAPHS.map((p) => ( +

+ {p} +

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

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

+ ))} +
+ +

+ Summary +

+

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

+
+
+ + Continue} /> + +
+
+
+ + + + Open tour} /> + + + + {SLIDES.map((slide) => ( + +
+

+ {slide.title} +

+

+ {slide.body} +

+
+
+ ))} +
+ + + + +
+
+
+
+ + + + Open} /> + + + + + Settings + + + + + + +

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

+
+ +

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

+
+
+
+
+
+
+
+ ); +} + +function ViewsHome() { + const { push } = useModalViews(); + return ( +
+ + +
+ ); +} diff --git a/app/ui/carousel.tsx b/app/ui/carousel.tsx new file mode 100644 index 0000000..6a3fec5 --- /dev/null +++ b/app/ui/carousel.tsx @@ -0,0 +1,248 @@ +"use client"; + +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react"; + +import { cn } from "~/lib/utils"; +import { Button } from "~/ui/button"; + +// Carousel — the stock shadcn embla carousel primitive. It is the raw slidable +// track the higher-level `ModalCarousel` family (registry/bases/*/ui/modal) +// composes on top of; a consumer's `shadcn add modal` pulls this in as the +// `carousel` registry dependency. This app-side copy is what the showcase renders +// the Modal carousel against. + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/app/ui/dialog.tsx b/app/ui/dialog.tsx new file mode 100644 index 0000000..4ba1f2b --- /dev/null +++ b/app/ui/dialog.tsx @@ -0,0 +1,147 @@ +"use client"; + +import * as React from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "~/ui/button"; + +// Dialog — the Base UI Dialog primitive dressed in crescent chrome. It is the +// raw popup the higher-level `Modal` family (registry/bases/*/ui/modal) composes +// on top of; on its own it's the standard centered dialog box. +// +// Style-tunable shape lives in the `cn-dialog-content` token (radius, per style +// — see registry/styles/*.css); colors/spacing/animation stay inline since they +// track the theme, not the style. + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton ? ( + + } + > + + Close + + ) : null} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/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 new file mode 100644 index 0000000..ed7eef5 --- /dev/null +++ b/registry-dist/base-luma/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n
\n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n
\n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n
\n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n
\n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-lyra/modal.json b/registry-dist/base-lyra/modal.json new file mode 100644 index 0000000..6facda6 --- /dev/null +++ b/registry-dist/base-lyra/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n
\n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-maia/modal.json b/registry-dist/base-maia/modal.json new file mode 100644 index 0000000..267a373 --- /dev/null +++ b/registry-dist/base-maia/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-mira/modal.json b/registry-dist/base-mira/modal.json new file mode 100644 index 0000000..14d1225 --- /dev/null +++ b/registry-dist/base-mira/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-nova/modal.json b/registry-dist/base-nova/modal.json new file mode 100644 index 0000000..efe76a1 --- /dev/null +++ b/registry-dist/base-nova/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-rhea/modal.json b/registry-dist/base-rhea/modal.json new file mode 100644 index 0000000..a210ae4 --- /dev/null +++ b/registry-dist/base-rhea/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-sera/modal.json b/registry-dist/base-sera/modal.json new file mode 100644 index 0000000..892fd08 --- /dev/null +++ b/registry-dist/base-sera/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "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 Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/base-vega/modal.json b/registry-dist/base-vega/modal.json new file mode 100644 index 0000000..ebe9c01 --- /dev/null +++ b/registry-dist/base-vega/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/base-vega/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-luma/modal.json b/registry-dist/radix-luma/modal.json new file mode 100644 index 0000000..7932c99 --- /dev/null +++ b/registry-dist/radix-luma/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-luma/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-lyra/modal.json b/registry-dist/radix-lyra/modal.json new file mode 100644 index 0000000..70b6257 --- /dev/null +++ b/registry-dist/radix-lyra/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-lyra/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-maia/modal.json b/registry-dist/radix-maia/modal.json new file mode 100644 index 0000000..6344923 --- /dev/null +++ b/registry-dist/radix-maia/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-maia/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-mira/modal.json b/registry-dist/radix-mira/modal.json new file mode 100644 index 0000000..4cd5179 --- /dev/null +++ b/registry-dist/radix-mira/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-mira/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-nova/modal.json b/registry-dist/radix-nova/modal.json new file mode 100644 index 0000000..5fccbf3 --- /dev/null +++ b/registry-dist/radix-nova/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-nova/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-rhea/modal.json b/registry-dist/radix-rhea/modal.json new file mode 100644 index 0000000..6419ed2 --- /dev/null +++ b/registry-dist/radix-rhea/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-rhea/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-sera/modal.json b/registry-dist/radix-sera/modal.json new file mode 100644 index 0000000..c27d7cb --- /dev/null +++ b/registry-dist/radix-sera/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-sera/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry-dist/radix-vega/modal.json b/registry-dist/radix-vega/modal.json new file mode 100644 index 0000000..43f289a --- /dev/null +++ b/registry-dist/radix-vega/modal.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable.", + "dependencies": [], + "registryDependencies": [ + "dialog", + "button", + "carousel" + ], + "files": [ + { + "path": "registry/radix-vega/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx", + "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { IconPlaceholder } from \"@/ui/icon-placeholder\";\nimport { Button } from \"@/ui/button\";\nimport {\n Carousel,\n CarouselContent,\n CarouselItem,\n type CarouselApi,\n} from \"@/ui/carousel\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogTitle,\n DialogTrigger,\n} from \"@/ui/dialog\";\n\n// Modal — the composable modal-layout system: a namespaced compound family that\n// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes\n// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds\n// the cohesive *layout* every dialog should share: a pinned header, a single\n// scrolling body, an optional muted-aside second column, a slidable carousel\n// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned\n// footer, all combinable.\n//\n// This is the registry SOURCE: it is intentionally icon-library agnostic\n// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track\n// the theme and the dialog's corner radius is the one style-tunable token\n// (cn-dialog-content). The replace-style view transition rides the\n// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel\n// track is the consumer's `carousel` primitive (a registry dependency).\n//\n// References (the resolved, app-level implementations this is generalized from):\n// - Post for Me · post-for-me-dashboard (app modal-layout system)\n// - DXLogic · web/app/components/modal (Modal / ModalViews family)\n//\n// Anatomy (compound):\n// \n// Open} />\n// \n// \n// \n// \n// \n// \n// \n// \n// \n\n// `layout=\"framed\"` (default) makes the popup a bounded flex column so the\n// header/footer pin and the body owns the only scroll; `layout=\"simple\"` keeps\n// the plain dialog box. The layout rides ModalLayoutContext so the header/footer\n// self-pad only when framed.\ntype ModalLayout = \"simple\" | \"framed\";\n\nconst ModalLayoutContext = React.createContext(\"framed\");\n\nfunction useModalLayout() {\n return React.useContext(ModalLayoutContext);\n}\n\n// Root + trigger + close + a11y title/description are the Dialog primitives,\n// re-exported under the Modal namespace so a consumer assembles one family.\nconst Modal = Dialog;\nconst ModalTrigger = DialogTrigger;\nconst ModalClose = DialogClose;\nconst ModalTitle = DialogTitle;\nconst ModalDescription = DialogDescription;\n\nfunction ModalContent({\n layout = \"framed\",\n className,\n children,\n ...props\n}: React.ComponentProps & { layout?: ModalLayout }) {\n return (\n \n \n {children}\n \n \n );\n}\n\nfunction ModalHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\nfunction ModalBody({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\n// The body split into two columns: a primary ModalColumn and a distinguished\n// ModalAside (muted panel). Container-query responsive — side-by-side when the\n// modal is wide, stacked when narrow.\n//\n// Flex (not grid) so the columns stay bounded to the available height and scroll\n// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the\n// height is definite via flexbox. `items-stretch` makes both columns full-height,\n// so the aside's panel background fills the whole side.\nfunction ModalColumns({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n return (\n \n
\n {children}\n
\n \n );\n}\n\nfunction ModalColumn({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalAside({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n const layout = useModalLayout();\n return (\n \n );\n}\n\n// ModalCarousel — the slidable variation: ordered horizontal slides with a\n// deliberate (button-driven) step, generalizing onboarding / tour carousels.\n// Drag is off — stepping is via ModalCarouselNav.\n//\n// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track\n// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with\n// ModalCarouselDots + ModalCarouselNav all read the same carousel state:\n//\n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n// \n//\n// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down\n// within a step.\ntype ModalCarouselContextValue = {\n index: number;\n total: number;\n isFirst: boolean;\n isLast: boolean;\n setApi: (api: CarouselApi) => void;\n scrollNext: () => void;\n scrollPrev: () => void;\n};\n\nconst ModalCarouselContext =\n React.createContext(null);\n\nfunction useModalCarousel() {\n const ctx = React.useContext(ModalCarouselContext);\n if (!ctx) {\n throw new Error(\"useModalCarousel must be used within \");\n }\n return ctx;\n}\n\nfunction ModalCarousel({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const [api, setApi] = React.useState();\n const [index, setIndex] = React.useState(0);\n const [total, setTotal] = React.useState(0);\n\n React.useEffect(() => {\n if (!api) return;\n const update = () => {\n setIndex(api.selectedScrollSnap());\n setTotal(api.scrollSnapList().length);\n };\n update();\n api.on(\"select\", update);\n api.on(\"reInit\", update);\n return () => {\n api.off(\"select\", update);\n api.off(\"reInit\", update);\n };\n }, [api]);\n\n const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);\n const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// The embla track. Lives inside ModalCarousel; holds ModalSlides.\nfunction ModalCarouselViewport({\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\">) {\n const { setApi } = useModalCarousel();\n return (\n \n {children}\n \n );\n}\n\nfunction ModalSlide({ className, ...props }: React.ComponentProps<\"div\">) {\n return (\n \n );\n}\n\nfunction ModalCarouselDots({ className }: { className?: string }) {\n const { index, total } = useModalCarousel();\n return (\n \n {Array.from({ length: total }, (_, i) => (\n \n ))}\n \n );\n}\n\nfunction ModalCarouselNav({\n backLabel = \"Back\",\n nextLabel = \"Next\",\n finishLabel = \"Finish\",\n onFinish,\n className,\n}: {\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n onFinish?: () => void;\n className?: string;\n}) {\n const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel();\n return (\n \n {!isFirst ? (\n \n ) : null}\n {isLast ? (\n \n ) : (\n \n )}\n \n );\n}\n\n// ModalViews — the replace-style inner navigation: a push/pop view stack that\n// swaps the active view *in place* (a subtle transition, NOT a horizontal\n// track). Use it when a dialog drills into sub-views and back (a settings panel,\n// a branching wizard).\n//\n// `ModalViews defaultView=\"…\"` owns the stack; each `ModalView value=\"…\"` is a\n// destination rendered only when active; `useModalViews` drives navigation\n// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when\n// there's nothing to pop.\ntype ModalViewsDirection = \"forward\" | \"back\" | \"none\";\n\ntype ModalViewsContextValue = {\n active: string;\n stack: string[];\n canGoBack: boolean;\n direction: ModalViewsDirection;\n push: (view: string) => void;\n pop: () => void;\n replace: (view: string) => void;\n reset: (view?: string) => void;\n};\n\nconst ModalViewsContext = React.createContext(\n null\n);\n\nfunction useModalViews() {\n const ctx = React.useContext(ModalViewsContext);\n if (!ctx) {\n throw new Error(\"useModalViews must be used within \");\n }\n return ctx;\n}\n\nfunction ModalViews({\n defaultView,\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & { defaultView: string }) {\n const [stack, setStack] = React.useState([defaultView]);\n const [direction, setDirection] = React.useState(\"none\");\n const active = stack[stack.length - 1];\n\n const push = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s, view]);\n }, []);\n const pop = React.useCallback(() => {\n setDirection(\"back\");\n setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));\n }, []);\n const replace = React.useCallback((view: string) => {\n setDirection(\"forward\");\n setStack((s) => [...s.slice(0, -1), view]);\n }, []);\n const reset = React.useCallback(\n (view?: string) => {\n setDirection(\"back\");\n setStack([view ?? defaultView]);\n },\n [defaultView]\n );\n\n return (\n 1,\n direction,\n push,\n pop,\n replace,\n reset,\n }}\n >\n \n {children}\n \n \n );\n}\n\nfunction ModalView({\n value,\n className,\n ...props\n}: React.ComponentProps<\"div\"> & { value: string }) {\n const { active, direction } = useModalViews();\n if (active !== value) return null;\n return (\n // `key` remounts on view change so the enter animation replays; only the\n // active view is mounted (a true replace, not a track). `data-direction`\n // (see this item's css) gives push vs pop a slightly different in-place\n // motion.\n \n );\n}\n\nfunction ModalViewsBack({\n className,\n label = \"Back\",\n ...props\n}: React.ComponentProps & { label?: string }) {\n const { canGoBack, pop } = useModalViews();\n if (!canGoBack) return null;\n return (\n \n \n {label}\n \n );\n}\n\nexport {\n Modal,\n ModalTrigger,\n ModalClose,\n ModalContent,\n ModalHeader,\n ModalTitle,\n ModalDescription,\n ModalBody,\n ModalColumns,\n ModalColumn,\n ModalAside,\n ModalFooter,\n ModalCarousel,\n ModalCarouselViewport,\n ModalSlide,\n ModalCarouselDots,\n ModalCarouselNav,\n ModalViews,\n ModalView,\n ModalViewsBack,\n useModalLayout,\n useModalCarousel,\n useModalViews,\n type ModalLayout,\n};\n" + } + ], + "css": { + "@keyframes modal-view-forward": { + "from": { + "opacity": "0", + "transform": "translateY(0.5rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "@keyframes modal-view-back": { + "from": { + "opacity": "0", + "transform": "translateY(-0.375rem)" + }, + "to": { + "opacity": "1", + "transform": "translateY(0)" + } + }, + "[data-slot=modal-view]": { + "animation": "modal-view-forward 200ms ease-out" + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back" + }, + "[data-slot=modal-view][data-direction=none]": { + "animation": "none" + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { + "animation": "none" + } + } + } +} diff --git a/registry.json b/registry.json index fa227f3..86d8deb 100644 --- a/registry.json +++ b/registry.json @@ -30,6 +30,21 @@ "target": "components/ui/choicebox.tsx" } ] + }, + { + "name": "modal", + "type": "registry:ui", + "title": "Modal", + "description": "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable. Base-agnostic — it composes the consumer's dialog + button + carousel.", + "dependencies": [], + "registryDependencies": ["dialog", "button", "carousel"], + "files": [ + { + "path": "registry/bases/base/ui/modal.tsx", + "type": "registry:ui", + "target": "components/ui/modal.tsx" + } + ] } ] } diff --git a/registry/bases/base/ui/modal.tsx b/registry/bases/base/ui/modal.tsx new file mode 100644 index 0000000..bb7334b --- /dev/null +++ b/registry/bases/base/ui/modal.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { IconPlaceholder } from "@/ui/icon-placeholder"; +import { Button } from "@/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + type CarouselApi, +} from "@/ui/carousel"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/ui/dialog"; + +// Modal — the composable modal-layout system: a namespaced compound family that +// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes +// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds +// the cohesive *layout* every dialog should share: a pinned header, a single +// scrolling body, an optional muted-aside second column, a slidable carousel +// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned +// footer, all combinable. +// +// This is the registry SOURCE: it is intentionally icon-library agnostic +// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track +// the theme and the dialog's corner radius is the one style-tunable token +// (cn-dialog-content). The replace-style view transition rides the +// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel +// track is the consumer's `carousel` primitive (a registry dependency). +// +// References (the resolved, app-level implementations this is generalized from): +// - Post for Me · post-for-me-dashboard (app modal-layout system) +// - DXLogic · web/app/components/modal (Modal / ModalViews family) +// +// Anatomy (compound): +// +// Open} /> +// +// +// +// +// +// +// +// +// + +// `layout="framed"` (default) makes the popup a bounded flex column so the +// header/footer pin and the body owns the only scroll; `layout="simple"` keeps +// the plain dialog box. The layout rides ModalLayoutContext so the header/footer +// self-pad only when framed. +type ModalLayout = "simple" | "framed"; + +const ModalLayoutContext = React.createContext("framed"); + +function useModalLayout() { + return React.useContext(ModalLayoutContext); +} + +// Root + trigger + close + a11y title/description are the Dialog primitives, +// re-exported under the Modal namespace so a consumer assembles one family. +const Modal = Dialog; +const ModalTrigger = DialogTrigger; +const ModalClose = DialogClose; +const ModalTitle = DialogTitle; +const ModalDescription = DialogDescription; + +function ModalContent({ + layout = "framed", + className, + children, + ...props +}: React.ComponentProps & { layout?: ModalLayout }) { + return ( + + + {children} + + + ); +} + +function ModalHeader({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +function ModalBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// The body split into two columns: a primary ModalColumn and a distinguished +// ModalAside (muted panel). Container-query responsive — side-by-side when the +// modal is wide, stacked when narrow. +// +// Flex (not grid) so the columns stay bounded to the available height and scroll +// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the +// height is definite via flexbox. `items-stretch` makes both columns full-height, +// so the aside's panel background fills the whole side. +function ModalColumns({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+ {children} +
+
+ ); +} + +function ModalColumn({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalAside({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +// ModalCarousel — the slidable variation: ordered horizontal slides with a +// deliberate (button-driven) step, generalizing onboarding / tour carousels. +// Drag is off — stepping is via ModalCarouselNav. +// +// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track +// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with +// ModalCarouselDots + ModalCarouselNav all read the same carousel state: +// +// +// +// +// +// +// +// +// +// +// +// +// +// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down +// within a step. +type ModalCarouselContextValue = { + index: number; + total: number; + isFirst: boolean; + isLast: boolean; + setApi: (api: CarouselApi) => void; + scrollNext: () => void; + scrollPrev: () => void; +}; + +const ModalCarouselContext = + React.createContext(null); + +function useModalCarousel() { + const ctx = React.useContext(ModalCarouselContext); + if (!ctx) { + throw new Error("useModalCarousel must be used within "); + } + return ctx; +} + +function ModalCarousel({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const [api, setApi] = React.useState(); + const [index, setIndex] = React.useState(0); + const [total, setTotal] = React.useState(0); + + React.useEffect(() => { + if (!api) return; + const update = () => { + setIndex(api.selectedScrollSnap()); + setTotal(api.scrollSnapList().length); + }; + update(); + api.on("select", update); + api.on("reInit", update); + return () => { + api.off("select", update); + api.off("reInit", update); + }; + }, [api]); + + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + + return ( + +
+ {children} +
+
+ ); +} + +// The embla track. Lives inside ModalCarousel; holds ModalSlides. +function ModalCarouselViewport({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const { setApi } = useModalCarousel(); + return ( + + {children} + + ); +} + +function ModalSlide({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function ModalCarouselDots({ className }: { className?: string }) { + const { index, total } = useModalCarousel(); + return ( +
+ {Array.from({ length: total }, (_, i) => ( + + ))} +
+ ); +} + +function ModalCarouselNav({ + backLabel = "Back", + nextLabel = "Next", + finishLabel = "Finish", + onFinish, + className, +}: { + backLabel?: string; + nextLabel?: string; + finishLabel?: string; + onFinish?: () => void; + className?: string; +}) { + const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel(); + return ( +
+ {!isFirst ? ( + + ) : null} + {isLast ? ( + + ) : ( + + )} +
+ ); +} + +// ModalViews — the replace-style inner navigation: a push/pop view stack that +// swaps the active view *in place* (a subtle transition, NOT a horizontal +// track). Use it when a dialog drills into sub-views and back (a settings panel, +// a branching wizard). +// +// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a +// destination rendered only when active; `useModalViews` drives navigation +// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when +// there's nothing to pop. +type ModalViewsDirection = "forward" | "back" | "none"; + +type ModalViewsContextValue = { + active: string; + stack: string[]; + canGoBack: boolean; + direction: ModalViewsDirection; + push: (view: string) => void; + pop: () => void; + replace: (view: string) => void; + reset: (view?: string) => void; +}; + +const ModalViewsContext = React.createContext( + null +); + +function useModalViews() { + const ctx = React.useContext(ModalViewsContext); + if (!ctx) { + throw new Error("useModalViews must be used within "); + } + return ctx; +} + +function ModalViews({ + defaultView, + className, + children, + ...props +}: React.ComponentProps<"div"> & { defaultView: string }) { + const [stack, setStack] = React.useState([defaultView]); + const [direction, setDirection] = React.useState("none"); + const active = stack[stack.length - 1]; + + const push = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s, view]); + }, []); + const pop = React.useCallback(() => { + setDirection("back"); + setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + }, []); + const replace = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s.slice(0, -1), view]); + }, []); + const reset = React.useCallback( + (view?: string) => { + setDirection("back"); + setStack([view ?? defaultView]); + }, + [defaultView] + ); + + return ( + 1, + direction, + push, + pop, + replace, + reset, + }} + > +
+ {children} +
+
+ ); +} + +function ModalView({ + value, + className, + ...props +}: React.ComponentProps<"div"> & { value: string }) { + const { active, direction } = useModalViews(); + if (active !== value) return null; + return ( + // `key` remounts on view change so the enter animation replays; only the + // active view is mounted (a true replace, not a track). `data-direction` + // (see this item's css) gives push vs pop a slightly different in-place + // motion. +
+ ); +} + +function ModalViewsBack({ + className, + label = "Back", + ...props +}: React.ComponentProps & { label?: string }) { + const { canGoBack, pop } = useModalViews(); + if (!canGoBack) return null; + return ( + + ); +} + +export { + Modal, + ModalTrigger, + ModalClose, + ModalContent, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalColumns, + ModalColumn, + ModalAside, + ModalFooter, + ModalCarousel, + ModalCarouselViewport, + ModalSlide, + ModalCarouselDots, + ModalCarouselNav, + ModalViews, + ModalView, + ModalViewsBack, + useModalLayout, + useModalCarousel, + useModalViews, + type ModalLayout, +}; diff --git a/registry/bases/radix/ui/modal.tsx b/registry/bases/radix/ui/modal.tsx new file mode 100644 index 0000000..bb7334b --- /dev/null +++ b/registry/bases/radix/ui/modal.tsx @@ -0,0 +1,539 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { IconPlaceholder } from "@/ui/icon-placeholder"; +import { Button } from "@/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + type CarouselApi, +} from "@/ui/carousel"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/ui/dialog"; + +// Modal — the composable modal-layout system: a namespaced compound family that +// sits ON TOP of the `Dialog` primitive (`@/ui/dialog`), which it consumes +// internally and never replaces. Where `Dialog` is the raw popup, `Modal` adds +// the cohesive *layout* every dialog should share: a pinned header, a single +// scrolling body, an optional muted-aside second column, a slidable carousel +// (ModalCarousel), replace-style inner navigation (ModalViews) — and a pinned +// footer, all combinable. +// +// This is the registry SOURCE: it is intentionally icon-library agnostic +// (IconPlaceholder, resolved on `shadcn add`) and structural-only — colors track +// the theme and the dialog's corner radius is the one style-tunable token +// (cn-dialog-content). The replace-style view transition rides the +// `[data-slot=modal-view]` animation shipped in this item's `css`; the carousel +// track is the consumer's `carousel` primitive (a registry dependency). +// +// References (the resolved, app-level implementations this is generalized from): +// - Post for Me · post-for-me-dashboard (app modal-layout system) +// - DXLogic · web/app/components/modal (Modal / ModalViews family) +// +// Anatomy (compound): +// +// Open} /> +// +// +// +// +// +// +// +// +// + +// `layout="framed"` (default) makes the popup a bounded flex column so the +// header/footer pin and the body owns the only scroll; `layout="simple"` keeps +// the plain dialog box. The layout rides ModalLayoutContext so the header/footer +// self-pad only when framed. +type ModalLayout = "simple" | "framed"; + +const ModalLayoutContext = React.createContext("framed"); + +function useModalLayout() { + return React.useContext(ModalLayoutContext); +} + +// Root + trigger + close + a11y title/description are the Dialog primitives, +// re-exported under the Modal namespace so a consumer assembles one family. +const Modal = Dialog; +const ModalTrigger = DialogTrigger; +const ModalClose = DialogClose; +const ModalTitle = DialogTitle; +const ModalDescription = DialogDescription; + +function ModalContent({ + layout = "framed", + className, + children, + ...props +}: React.ComponentProps & { layout?: ModalLayout }) { + return ( + + + {children} + + + ); +} + +function ModalHeader({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +function ModalBody({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// The body split into two columns: a primary ModalColumn and a distinguished +// ModalAside (muted panel). Container-query responsive — side-by-side when the +// modal is wide, stacked when narrow. +// +// Flex (not grid) so the columns stay bounded to the available height and scroll +// INTERNALLY. The whole chain uses `flex-1 min-h-0` rather than `h-full` so the +// height is definite via flexbox. `items-stretch` makes both columns full-height, +// so the aside's panel background fills the whole side. +function ModalColumns({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
+ {children} +
+
+ ); +} + +function ModalColumn({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalAside({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ModalFooter({ className, ...props }: React.ComponentProps<"div">) { + const layout = useModalLayout(); + return ( +
+ ); +} + +// ModalCarousel — the slidable variation: ordered horizontal slides with a +// deliberate (button-driven) step, generalizing onboarding / tour carousels. +// Drag is off — stepping is via ModalCarouselNav. +// +// `ModalCarousel` is a PROVIDER that wraps the whole region, so the track +// (ModalCarouselViewport, holding ModalSlides) and a sibling ModalFooter with +// ModalCarouselDots + ModalCarouselNav all read the same carousel state: +// +// +// +// +// +// +// +// +// +// +// +// +// +// Orthogonal to ModalViews: a slide may host a nested ModalViews for drill-down +// within a step. +type ModalCarouselContextValue = { + index: number; + total: number; + isFirst: boolean; + isLast: boolean; + setApi: (api: CarouselApi) => void; + scrollNext: () => void; + scrollPrev: () => void; +}; + +const ModalCarouselContext = + React.createContext(null); + +function useModalCarousel() { + const ctx = React.useContext(ModalCarouselContext); + if (!ctx) { + throw new Error("useModalCarousel must be used within "); + } + return ctx; +} + +function ModalCarousel({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const [api, setApi] = React.useState(); + const [index, setIndex] = React.useState(0); + const [total, setTotal] = React.useState(0); + + React.useEffect(() => { + if (!api) return; + const update = () => { + setIndex(api.selectedScrollSnap()); + setTotal(api.scrollSnapList().length); + }; + update(); + api.on("select", update); + api.on("reInit", update); + return () => { + api.off("select", update); + api.off("reInit", update); + }; + }, [api]); + + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + + return ( + +
+ {children} +
+
+ ); +} + +// The embla track. Lives inside ModalCarousel; holds ModalSlides. +function ModalCarouselViewport({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const { setApi } = useModalCarousel(); + return ( + + {children} + + ); +} + +function ModalSlide({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function ModalCarouselDots({ className }: { className?: string }) { + const { index, total } = useModalCarousel(); + return ( +
+ {Array.from({ length: total }, (_, i) => ( + + ))} +
+ ); +} + +function ModalCarouselNav({ + backLabel = "Back", + nextLabel = "Next", + finishLabel = "Finish", + onFinish, + className, +}: { + backLabel?: string; + nextLabel?: string; + finishLabel?: string; + onFinish?: () => void; + className?: string; +}) { + const { isFirst, isLast, scrollNext, scrollPrev } = useModalCarousel(); + return ( +
+ {!isFirst ? ( + + ) : null} + {isLast ? ( + + ) : ( + + )} +
+ ); +} + +// ModalViews — the replace-style inner navigation: a push/pop view stack that +// swaps the active view *in place* (a subtle transition, NOT a horizontal +// track). Use it when a dialog drills into sub-views and back (a settings panel, +// a branching wizard). +// +// `ModalViews defaultView="…"` owns the stack; each `ModalView value="…"` is a +// destination rendered only when active; `useModalViews` drives navigation +// (push/pop/replace/reset); `ModalViewsBack` is a back affordance that hides when +// there's nothing to pop. +type ModalViewsDirection = "forward" | "back" | "none"; + +type ModalViewsContextValue = { + active: string; + stack: string[]; + canGoBack: boolean; + direction: ModalViewsDirection; + push: (view: string) => void; + pop: () => void; + replace: (view: string) => void; + reset: (view?: string) => void; +}; + +const ModalViewsContext = React.createContext( + null +); + +function useModalViews() { + const ctx = React.useContext(ModalViewsContext); + if (!ctx) { + throw new Error("useModalViews must be used within "); + } + return ctx; +} + +function ModalViews({ + defaultView, + className, + children, + ...props +}: React.ComponentProps<"div"> & { defaultView: string }) { + const [stack, setStack] = React.useState([defaultView]); + const [direction, setDirection] = React.useState("none"); + const active = stack[stack.length - 1]; + + const push = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s, view]); + }, []); + const pop = React.useCallback(() => { + setDirection("back"); + setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + }, []); + const replace = React.useCallback((view: string) => { + setDirection("forward"); + setStack((s) => [...s.slice(0, -1), view]); + }, []); + const reset = React.useCallback( + (view?: string) => { + setDirection("back"); + setStack([view ?? defaultView]); + }, + [defaultView] + ); + + return ( + 1, + direction, + push, + pop, + replace, + reset, + }} + > +
+ {children} +
+
+ ); +} + +function ModalView({ + value, + className, + ...props +}: React.ComponentProps<"div"> & { value: string }) { + const { active, direction } = useModalViews(); + if (active !== value) return null; + return ( + // `key` remounts on view change so the enter animation replays; only the + // active view is mounted (a true replace, not a track). `data-direction` + // (see this item's css) gives push vs pop a slightly different in-place + // motion. +
+ ); +} + +function ModalViewsBack({ + className, + label = "Back", + ...props +}: React.ComponentProps & { label?: string }) { + const { canGoBack, pop } = useModalViews(); + if (!canGoBack) return null; + return ( + + ); +} + +export { + Modal, + ModalTrigger, + ModalClose, + ModalContent, + ModalHeader, + ModalTitle, + ModalDescription, + ModalBody, + ModalColumns, + ModalColumn, + ModalAside, + ModalFooter, + ModalCarousel, + ModalCarouselViewport, + ModalSlide, + ModalCarouselDots, + ModalCarouselNav, + ModalViews, + ModalView, + ModalViewsBack, + useModalLayout, + useModalCarousel, + useModalViews, + type ModalLayout, +}; diff --git a/registry/styles/style-luma.css b/registry/styles/style-luma.css index 3f93fad..b5e331f 100644 --- a/registry/styles/style-luma.css +++ b/registry/styles/style-luma.css @@ -51,6 +51,9 @@ .cn-select-item { @apply rounded-2xl; } + .cn-dialog-content { + @apply rounded-3xl; + } .cn-card { @apply rounded-3xl; } diff --git a/registry/styles/style-lyra.css b/registry/styles/style-lyra.css index 05ddbe7..ada5707 100644 --- a/registry/styles/style-lyra.css +++ b/registry/styles/style-lyra.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-none; } + .cn-dialog-content { + @apply rounded-none; + } .cn-card { @apply rounded-none; } diff --git a/registry/styles/style-maia.css b/registry/styles/style-maia.css index c1d8683..a0c2b47 100644 --- a/registry/styles/style-maia.css +++ b/registry/styles/style-maia.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-lg; } + .cn-dialog-content { + @apply rounded-2xl; + } .cn-card { @apply rounded-2xl; } diff --git a/registry/styles/style-mira.css b/registry/styles/style-mira.css index e16d88f..60a7989 100644 --- a/registry/styles/style-mira.css +++ b/registry/styles/style-mira.css @@ -63,6 +63,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-lg; + } .cn-card { @apply rounded-xl; } diff --git a/registry/styles/style-nova.css b/registry/styles/style-nova.css index f1f30f4..f7e1755 100644 --- a/registry/styles/style-nova.css +++ b/registry/styles/style-nova.css @@ -49,6 +49,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-xl; + } .cn-card { @apply rounded-xl; } diff --git a/registry/styles/style-rhea.css b/registry/styles/style-rhea.css index cbff236..9c2719e 100644 --- a/registry/styles/style-rhea.css +++ b/registry/styles/style-rhea.css @@ -50,6 +50,9 @@ .cn-select-item { @apply rounded-xl; } + .cn-dialog-content { + @apply rounded-2xl; + } .cn-card { @apply rounded-2xl; } diff --git a/registry/styles/style-sera.css b/registry/styles/style-sera.css index 80fac8c..0b17edf 100644 --- a/registry/styles/style-sera.css +++ b/registry/styles/style-sera.css @@ -58,6 +58,9 @@ .cn-select-item { @apply rounded-none; } + .cn-dialog-content { + @apply rounded-none; + } .cn-card { @apply rounded-none; } diff --git a/registry/styles/style-vega.css b/registry/styles/style-vega.css index 4ef7507..e09ac49 100644 --- a/registry/styles/style-vega.css +++ b/registry/styles/style-vega.css @@ -49,6 +49,9 @@ .cn-select-item { @apply rounded-md; } + .cn-dialog-content { + @apply rounded-xl; + } .cn-card { @apply rounded-xl; } diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index f2cc629..07eaf83 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -30,6 +30,12 @@ type Item = { title: string; description: string; dependencies: string[]; + // Other registry items this one installs alongside (shadcn `shadcn add` + // resolves these too) — e.g. modal builds on the consumer's dialog + button. + registryDependencies?: string[]; + // Arbitrary CSS the item injects into the consumer's stylesheet (keyframes, + // data-slot rules). Same shape shadcn's registry-item `css` field expects. + css?: Record; // Source path relative to registry/bases//, and install target. file: string; target: string; @@ -61,6 +67,39 @@ const CHOICEBOX_BASE_DESCRIPTION = const CHOICEBOX_RADIX_DESCRIPTION = "The Radix variant of Choicebox, built on the Radix Toggle Group (type=\"single\" | \"multiple\")."; +const MODAL_DESCRIPTION = + "A composable modal-layout system on top of the Dialog primitive: a pinned header, a single scrolling body, an optional muted-aside column, a slidable carousel (ModalCarousel), replace-style inner navigation (ModalViews), and a pinned footer — all combinable."; + +// Modal source is base-agnostic (it composes the consumer's dialog + button + +// carousel via `@/ui/*`, never a base primitive), so both variants ship the same +// file and pull in whichever primitives the consumer's base provides. +const MODAL_REGISTRY_DEPENDENCIES = ["dialog", "button", "carousel"]; + +// The ModalViews replace-style transition. Injected into the consumer's +// stylesheet on install; the showcase mirrors it in app/app.css. +const MODAL_CSS: Record = { + "@keyframes modal-view-forward": { + from: { opacity: "0", transform: "translateY(0.5rem)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "@keyframes modal-view-back": { + from: { opacity: "0", transform: "translateY(-0.375rem)" }, + to: { opacity: "1", transform: "translateY(0)" }, + }, + "[data-slot=modal-view]": { + animation: "modal-view-forward 200ms ease-out", + }, + "[data-slot=modal-view][data-direction=back]": { + "animation-name": "modal-view-back", + }, + "[data-slot=modal-view][data-direction=none]": { + animation: "none", + }, + "@media (prefers-reduced-motion: reduce)": { + "[data-slot=modal-view]": { animation: "none" }, + }, +}; + const BASES: Base[] = [ { name: "base", @@ -74,6 +113,17 @@ const BASES: Base[] = [ file: "ui/choicebox.tsx", target: "components/ui/choicebox.tsx", }, + { + name: "modal", + type: "registry:ui", + title: "Modal", + description: MODAL_DESCRIPTION, + dependencies: [], + registryDependencies: MODAL_REGISTRY_DEPENDENCIES, + css: MODAL_CSS, + file: "ui/modal.tsx", + target: "components/ui/modal.tsx", + }, ], }, { @@ -88,6 +138,17 @@ const BASES: Base[] = [ file: "ui/choicebox.tsx", target: "components/ui/choicebox.tsx", }, + { + name: "modal", + type: "registry:ui", + title: "Modal", + description: MODAL_DESCRIPTION, + dependencies: [], + registryDependencies: MODAL_REGISTRY_DEPENDENCIES, + css: MODAL_CSS, + file: "ui/modal.tsx", + target: "components/ui/modal.tsx", + }, ], }, ]; @@ -147,6 +208,9 @@ async function build() { title: item.title, description: item.description, dependencies: item.dependencies, + ...(item.registryDependencies + ? { registryDependencies: item.registryDependencies } + : {}), files: [ { path: `registry/${base.name}-${style}/${item.file}`, @@ -155,6 +219,7 @@ async function build() { content, }, ], + ...(item.css ? { css: item.css } : {}), }; const dir = path.join(outRoot, `${base.name}-${style}`); diff --git a/vite.config.ts b/vite.config.ts index 3417b75..cd29268 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ "@base-ui/react/toggle-group", "@base-ui/react/select", "@base-ui/react/tooltip", + "@base-ui/react/dialog", ], }, });