{
"files": [
{
"content": "defmodule Shadix.Components.Sheet do\n @moduledoc \"\"\"\n A side panel (\"sheet\") built on client-side JS commands and a small LiveView hook.\n\n Modelled on `Shadix.Components.Dialog`: `show_sheet/1` and `hide_sheet/1` are\n `Phoenix.LiveView.JS` command builders that toggle the overlay and content\n panel (with side-aware slide transitions) and dispatch `shadix:sheet-open` /\n `shadix:sheet-close` events. The `ShadixSheet` hook (assets/ts/sheet.ts) listens\n 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 Unlike the dialog, the content is NOT centered: it is fixed to the chosen\n `:side` (one of `top`, `right`, `bottom`, `left`) and slides in from that edge.\n Side-based classes and enter/leave transitions are computed from the `@sides`\n map.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n # Per-side positioning + slide transition classes. `from`/`to` are the\n # transition endpoints (the off-screen and on-screen translate states).\n @sides %{\n \"right\" => %{\n position: \"inset-y-0 right-0 h-full w-3/4 max-w-sm border-l\",\n from: \"translate-x-full\",\n to: \"translate-x-0\"\n },\n \"left\" => %{\n position: \"inset-y-0 left-0 h-full w-3/4 max-w-sm border-r\",\n from: \"-translate-x-full\",\n to: \"translate-x-0\"\n },\n \"top\" => %{\n position: \"inset-x-0 top-0 h-auto w-full border-b\",\n from: \"-translate-y-full\",\n to: \"translate-y-0\"\n },\n \"bottom\" => %{\n position: \"inset-x-0 bottom-0 h-auto w-full border-t\",\n from: \"translate-y-full\",\n to: \"translate-y-0\"\n }\n }\n\n @doc \"\"\"\n Renders a side panel with a `:trigger` slot and arbitrary content.\n\n The trigger is wrapped in a `display: contents` span wired to `show_sheet/1`,\n so the caller's own button/element shows the sheet without extra markup. The\n `:side` attribute controls which edge the panel is fixed to and slides from.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:side, :string, default: \"right\", values: ~w(top right bottom left))\n attr(:class, :string, default: nil)\n slot(:trigger, required: true)\n slot(:inner_block, required: true)\n\n def sheet(assigns) do\n assigns = assign(assigns, :side_classes, @sides[assigns.side])\n\n ~H\"\"\"\n <span phx-click={show_sheet(@id, @side)} class=\"contents\">{render_slot(@trigger)}</span>\n <div id={\"#{@id}-root\"} phx-hook=\"ShadixSheet\">\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_sheet(@id, @side)}\n />\n <.focus_wrap\n id={\"#{@id}-content\"}\n class={\n cn([\n \"hidden fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg\",\n @side_classes.position,\n @class\n ])\n }\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={\"#{@id}-title\"}\n aria-describedby={\"#{@id}-description\"}\n data-slot=\"sheet-content\"\n data-side={@side}\n phx-window-keydown={hide_sheet(@id, @side)}\n phx-key=\"escape\"\n >\n {render_slot(@inner_block)}\n <.sheet_close id={@id} side={@side} />\n </.focus_wrap>\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that opens the sheet with `id`.\n\n Shows the overlay and content panel with side-aware enter transitions and\n dispatches `shadix:sheet-open` on the `*-root` element for the `ShadixSheet`\n hook.\n \"\"\"\n def show_sheet(id, side \\\\ \"right\") do\n %{from: from, to: to} = @sides[side]\n\n %JS{}\n |> JS.show(\n to: \"##{id}-overlay\",\n transition:\n {\"transition-opacity ease-out duration-300 motion-reduce:transition-none\", \"opacity-0\",\n \"opacity-100\"}\n )\n |> JS.show(\n to: \"##{id}-content\",\n display: \"flex\",\n transition:\n {\"transition ease-out duration-500 motion-reduce:transition-none\", \"#{from} opacity-0\",\n \"#{to} opacity-100\"}\n )\n |> JS.dispatch(\"shadix:sheet-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 sheet with `id`.\n\n Hides the overlay and content panel with side-aware leave transitions and\n dispatches `shadix:sheet-close` on the `*-root` element for the `ShadixSheet`\n hook.\n \"\"\"\n def hide_sheet(id, side \\\\ \"right\") do\n %{from: from, to: to} = @sides[side]\n\n %JS{}\n |> JS.hide(\n to: \"##{id}-overlay\",\n transition:\n {\"transition-opacity ease-in duration-300 motion-reduce:transition-none\", \"opacity-100\",\n \"opacity-0\"}\n )\n |> JS.hide(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-in duration-300 motion-reduce:transition-none\", \"#{to} opacity-100\",\n \"#{from} opacity-0\"}\n )\n |> JS.dispatch(\"shadix:sheet-close\", to: \"##{id}-root\")\n end\n\n @doc \"Header region of a sheet; stacks title/description with sensible spacing.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sheet_header(assigns) do\n ~H\"\"\"\n <div data-slot=\"sheet-header\" class={cn([\"flex flex-col gap-1.5\", @class])} {@rest}>\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"Footer region of a sheet; 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 sheet_footer(assigns) do\n ~H\"\"\"\n <div data-slot=\"sheet-footer\" class={cn([\"mt-auto flex flex-col gap-2\", @class])} {@rest}>\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n Sheet title. `:id` is the *sheet's* id; the title renders with `\"#{id}-title\"`\n 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 sheet_title(assigns) do\n ~H\"\"\"\n <h2\n id={\"#{@id}-title\"}\n data-slot=\"sheet-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 Sheet description. `:id` is the *sheet's* id; renders with `\"#{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 sheet_description(assigns) do\n ~H\"\"\"\n <p\n id={\"#{@id}-description\"}\n data-slot=\"sheet-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 sheet with `:id`. Renders the X icon and closes the\n sheet via `hide_sheet/1`.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:side, :string, default: \"right\", values: ~w(top right bottom left))\n attr(:class, :string, default: nil)\n\n def sheet_close(assigns) do\n ~H\"\"\"\n <button\n type=\"button\"\n data-slot=\"sheet-close\"\n phx-click={hide_sheet(@id, @side)}\n aria-label=\"Close\"\n class={\n cn([\n \"absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\",\n @class\n ])\n }\n >\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"size-4\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </button>\n \"\"\"\n end\nend\n",
"path": "sheet.ex"
}
],
"hooks": [
{
"content": "interface SheetHook {\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 ShadixSheet = {\n mounted(this: SheetHook) {\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:sheet-open\", open);\n el.addEventListener(\"shadix:sheet-close\", close);\n },\n\n destroyed(this: SheetHook) {\n document.body.style.overflow = \"\";\n },\n};\n",
"name": "ShadixSheet",
"path": "sheet.ts"
}
],
"name": "sheet",
"npm_deps": [],
"registry_deps": [
"cn"
]
}