Skip to main content

priv/registry/drawer.json

{
  "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"
  ]
}