UNPKG

@loke/design-system

Version:

A design system with individually importable components

482 lines (402 loc) 16.4 kB
--- name: overlay-composition description: > Compose and debug overlay components. Dialog (DialogContent, DialogTrigger, DialogPortal, DialogOverlay, DialogClose, showCloseButton), Sheet (side variants left/right/top/bottom, collapsible modes), AlertDialog (AlertDialogAction/Cancel for confirmations), Popover (PopoverContent, PopoverAnchor, matchTriggerWidth), Tooltip (TooltipProvider required, TooltipContent with arrow), DropdownMenu (items, checkbox items, radio items, submenus), Command (CommandInput, CommandList, CommandItem, CommandDialog). Portal stacking, focus trapping, dismiss on escape/ click-outside, exit animations via data-state, forceMount. Activate when adding modals, popovers, tooltips, menus, or debugging overlay behavior. type: core library: '@loke/design-system' library_version: '2.0.0-rc.6' requires: - getting-started sources: - 'LOKE/merchant-frontends:packages/design-system/src/components/dialog' - 'LOKE/merchant-frontends:packages/design-system/src/components/sheet' - 'LOKE/merchant-frontends:packages/design-system/src/components/alert-dialog' - 'LOKE/merchant-frontends:packages/design-system/src/components/popover' - 'LOKE/merchant-frontends:packages/design-system/src/components/tooltip' - 'LOKE/merchant-frontends:packages/design-system/src/components/dropdown-menu' - 'LOKE/merchant-frontends:packages/design-system/src/components/command' --- # Overlay Composition ## Dependency note This skill builds on getting-started. Read it first. Overlays require `"use client"` boundaries and `TooltipProvider`. ## Setup All overlay components are client components. Their barrel exports include the `"use client"` directive. Ensure `TooltipProvider` wraps your app (typically in a root layout) before using any tooltip. ```tsx "use client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@loke/design-system/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@loke/design-system/tooltip"; import { Button } from "@loke/design-system/button"; function OverlayExample() { return ( <> <Dialog> <DialogTrigger asChild> <Button>Open Dialog</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Edit profile</DialogTitle> </DialogHeader> <p>Dialog body content goes here.</p> </DialogContent> </Dialog> {/* Tooltip requires TooltipProvider ancestor */} <Tooltip> <TooltipTrigger asChild> <Button variant="outline">Hover me</Button> </TooltipTrigger> <TooltipContent>Helpful tooltip text</TooltipContent> </Tooltip> </> ); } ``` ## Core Patterns ### Dialog Composed from `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogFooter`, `DialogTitle`, `DialogDescription`, and `DialogClose`. `DialogContent` renders a portal with an overlay backdrop automatically. It accepts a `showCloseButton` prop (defaults to `true`) that renders an X button in the top-right corner. Use `forceMount` on `DialogContent` to keep the content in the DOM for animation libraries. `DialogFooter` renders a sticky footer area with a border top and muted background. It accepts a `showCloseButton` prop that adds a "Close" button. ```tsx import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@loke/design-system/dialog"; import { Button } from "@loke/design-system/button"; function DialogExample() { return ( <Dialog> <DialogTrigger asChild> <Button>Edit Settings</Button> </DialogTrigger> <DialogContent showCloseButton={false}> <DialogHeader> <DialogTitle>Settings</DialogTitle> <DialogDescription>Adjust your preferences below.</DialogDescription> </DialogHeader> {/* form fields */} <DialogFooter> <DialogClose asChild> <Button variant="outline">Cancel</Button> </DialogClose> <Button>Save</Button> </DialogFooter> </DialogContent> </Dialog> ); } ``` ### Sheet Sheet is a slide-out panel built on the same Dialog primitive. Use the `side` prop on `SheetContent` to control direction: `"left"`, `"right"` (default), `"top"`, or `"bottom"`. Each side has its own slide animation. `SheetHeader` and `SheetFooter` are sticky (top and bottom respectively) with border separators. `SheetTitle` accepts an optional `icon` prop (any `LokeIcon`). ```tsx import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@loke/design-system/sheet"; import { Button } from "@loke/design-system/button"; function SheetExample() { return ( <Sheet> <SheetTrigger asChild> <Button variant="outline">Open Panel</Button> </SheetTrigger> <SheetContent side="right"> <SheetHeader> <SheetTitle>Navigation</SheetTitle> <SheetDescription>Browse sections</SheetDescription> </SheetHeader> <nav>{/* links */}</nav> <SheetFooter> <Button>Done</Button> </SheetFooter> </SheetContent> </Sheet> ); } ``` ### AlertDialog for confirmations AlertDialog blocks outside interaction until the user explicitly confirms or cancels. Use it for destructive or irreversible actions. `AlertDialogAction` and `AlertDialogCancel` accept `variant` and `size` props from `Button`. `AlertDialogContent` accepts a `size` prop: `"default"` (responsive, wider on sm+) or `"sm"` (compact, two-column footer grid). `AlertDialogMedia` renders an icon slot above the title. ```tsx import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger, } from "@loke/design-system/alert-dialog"; import { TrashIcon } from "@loke/icons"; function DeleteConfirmation() { return ( <AlertDialog> <AlertDialogTrigger asChild> <Button variant="destructive">Delete Account</Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogMedia> <TrashIcon /> </AlertDialogMedia> <AlertDialogTitle>Delete account?</AlertDialogTitle> <AlertDialogDescription> This action cannot be undone. All data will be permanently removed. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction variant="destructive"> Delete </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } ``` ### Popover `PopoverContent` renders inside a portal. It accepts `align` (default `"center"`), `sideOffset` (default `4`), and `matchTriggerWidth` (constrains width to trigger element width, useful for comboboxes). `PopoverAnchor` lets you position the popover relative to an element other than the trigger. ```tsx import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger, } from "@loke/design-system/popover"; import { Button } from "@loke/design-system/button"; function PopoverExample() { return ( <Popover> <PopoverTrigger asChild> <Button variant="outline">Filter</Button> </PopoverTrigger> <PopoverContent align="start"> <PopoverHeader> <PopoverTitle>Filter options</PopoverTitle> </PopoverHeader> {/* filter form */} </PopoverContent> </Popover> ); } ``` ### Tooltip Requires a `TooltipProvider` ancestor (typically at your app root with `delayDuration={0}`). `TooltipContent` automatically renders an arrow pointing to the trigger. Use `forceMount` to keep the tooltip in the DOM during animations. ```tsx import { Tooltip, TooltipContent, TooltipTrigger, } from "@loke/design-system/tooltip"; import { Button } from "@loke/design-system/button"; function TooltipExample() { return ( <Tooltip> <TooltipTrigger asChild> <Button variant="ghost" size="icon-sm">?</Button> </TooltipTrigger> <TooltipContent side="right"> More information about this field. </TooltipContent> </Tooltip> ); } ``` ### DropdownMenu Composed from `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, and item variants. Content renders in a portal with `z-50`. Item variants: - `DropdownMenuItem` -- standard clickable item. `variant="destructive"` for dangerous actions. `inset` adds left padding. - `DropdownMenuCheckboxItem` -- toggleable checkbox with `checked` prop. - `DropdownMenuRadioGroup` + `DropdownMenuRadioItem` -- single-select radio group. - `DropdownMenuSub` + `DropdownMenuSubTrigger` + `DropdownMenuSubContent` -- nested submenus. Use `DropdownMenuLabel`, `DropdownMenuSeparator`, and `DropdownMenuShortcut` for structure. ```tsx import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@loke/design-system/dropdown-menu"; import { Button } from "@loke/design-system/button"; function MenuExample() { return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline">Actions</Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Account</DropdownMenuLabel> <DropdownMenuGroup> <DropdownMenuItem> Profile <DropdownMenuShortcut>Ctrl+P</DropdownMenuShortcut> </DropdownMenuItem> <DropdownMenuItem>Settings</DropdownMenuItem> </DropdownMenuGroup> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger> <DropdownMenuSubContent> <DropdownMenuItem>Email</DropdownMenuItem> <DropdownMenuItem>Link</DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive">Delete</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); } ``` ### Command (command palette / combobox) `Command` wraps the cmdk primitive with DS styling. Key sub-components: `CommandInput` (search field with icon), `CommandList` (scrollable results), `CommandItem` (selectable row), `CommandGroup` (section with optional `heading`), `CommandEmpty` (no results), `CommandLoading`. `CommandDialog` renders Command inside a Dialog with a screen-reader-only header. Pass `title` and `description` for accessibility. `showCloseButton` defaults to `false`. **Popover + Command combobox pattern:** ```tsx "use client"; import { useState } from "react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@loke/design-system/command"; import { Popover, PopoverContent, PopoverTrigger } from "@loke/design-system/popover"; import { Button } from "@loke/design-system/button"; const options = [ { label: "Apple", value: "apple" }, { label: "Banana", value: "banana" }, { label: "Cherry", value: "cherry" }, ]; function Combobox() { const [open, setOpen] = useState(false); const [selected, setSelected] = useState(""); return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline"> {selected || "Select fruit..."} </Button> </PopoverTrigger> <PopoverContent matchTriggerWidth> <Command> <CommandInput placeholder="Search..." /> <CommandList> <CommandEmpty>No results.</CommandEmpty> <CommandGroup> {options.map((opt) => ( <CommandItem key={opt.value} onSelect={() => { setSelected(opt.label); setOpen(false); }} > {opt.label} </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ); } ``` ### Nested overlays When nesting overlays (e.g., a Popover inside a Dialog), use controlled state on both to prevent focus trap conflicts. ```tsx "use client"; import { useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@loke/design-system/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@loke/design-system/popover"; import { Button } from "@loke/design-system/button"; function DialogWithPopover() { const [dialogOpen, setDialogOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); return ( <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <DialogTrigger asChild> <Button>Open Dialog</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Parent Dialog</DialogTitle> </DialogHeader> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <PopoverTrigger asChild> <Button variant="outline">Open Popover</Button> </PopoverTrigger> <PopoverContent>Nested popover content.</PopoverContent> </Popover> </DialogContent> </Dialog> ); } ``` ## Common Mistakes ### 1. CRITICAL: Nesting overlays without controlled state Wrong: ```tsx // Two uncontrolled Dialogs -- focus traps fight each other <Dialog> <DialogContent> <Dialog> <DialogContent>Inner</DialogContent> </Dialog> </DialogContent> </Dialog> ``` Correct: ```tsx const [outerOpen, setOuterOpen] = useState(false); const [innerOpen, setInnerOpen] = useState(false); <Dialog open={outerOpen} onOpenChange={setOuterOpen}> <DialogContent> <Dialog open={innerOpen} onOpenChange={setInnerOpen}> <DialogContent>Inner</DialogContent> </Dialog> </DialogContent> </Dialog> ``` Use controlled `open`/`onOpenChange` on nested overlays so the parent can manage the child lifecycle and focus trapping works correctly. ### 2. HIGH: Conditional render instead of open prop Wrong: ```tsx {showDialog && ( <Dialog> <DialogContent>...</DialogContent> </Dialog> )} ``` Correct: ```tsx <Dialog open={showDialog} onOpenChange={setShowDialog}> <DialogContent>...</DialogContent> </Dialog> ``` Conditional rendering skips exit animations (`data-closed:animate-out`) and can leak event listeners. Always use the `open` prop for controlled visibility. ### 3. HIGH: Using Dialog instead of AlertDialog for confirmations `Dialog` allows dismiss by clicking outside the overlay or pressing Escape. `AlertDialog` blocks all outside interaction until the user clicks `AlertDialogAction` or `AlertDialogCancel`. Use `AlertDialog` for destructive actions (delete, discard changes) where accidental dismissal could skip the confirmation. ### 4. MEDIUM: Popover closing when interacting with nested content If a `Popover` contains a `Command` (combobox) or another overlay, the nested component's dismiss behavior can bubble up and close the parent. Use controlled state (`open`/`onOpenChange`) on the `Popover` and handle `onSelect` to close only when intended: ```tsx <Popover open={open} onOpenChange={setOpen}> <PopoverContent> <Command> <CommandItem onSelect={() => setOpen(false)}> Pick this </CommandItem> </Command> </PopoverContent> </Popover> ``` ### 5. MEDIUM: Z-index collision between multiple overlays DS overlays render inside portals and use `z-50` by default. Do not add custom `z-index` classes to overlay components. If overlays stack in the wrong order, check that portals are rendered in the correct DOM order (later portals appear on top). The `forceMount` prop keeps content in the DOM but does not change stacking order. ## See also - `getting-started/SKILL.md` -- TooltipProvider setup, `"use client"` boundaries - `forms/SKILL.md` -- Popover+Command combobox in form context - `interactive-components/SKILL.md` -- Button variants for dialog actions