Files
Rivertalk/lib/river_connect_web/components/ui/sheet.ex
T
2026-02-12 17:10:03 -06:00

283 lines
8.1 KiB
Elixir

defmodule RiverConnectWeb.Components.UI.Sheet do
@moduledoc """
Implementation of Sheet component for SaladUI framework.
Sheets are like dialogs that appear from the edge of the screen. They can be configured
to slide in from different sides of the viewport.
## Examples:
<.sheet id="user-sheet">
<.sheet_trigger>
<.button variant="outline">Open Sheet</.button>
</.sheet_trigger>
<.sheet_content side="right">
<.sheet_header>
<.sheet_title>Edit profile</.sheet_title>
<.sheet_description>
Make changes to your profile here. Click save when you're done.
</.sheet_description>
</.sheet_header>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<.label for="name" class="text-right">Name</.label>
<.input id="name" value="John Doe" class="col-span-3" />
</div>
</div>
<.sheet_footer>
<.button type="submit">Save changes</.button>
</.sheet_footer>
</.sheet_content>
</.sheet>
"""
use RiverConnectWeb.Components.UI, :component
@doc """
The main sheet component that manages state and positioning.
## Options
* `:id` - Required unique identifier for the sheet.
* `:open` - Whether the sheet is initially open. Defaults to `false`.
* `:on-open` - Handler for sheet open event.
* `:on-close` - Handler for sheet close event.
* `:class` - Additional CSS classes.
"""
attr :id, :string, required: true, doc: "Unique identifier for the sheet"
attr :open, :boolean, default: false, doc: "Whether the sheet is initially open"
attr :class, :string, default: nil
attr :"close-on-outside-click", :boolean, default: true
attr :"on-open", :any, default: nil, doc: "Handler for sheet open event"
attr :"on-close", :any, default: nil, doc: "Handler for sheet close event"
attr :rest, :global
slot :inner_block, required: true
def sheet(assigns) do
# Collect event mappings
event_map =
%{}
|> add_event_mapping(assigns, "opened", :"on-open")
|> add_event_mapping(assigns, "closed", :"on-close")
assigns =
assigns
|> assign(:event_map, json(event_map))
|> assign(:initial_state, if(assigns.open, do: "open", else: "closed"))
|> assign(
:options,
json(%{
animations: get_animation_config(),
closeOnOutsideClick: assigns[:"close-on-outside-click"]
})
)
~H"""
<div
id={@id}
class={classes(["relative inline-block", @class])}
data-component="dialog"
data-state={@initial_state}
data-event-mappings={@event_map}
data-options={@options}
data-part="root"
phx-hook="SaladUI"
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
The trigger element that opens the sheet.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_trigger(assigns) do
~H"""
<div data-part="trigger" data-action="open" class={classes(["", @class])} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
The sheet content that appears when triggered.
## Options
* `:side` - The side from which the sheet appears (top, right, bottom, left). Defaults to `"right"`.
* `:class` - Additional CSS classes to customize dimensions and appearance.
"""
attr :class, :string, default: nil
attr :side, :string,
values: ~w(top right bottom left),
default: "right",
doc: "The side from which the sheet appears"
attr :rest, :global
slot :inner_block, required: true
def sheet_content(assigns) do
assigns = assign(assigns, :variant_class, sheet_variants(assigns))
~H"""
<div data-part="content" tabindex="0" hidden>
<div
data-part="overlay"
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<div
data-part="content-panel"
data-side={@side}
class={
classes([
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
@variant_class,
@class
])
}
{@rest}
>
<div class="h-full flex flex-col">
{render_slot(@inner_block)}
</div>
<button
type="button"
data-part="close-trigger"
data-action="close"
class="absolute right-4 top-4 rounded-sm 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 data-[state=open]:bg-secondary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">Close</span>
</button>
</div>
</div>
"""
end
@doc """
Renders a sheet header section for title and description.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_header(assigns) do
~H"""
<div class={classes(["flex flex-col space-y-1.5 text-center sm:text-left", @class])} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
Renders a sheet title within the header section.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_title(assigns) do
~H"""
<h3 class={classes(["flex flex-col space-y-2 text-center sm:text-left", @class])} {@rest}>
{render_slot(@inner_block)}
</h3>
"""
end
@doc """
Renders a sheet description within the header section.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_description(assigns) do
~H"""
<p class={classes(["text-sm text-muted-foreground", @class])} {@rest}>
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a footer section for the sheet, typically containing actions.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_footer(assigns) do
~H"""
<div
class={classes(["flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", @class])}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
The close button for the sheet.
"""
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def sheet_close(assigns) do
~H"""
<div data-part="close-trigger" data-action="close" class={classes(["", @class])} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
defp get_animation_config do
%{
"open_to_closed" => %{
duration: 130,
target_part: "content"
}
}
end
@variants %{
side: %{
"top" =>
"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
"bottom" =>
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
"left" =>
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
"right" =>
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"
}
}
defp sheet_variants(props) do
variants =
Map.take(props, ~w(side)a)
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
end
end