{
"files": [
{
"content": "defmodule Shadix.Components.Drawer do\n @moduledoc \"\"\"\n A bottom sheet (\"drawer\") built on client-side JS commands and a small\n LiveView hook.\n\n Modelled on `Shadix.Components.Sheet`: `show_drawer/1` and `hide_drawer/1` are\n `Phoenix.LiveView.JS` command builders that toggle the overlay and content\n panel (with a slide-up transition) and dispatch `shadix:drawer-open` /\n `shadix:drawer-close` events. The `ShadixDrawer` hook (assets/ts/drawer.ts)\n listens for those events to lock body scroll and manage focus. `Escape` and\n overlay-click both close via `phx-window-keydown` / `phx-click`. Focus is\n trapped with Phoenix's built-in `<.focus_wrap>`.\n\n The content is fixed to the BOTTOM edge of the viewport and slides up from\n below; a small grab-handle bar sits near the top for visual affordance.\n\n > #### v1 has no drag-to-dismiss {: .info}\n >\n > shadcn/ui's drawer uses [vaul](https://github.com/emilkowalski/vaul) to\n > support dragging the sheet down to dismiss it. This v1 deliberately does\n > NOT implement drag gestures: it is a tap/Escape/overlay drawer. The grab\n > handle is decorative.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n @doc \"\"\"\n Renders a bottom drawer with a `:trigger` slot and arbitrary content.\n\n The trigger is wrapped in a `display: contents` span wired to `show_drawer/1`,\n so the caller's own button/element shows the drawer without extra markup.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n slot(:trigger, required: true)\n slot(:inner_block, required: true)\n\n def drawer(assigns) do\n ~H\"\"\"\n <span phx-click={show_drawer(@id)} class=\"contents\">{render_slot(@trigger)}</span>\n <div id={\"#{@id}-root\"} phx-hook=\"ShadixDrawer\">\n <div\n id={\"#{@id}-overlay\"}\n class=\"hidden fixed inset-0 z-50 bg-black/50\"\n aria-hidden=\"true\"\n phx-click={hide_drawer(@id)}\n />\n <.focus_wrap\n id={\"#{@id}-content\"}\n class={\n cn([\n \"hidden fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-lg border bg-background\",\n @class\n ])\n }\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={\"#{@id}-title\"}\n aria-describedby={\"#{@id}-description\"}\n data-slot=\"drawer-content\"\n phx-window-keydown={hide_drawer(@id)}\n phx-key=\"escape\"\n >\n <div\n data-slot=\"drawer-handle\"\n aria-hidden=\"true\"\n class=\"mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted\"\n />\n {render_slot(@inner_block)}\n </.focus_wrap>\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that opens the drawer with `id`.\n\n Shows the overlay and slides the content panel up from the bottom edge, then\n dispatches `shadix:drawer-open` on the `*-root` element for the `ShadixDrawer`\n hook.\n \"\"\"\n def show_drawer(id) do\n %JS{}\n |> JS.show(\n to: \"##{id}-overlay\",\n transition: {\"transition-opacity ease-out duration-300\", \"opacity-0\", \"opacity-100\"}\n )\n |> JS.show(\n to: \"##{id}-content\",\n display: \"flex\",\n transition:\n {\"transition ease-out duration-500\", \"translate-y-full opacity-0\",\n \"translate-y-0 opacity-100\"}\n )\n |> JS.dispatch(\"shadix:drawer-open\", to: \"##{id}-root\")\n |> JS.focus_first(to: \"##{id}-content\")\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that closes the drawer with `id`.\n\n Hides the overlay and slides the content panel back down off the bottom edge,\n then dispatches `shadix:drawer-close` on the `*-root` element for the\n `ShadixDrawer` hook.\n \"\"\"\n def hide_drawer(id) do\n %JS{}\n |> JS.hide(\n to: \"##{id}-overlay\",\n transition: {\"transition-opacity ease-in duration-300\", \"opacity-100\", \"opacity-0\"}\n )\n |> JS.hide(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-in duration-300\", \"translate-y-0 opacity-100\",\n \"translate-y-full opacity-0\"}\n )\n |> JS.dispatch(\"shadix:drawer-close\", to: \"##{id}-root\")\n end\n\n @doc \"Header region of a drawer; stacks title/description, centered by default.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def drawer_header(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"drawer-header\"\n class={cn([\"flex flex-col gap-0.5 p-4 text-center md:gap-1.5 md:text-left\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"Footer region of a drawer; pushed to the bottom of the panel.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def drawer_footer(assigns) do\n ~H\"\"\"\n <div data-slot=\"drawer-footer\" class={cn([\"mt-auto flex flex-col gap-2 p-4\", @class])} {@rest}>\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n Drawer title. `:id` is the *drawer's* id; the title renders with\n `\"#{id}-title\"` so it matches the content panel's `aria-labelledby`.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def drawer_title(assigns) do\n ~H\"\"\"\n <h2\n id={\"#{@id}-title\"}\n data-slot=\"drawer-title\"\n class={cn([\"text-foreground font-semibold\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </h2>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n Drawer description. `:id` is the *drawer's* id; renders with\n `\"#{id}-description\"`.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def drawer_description(assigns) do\n ~H\"\"\"\n <p\n id={\"#{@id}-description\"}\n data-slot=\"drawer-description\"\n class={cn([\"text-muted-foreground text-sm\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </p>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n A close button for the drawer with `:id`. Closes the drawer via\n `hide_drawer/1`. Renders its `inner_block` as the button label (defaulting to\n a visually-hidden \"Close\" when empty).\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block)\n\n def drawer_close(assigns) do\n ~H\"\"\"\n <button\n type=\"button\"\n data-slot=\"drawer-close\"\n phx-click={hide_drawer(@id)}\n aria-label=\"Close\"\n class={\n cn([\n \"rounded-md ring-offset-background transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </button>\n \"\"\"\n end\nend\n",
"path": "drawer.ex"
}
],
"hooks": [
{
"content": "interface DrawerHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nconst FOCUSABLE =\n 'a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport const ShadixDrawer = {\n mounted(this: DrawerHook) {\n const el = this.el;\n let restoreTo: HTMLElement | null = null;\n\n const open = () => {\n restoreTo = document.activeElement as HTMLElement | null;\n document.body.style.overflow = \"hidden\";\n window.requestAnimationFrame(() => {\n const content = el.querySelector<HTMLElement>('[role=\"dialog\"]');\n const candidates = content?.querySelectorAll<HTMLElement>(FOCUSABLE);\n const first = candidates\n ? Array.from(candidates).find((node) => node.getAttribute(\"aria-hidden\") !== \"true\")\n : undefined;\n (first ?? content)?.focus();\n });\n };\n\n const close = () => {\n document.body.style.overflow = \"\";\n restoreTo?.focus();\n restoreTo = null;\n };\n\n el.addEventListener(\"shadix:drawer-open\", open);\n el.addEventListener(\"shadix:drawer-close\", close);\n },\n\n destroyed(this: DrawerHook) {\n document.body.style.overflow = \"\";\n },\n};\n",
"name": "ShadixDrawer",
"path": "drawer.ts"
}
],
"name": "drawer",
"npm_deps": [],
"registry_deps": [
"cn"
]
}