Skip to main content

priv/registry/sheet.json

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