Initial Commit
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
defmodule RiverConnectWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as tables, forms, and
|
||||
inputs. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
|
||||
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
|
||||
and themes. Here are useful references:
|
||||
|
||||
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
|
||||
started and see the available components.
|
||||
|
||||
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||||
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||||
spacing.
|
||||
|
||||
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||
|
||||
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||
the component system used by Phoenix. Some components, such as `<.link>`
|
||||
and `<.form>`, are defined there.
|
||||
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: RiverConnectWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="toast toast-top toast-end z-50"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||
attr :class, :any
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :class, fn ->
|
||||
["btn", Map.fetch!(variants, assigns[:variant])]
|
||||
end)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
~H"""
|
||||
<.link class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as radio, are best
|
||||
written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
```heex
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
```
|
||||
|
||||
## Select type
|
||||
|
||||
When using `type="select"`, you must pass the `options` and optionally
|
||||
a `value` to mark which option should be preselected.
|
||||
|
||||
```heex
|
||||
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
|
||||
```
|
||||
|
||||
For more information on what kind of data can be passed to `options` see
|
||||
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
search select tel text textarea time url week hidden)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :class, :any, default: nil, doc: "the input class to use over defaults"
|
||||
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "hidden"} = assigns) do
|
||||
~H"""
|
||||
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<input
|
||||
type="hidden"
|
||||
name={@name}
|
||||
value="false"
|
||||
disabled={@rest[:disabled]}
|
||||
form={@rest[:form]}
|
||||
/>
|
||||
<span class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class || "w-full textarea",
|
||||
@errors != [] && (@error_class || "textarea-error")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input",
|
||||
@errors != [] && (@error_class || "input-error")
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title">{@post.title}</:item>
|
||||
<:item title="Views">{@post.views}</:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<ul class="list">
|
||||
<li :for={item <- @item} class="list-row">
|
||||
<div class="list-col-grow">
|
||||
<div class="font-bold">{item.title}</div>
|
||||
<div>{render_slot(item)}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :any, default: "size-4"
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(RiverConnectWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(RiverConnectWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
defmodule RiverConnectWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds layouts and related functionality
|
||||
used by your application.
|
||||
"""
|
||||
use RiverConnectWeb, :html
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
# and other static content.
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
Renders your app layout.
|
||||
|
||||
This function is typically invoked from every template,
|
||||
and it often contains your application menu, sidebar,
|
||||
or similar.
|
||||
|
||||
## Examples
|
||||
|
||||
<Layouts.app flash={@flash}>
|
||||
<h1>Content</h1>
|
||||
</Layouts.app>
|
||||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||
<li>
|
||||
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<.theme_toggle />
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Provides dark vs light theme toggle based on themes defined in app.css.
|
||||
|
||||
See <head> in root.html.heex which applies the theme before page load.
|
||||
"""
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="system"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="light"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="dark"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="RiverConnect" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
defmodule RiverConnectWeb.Components.UI do
|
||||
@moduledoc """
|
||||
SaladUI - A comprehensive UI component library for Phoenix LiveView applications.
|
||||
|
||||
SaladUI provides a collection of accessible, customizable UI components that combine
|
||||
server-side rendering with client-side interactivity. Built specifically for Phoenix
|
||||
LiveView, it offers seamless integration between Elixir and JavaScript.
|
||||
|
||||
## Features
|
||||
|
||||
- **40+ UI Components** - Buttons, forms, overlays, navigation, and data display
|
||||
- **Accessibility First** - ARIA compliant with keyboard navigation and screen reader support
|
||||
- **LiveView Integration** - Two-way communication between server and client
|
||||
- **Customizable** - Tailwind CSS based with variant support
|
||||
- **Type Safe** - Full attribute validation and documentation
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add SaladUI to your Phoenix application:
|
||||
|
||||
# In your core components module
|
||||
use SaladUI
|
||||
|
||||
# In templates
|
||||
<.button variant="primary" phx-click="save">
|
||||
Save Changes
|
||||
</.button>
|
||||
|
||||
## Component Categories
|
||||
|
||||
### Layout & Structure
|
||||
- `card/1` - Content containers with headers and footers
|
||||
- `separator/1` - Visual dividers between content sections
|
||||
- `scroll_area/1` - Custom scrollable containers
|
||||
|
||||
### Forms & Input
|
||||
- `button/1` - Interactive buttons with multiple variants
|
||||
- `input/1` - Text inputs with validation support
|
||||
- `textarea/1` - Multi-line text input areas
|
||||
- `checkbox/1` - Binary choice inputs
|
||||
- `radio_group/1` - Single choice from multiple options
|
||||
- `select/1` - Dropdown selection menus
|
||||
- `slider/1` - Range value selection
|
||||
- `switch/1` - Toggle controls
|
||||
- `form/1`, `form_item/1`, `form_label/1` - Form structure and validation
|
||||
|
||||
### Navigation
|
||||
- `tabs/1` - Tabbed content navigation
|
||||
- `breadcrumb/1` - Hierarchical navigation paths
|
||||
- `pagination/1` - Page navigation controls
|
||||
- `sidebar/1` - Application navigation sidebars
|
||||
|
||||
### Overlays & Dialogs
|
||||
- `dialog/1` - Modal dialogs and confirmations
|
||||
- `alert_dialog/1` - Alert and confirmation dialogs
|
||||
- `sheet/1` - Slide-out panels
|
||||
- `popover/1` - Contextual popup content
|
||||
- `tooltip/1` - Hover information displays
|
||||
- `hover_card/1` - Rich hover content
|
||||
- `dropdown_menu/1` - Contextual action menus
|
||||
|
||||
### Feedback & Status
|
||||
- `alert/1` - Status messages and notifications
|
||||
- `badge/1` - Labels and status indicators
|
||||
- `progress/1` - Progress bars and loading states
|
||||
- `skeleton/1` - Loading placeholder content
|
||||
|
||||
### Data Display
|
||||
- `table/1` - Data tables with sorting and selection
|
||||
- `chart/1` - Data visualization charts
|
||||
- `avatar/1` - User profile images
|
||||
- `accordion/1` - Collapsible content sections
|
||||
|
||||
### Utility
|
||||
- `toggle/1`, `toggle_group/1` - Toggle controls and groups
|
||||
- `collapsible/1` - Expandable content areas
|
||||
- `command/1` - Command palette interfaces
|
||||
|
||||
## Architecture
|
||||
|
||||
SaladUI uses a hybrid architecture combining:
|
||||
|
||||
- **Phoenix Function Components** - Server-side rendering with HEEx templates
|
||||
- **JavaScript State Machines** - Client-side behavior and interactivity
|
||||
- **LiveView Hooks** - Seamless server-client communication
|
||||
- **Automatic ARIA** - Accessibility attributes managed by the framework
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Basic Component Usage
|
||||
|
||||
<.button variant="outline" size="lg">
|
||||
Large Outline Button
|
||||
</.button>
|
||||
|
||||
### Form Integration
|
||||
|
||||
<.form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.form_item>
|
||||
<.form_label>Email</.form_label>
|
||||
<.form_control>
|
||||
<.input field={@form[:email]} type="email" />
|
||||
</.form_control>
|
||||
<.form_message field={@form[:email]} />
|
||||
</.form_item>
|
||||
</.form>
|
||||
|
||||
### Interactive Components with Events
|
||||
|
||||
<.dialog id="user-dialog" on-open={JS.push("dialog_opened")}>
|
||||
<.dialog_trigger>
|
||||
<.button>Edit Profile</.button>
|
||||
</.dialog_trigger>
|
||||
<.dialog_content>
|
||||
<!-- Dialog content -->
|
||||
</.dialog_content>
|
||||
</.dialog>
|
||||
|
||||
### Server-Client Communication
|
||||
|
||||
# Send commands to components from LiveView
|
||||
def handle_event("open_dialog", _params, socket) do
|
||||
socket = RiverConnectWeb.Components.UI.LiveView.send_command(socket, "user-dialog", "open")
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
## Customization
|
||||
|
||||
Components support extensive customization through:
|
||||
|
||||
- **Variants** - Pre-defined style variations (e.g., `variant="destructive"`)
|
||||
- **Classes** - Custom CSS classes via the `class` attribute
|
||||
- **Slots** - Flexible content areas within components
|
||||
- **Attributes** - Comprehensive configuration options
|
||||
|
||||
## Accessibility
|
||||
|
||||
All components include:
|
||||
|
||||
- Proper ARIA attributes and roles
|
||||
- Keyboard navigation support
|
||||
- Focus management for overlays
|
||||
- Screen reader announcements
|
||||
- High contrast support
|
||||
|
||||
## Development
|
||||
|
||||
When extending SaladUI:
|
||||
|
||||
1. **Follow naming conventions** - Use `kebab-case` for data attributes
|
||||
2. **Implement state machines** - Define clear states and transitions
|
||||
3. **Handle accessibility** - Include ARIA configuration
|
||||
4. **Test thoroughly** - Verify keyboard and screen reader usage
|
||||
5. **Document examples** - Provide clear usage examples
|
||||
"""
|
||||
|
||||
def component do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
import RiverConnectWeb.Components.UI.Helpers
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
defp classes(input) do
|
||||
TwMerge.merge(List.flatten(input))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate macro.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
import RiverConnectWeb.Components.UI.Accordion
|
||||
import RiverConnectWeb.Components.UI.Alert
|
||||
import RiverConnectWeb.Components.UI.AlertDialog
|
||||
import RiverConnectWeb.Components.UI.Avatar
|
||||
import RiverConnectWeb.Components.UI.Badge
|
||||
import RiverConnectWeb.Components.UI.Breadcrumb
|
||||
import RiverConnectWeb.Components.UI.Button
|
||||
import RiverConnectWeb.Components.UI.Card
|
||||
import RiverConnectWeb.Components.UI.Chart
|
||||
import RiverConnectWeb.Components.UI.Checkbox
|
||||
import RiverConnectWeb.Components.UI.Collapsible
|
||||
import RiverConnectWeb.Components.UI.Dialog
|
||||
import RiverConnectWeb.Components.UI.DropdownMenu
|
||||
import RiverConnectWeb.Components.UI.Form
|
||||
import RiverConnectWeb.Components.UI.Helpers
|
||||
import RiverConnectWeb.Components.UI.HoverCard
|
||||
import RiverConnectWeb.Components.UI.Icon
|
||||
import RiverConnectWeb.Components.UI.Input
|
||||
import RiverConnectWeb.Components.UI.Label
|
||||
import RiverConnectWeb.Components.UI.Menu
|
||||
import RiverConnectWeb.Components.UI.Pagination
|
||||
import RiverConnectWeb.Components.UI.Popover
|
||||
import RiverConnectWeb.Components.UI.Progress
|
||||
import RiverConnectWeb.Components.UI.RadioGroup
|
||||
import RiverConnectWeb.Components.UI.ScrollArea
|
||||
import RiverConnectWeb.Components.UI.Select
|
||||
import RiverConnectWeb.Components.UI.Separator
|
||||
import RiverConnectWeb.Components.UI.Sheet
|
||||
import RiverConnectWeb.Components.UI.Sidebar
|
||||
import RiverConnectWeb.Components.UI.Skeleton
|
||||
import RiverConnectWeb.Components.UI.Slider
|
||||
import RiverConnectWeb.Components.UI.Switch
|
||||
import RiverConnectWeb.Components.UI.Table
|
||||
import RiverConnectWeb.Components.UI.Tabs
|
||||
import RiverConnectWeb.Components.UI.Textarea
|
||||
import RiverConnectWeb.Components.UI.Toggle
|
||||
import RiverConnectWeb.Components.UI.ToggleGroup
|
||||
import RiverConnectWeb.Components.UI.Tooltip
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Accordion do
|
||||
@moduledoc """
|
||||
Implementation of the Accordion component.
|
||||
|
||||
Accordions are vertically stacked sections that can be expanded/collapsed
|
||||
to reveal their content. They are useful for breaking down complex content
|
||||
into digestible sections.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.accordion
|
||||
id="faq-accordion"
|
||||
type="single"
|
||||
default-value="item-1"
|
||||
on-value-changed={JS.push("accordion_changed")}
|
||||
>
|
||||
<.accordion_item value="item-1">
|
||||
<.accordion_trigger>
|
||||
Is it accessible?
|
||||
</.accordion_trigger>
|
||||
<.accordion_content>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</.accordion_content>
|
||||
</.accordion_item>
|
||||
<.accordion_item value="item-2">
|
||||
<.accordion_trigger>
|
||||
Is it styled?
|
||||
</.accordion_trigger>
|
||||
<.accordion_content>
|
||||
Yes. It comes with default styles that matches the other components' aesthetic.
|
||||
</.accordion_content>
|
||||
</.accordion_item>
|
||||
</.accordion>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main accordion component that manages the accordion state.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the accordion.
|
||||
* `:type` - Type of accordion: "single" or "multiple". Defaults to "single".
|
||||
* `:value` - The currently expanded items. String for "single", list for "multiple".
|
||||
* `:default-value` - The default expanded items. Used if `:value` is not provided.
|
||||
* `:disabled` - Whether the accordion is disabled. Defaults to `false`.
|
||||
* `:on-value-changed` - Handler for accordion value change event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the accordion"
|
||||
|
||||
attr :type, :string,
|
||||
values: ~w(single multiple),
|
||||
default: "single",
|
||||
doc: "Whether only one item can be open at a time"
|
||||
|
||||
attr :value, :any, default: nil, doc: "The value(s) of the currently expanded item(s)"
|
||||
attr :"default-value", :any, default: nil, doc: "The default value(s) of the expanded item(s)"
|
||||
attr :disabled, :boolean, default: false, doc: "Whether the accordion is disabled"
|
||||
attr :"on-value-changed", :any, default: nil, doc: "Handler for accordion value change event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def accordion(assigns) do
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "value-changed", :"on-value-changed")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, Jason.encode!(event_map))
|
||||
|> assign(
|
||||
:options,
|
||||
Jason.encode!(%{
|
||||
type: assigns.type,
|
||||
value: assigns.value,
|
||||
defaultValue: assigns[:"default-value"],
|
||||
disabled: assigns.disabled
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["w-full", @class])}
|
||||
data-component="accordion"
|
||||
data-state="idle"
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
data-part="root"
|
||||
phx-hook="SaladUI"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
An accordion item that contains a trigger and content.
|
||||
|
||||
## Options
|
||||
|
||||
* `:value` - Required unique value for this accordion item.
|
||||
* `:disabled` - Whether this item is disabled. Defaults to `false`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :value, :string, required: true, doc: "Unique value for this accordion item"
|
||||
attr :disabled, :boolean, default: false, doc: "Whether this item is disabled"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def accordion_item(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="item"
|
||||
data-value={@value}
|
||||
data-disabled={to_string(@disabled)}
|
||||
data-state="closed"
|
||||
class={classes(["border-b border-border", @class])}
|
||||
tabindex="-1"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The trigger element that expands/collapses an accordion item.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def accordion_trigger(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
data-part="item-trigger"
|
||||
class={
|
||||
classes([
|
||||
"flex w-full justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
"text-sm",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex="-1"
|
||||
aria-expanded="false"
|
||||
{@rest}
|
||||
>
|
||||
<span>{render_slot(@inner_block)}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The content element of an accordion item that shows when expanded.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def accordion_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="item-content"
|
||||
data-state="closed"
|
||||
class={
|
||||
classes([
|
||||
"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
@class
|
||||
])
|
||||
}
|
||||
hidden
|
||||
{@rest}
|
||||
>
|
||||
<div class="pb-4 pt-0">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Alert do
|
||||
@moduledoc """
|
||||
Implementation of alert component for displaying important messages to users.
|
||||
|
||||
Alerts are used to communicate status, warnings, or contextual feedback to users.
|
||||
They can be configured with different variants to indicate severity and typically
|
||||
consist of a title and a description.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.alert>
|
||||
<.alert_title>Note</.alert_title>
|
||||
<.alert_description>This is a standard informational alert.</.alert_description>
|
||||
</.alert>
|
||||
|
||||
<.alert variant="destructive">
|
||||
<.alert_title>Error</.alert_title>
|
||||
<.alert_description>
|
||||
There was a problem with your request. Please try again.
|
||||
</.alert_description>
|
||||
</.alert>
|
||||
|
||||
<.alert>
|
||||
<span class="mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 12l2 2l4-4"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<.alert_title>Success</.alert_title>
|
||||
<.alert_description>Your changes have been saved successfully.</.alert_description>
|
||||
</.alert>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders an alert container.
|
||||
|
||||
The alert component displays important messages or feedback to users with styling
|
||||
appropriate for the context.
|
||||
|
||||
## Options
|
||||
|
||||
* `:variant` - The visual style of the alert. Available variants:
|
||||
* `"default"` - Standard alert styling (default)
|
||||
* `"destructive"` - Red-tinted styling for errors and warnings
|
||||
* `:class` - Additional CSS classes to apply to the alert container.
|
||||
|
||||
## Examples
|
||||
|
||||
<.alert>
|
||||
<.alert_title>Information</.alert_title>
|
||||
<.alert_description>This is an informational message.</.alert_description>
|
||||
</.alert>
|
||||
|
||||
<.alert variant="destructive" class="mt-4">
|
||||
<.alert_title>Warning</.alert_title>
|
||||
<.alert_description>This action cannot be undone.</.alert_description>
|
||||
</.alert>
|
||||
"""
|
||||
attr :variant, :string, default: "default", values: ~w(default destructive)
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global, default: %{}
|
||||
|
||||
def alert(assigns) do
|
||||
assigns = assign(assigns, :variant_class, variant(assigns))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
role="alert"
|
||||
data-part="root"
|
||||
class={
|
||||
classes([
|
||||
"relative w-full rounded-lg border p-4 [&>span~*]:pl-7 [&>span+div]:translate-y-[-3px] [&>span]:absolute [&>span]:left-4 [&>span]:top-4",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an alert title.
|
||||
|
||||
The title component is used to provide a concise heading for the alert message.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the title.
|
||||
|
||||
## Examples
|
||||
|
||||
<.alert_title>Success</.alert_title>
|
||||
<.alert_title class="text-primary">Important Notice</.alert_title>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def alert_title(assigns) do
|
||||
~H"""
|
||||
<h5
|
||||
class={
|
||||
classes([
|
||||
"mb-1 font-medium leading-none tracking-tight",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</h5>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an alert description.
|
||||
|
||||
The description provides more detailed information about the alert message.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the description.
|
||||
|
||||
## Examples
|
||||
|
||||
<.alert_description>Your account has been updated successfully.</.alert_description>
|
||||
<.alert_description class="text-gray-600">
|
||||
Please review the changes before continuing.
|
||||
</.alert_description>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def alert_description(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"text-sm [&_p]:leading-relaxed",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"default" => "bg-background text-foreground",
|
||||
"destructive" =>
|
||||
"bg-background border-destructive/50 text-destructive dark:border-destructive [&>span]:text-destructive"
|
||||
}
|
||||
}
|
||||
|
||||
@default_variants %{
|
||||
variant: "default"
|
||||
}
|
||||
|
||||
defp variant(variants) do
|
||||
variants = Map.merge(@default_variants, variants)
|
||||
|
||||
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,331 @@
|
||||
defmodule RiverConnectWeb.Components.UI.AlertDialog do
|
||||
@moduledoc """
|
||||
Implementation of Alert Dialog component for SaladUI framework.
|
||||
|
||||
Alert Dialogs are modal dialogs that require a user action before they can be dismissed,
|
||||
used to confirm user decisions or provide critical information.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.alert_dialog id="delete-confirmation">
|
||||
<.alert_dialog_trigger>
|
||||
<.button variant="destructive">Delete Account</.button>
|
||||
</.alert_dialog_trigger>
|
||||
<.alert_dialog_content>
|
||||
<.alert_dialog_header>
|
||||
<.alert_dialog_title>Are you absolutely sure?</.alert_dialog_title>
|
||||
<.alert_dialog_description>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
account and remove your data from our servers.
|
||||
</.alert_dialog_description>
|
||||
</.alert_dialog_header>
|
||||
<.alert_dialog_footer>
|
||||
<.alert_dialog_cancel>Cancel</.alert_dialog_cancel>
|
||||
<.alert_dialog_action>Continue</.alert_dialog_action>
|
||||
</.alert_dialog_footer>
|
||||
</.alert_dialog_content>
|
||||
</.alert_dialog>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main alert dialog component.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the alert dialog.
|
||||
* `:open` - Whether the alert dialog is initially open. Defaults to `false`.
|
||||
* `:on-open` - Handler for alert dialog open event.
|
||||
* `:on-close` - Handler for alert dialog close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the alert dialog"
|
||||
attr :open, :boolean, default: false, doc: "Whether the alert dialog is initially open"
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for alert dialog open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for alert dialog close event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def alert_dialog(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()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative z-50 group/alert-dialog", @class])}
|
||||
data-component="dialog"
|
||||
data-options={@options}
|
||||
data-state={@initial_state}
|
||||
data-event-mappings={@event_map}
|
||||
phx-hook="SaladUI"
|
||||
data-part="root"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The trigger element that opens the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :as, :any, default: "div"
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_trigger(assigns) do
|
||||
~H"""
|
||||
<.dynamic data-part="trigger" data-action="open" tag={@as} class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The content container of the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_content(assigns) do
|
||||
~H"""
|
||||
<div data-part="content" tabindex="0" hidden class="z-50">
|
||||
<div
|
||||
data-part="overlay"
|
||||
class="fixed inset-0 bg-black/80 data-[state=open]/dialog:animate-in data-[state=closed]/dialog:animate-out data-[state=closed]/dialog:fade-out-0 data-[state=open]/dialog:fade-in-0 z-50"
|
||||
/>
|
||||
<div
|
||||
data-part="content-panel"
|
||||
class={
|
||||
classes([
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
|
||||
<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]/dialog:bg-accent data-[state=open]/dialog:text-muted-foreground"
|
||||
>
|
||||
<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 """
|
||||
The header section of the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_header(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The title of the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_title(assigns) do
|
||||
~H"""
|
||||
<h2
|
||||
data-part="title"
|
||||
class={
|
||||
classes([
|
||||
"text-lg font-semibold",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</h2>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The description of the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_description(assigns) do
|
||||
~H"""
|
||||
<p
|
||||
data-part="description"
|
||||
class={
|
||||
classes([
|
||||
"text-sm text-muted-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The footer section of the alert dialog containing action buttons.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def alert_dialog_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 cancel button for the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(default secondary destructive outline ghost link),
|
||||
default: "outline"
|
||||
|
||||
attr :size, :string, values: ~w(default sm lg icon), default: "default"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def alert_dialog_cancel(assigns) do
|
||||
assigns =
|
||||
assign(
|
||||
assigns,
|
||||
:variant_class,
|
||||
button_variant(%{variant: assigns.variant, size: assigns.size})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
data-part="cancel"
|
||||
data-action="close"
|
||||
class={
|
||||
classes([
|
||||
@variant_class,
|
||||
"mt-2 sm:mt-0",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The primary action button for the alert dialog.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(default secondary destructive outline ghost link),
|
||||
default: "default"
|
||||
|
||||
attr :size, :string, values: ~w(default sm lg icon), default: "default"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def alert_dialog_action(assigns) do
|
||||
assigns =
|
||||
assign(
|
||||
assigns,
|
||||
:variant_class,
|
||||
button_variant(%{variant: assigns.variant, size: assigns.size})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
data-part="action"
|
||||
class={
|
||||
classes([
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Avatar do
|
||||
@moduledoc """
|
||||
Implementation of avatar component for user profile pictures with fallback support.
|
||||
|
||||
Avatars are visual representations of users, typically displaying a profile picture
|
||||
or initials as a fallback. The component consists of a container, image, and fallback
|
||||
elements to ensure consistent display even when images fail to load.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.avatar>
|
||||
<.avatar_image src="/images/profile.jpg" alt="User avatar" />
|
||||
<.avatar_fallback>JD</.avatar_fallback>
|
||||
</.avatar>
|
||||
|
||||
<.avatar class="h-12 w-12">
|
||||
<.avatar_image src={@user.avatar_url} alt={@user.name} />
|
||||
<.avatar_fallback>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0zm-5.5-2.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zM10 12a5.99 5.99 0 0 0-4.793 2.39A6.483 6.483 0 0 0 10 16.5a6.483 6.483 0 0 0 4.793-2.11A5.99 5.99 0 0 0 10 12z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</.avatar_fallback>
|
||||
</.avatar>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders an avatar container.
|
||||
|
||||
The avatar container is a wrapper that holds the avatar image and fallback content.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the avatar container.
|
||||
|
||||
## Examples
|
||||
|
||||
<.avatar>
|
||||
<.avatar_image src="/images/profile.jpg" alt="User avatar" />
|
||||
<.avatar_fallback>JD</.avatar_fallback>
|
||||
</.avatar>
|
||||
|
||||
<.avatar class="h-16 w-16">...</.avatar>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: false
|
||||
|
||||
def avatar(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
class={classes(["relative h-10 w-10 shrink-0 overflow-hidden rounded-full", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an avatar image.
|
||||
|
||||
The image is set to display only after it has loaded, preventing layout shifts
|
||||
and allowing the fallback to show during loading.
|
||||
|
||||
## Options
|
||||
|
||||
* `:src` - The URL of the avatar image.
|
||||
* `:alt` - The alt text for the image for accessibility.
|
||||
* `:class` - Additional CSS classes to apply to the image.
|
||||
|
||||
## Examples
|
||||
|
||||
<.avatar_image src="/images/profile.jpg" alt="User profile" />
|
||||
<.avatar_image src={@user.avatar_url} alt={@user.name} class="border-2" />
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(alt src)
|
||||
|
||||
def avatar_image(assigns) do
|
||||
~H"""
|
||||
<img
|
||||
class={classes(["aspect-square h-full w-full", @class])}
|
||||
{@rest}
|
||||
phx-update="ignore"
|
||||
style="display:none"
|
||||
onload="this.style.display=''"
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an avatar fallback element.
|
||||
|
||||
The fallback is displayed when the avatar image is not available or during image loading.
|
||||
This can contain text (like user initials) or an icon.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the fallback element.
|
||||
|
||||
## Examples
|
||||
|
||||
<.avatar_fallback>JD</.avatar_fallback>
|
||||
|
||||
<.avatar_fallback class="bg-primary text-primary-foreground">
|
||||
<svg><!-- User icon SVG --></svg>
|
||||
</.avatar_fallback>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: false
|
||||
|
||||
def avatar_fallback(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
class={
|
||||
classes(["flex h-full w-full items-center justify-center rounded-full bg-muted", @class])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Badge do
|
||||
@moduledoc """
|
||||
Implementation of badge component for displaying short labels, statuses, or counts.
|
||||
|
||||
Badges are small UI elements typically used to highlight status, categories, or counts
|
||||
in a compact format. They are designed to be visually distinct and draw attention to
|
||||
important information.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.badge>New</.badge>
|
||||
<.badge variant="secondary">Beta</.badge>
|
||||
<.badge variant="destructive">Error</.badge>
|
||||
<.badge variant="outline">Version 1.0</.badge>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.badge>Default</.badge>
|
||||
<.badge variant="secondary">Secondary</.badge>
|
||||
<.badge variant="destructive">Destructive</.badge>
|
||||
<.badge variant="outline">Outline</.badge>
|
||||
</div>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a badge component.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the badge.
|
||||
* `:variant` - The visual style of the badge. Available variants:
|
||||
* `"default"` - Primary color with white text (default)
|
||||
* `"secondary"` - Secondary color with contrasting text
|
||||
* `"destructive"` - Typically red, for warning or error states
|
||||
* `"outline"` - Bordered style with no background
|
||||
|
||||
## Examples
|
||||
|
||||
<.badge>Badge</.badge>
|
||||
<.badge variant="destructive">Warning</.badge>
|
||||
<.badge variant="outline" class="text-sm">Custom</.badge>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(default secondary destructive outline),
|
||||
default: "default",
|
||||
doc: "the badge variant style"
|
||||
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def badge(assigns) do
|
||||
assigns = assign(assigns, :variant_class, variant(assigns))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"default" => "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
"secondary" =>
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"destructive" =>
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
"outline" => "text-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
@default_variants %{
|
||||
variant: "default"
|
||||
}
|
||||
|
||||
defp variant(props) do
|
||||
variants = Map.merge(@default_variants, props)
|
||||
|
||||
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,295 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Breadcrumb do
|
||||
@moduledoc """
|
||||
Implementation of breadcrumb component from https://ui.shadcn.com/docs/components/breadcrumb
|
||||
|
||||
Breadcrumbs help users navigate through the application by showing the current location
|
||||
and providing links to previous levels in the hierarchy.
|
||||
|
||||
## Features
|
||||
|
||||
* Semantic HTML structure with proper ARIA attributes for accessibility
|
||||
* Visual separators between navigation items
|
||||
* Current page indication with proper styling
|
||||
* Optional ellipsis for collapsed paths
|
||||
* Responsive design capabilities
|
||||
|
||||
## Examples
|
||||
|
||||
<.breadcrumb>
|
||||
<.breadcrumb_list>
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_link href="/">Home</.breadcrumb_link>
|
||||
</.breadcrumb_item>
|
||||
<.breadcrumb_separator />
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_link href="/components">Components</.breadcrumb_link>
|
||||
</.breadcrumb_item>
|
||||
<.breadcrumb_separator />
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_page>Breadcrumb</.breadcrumb_page>
|
||||
</.breadcrumb_item>
|
||||
</.breadcrumb_list>
|
||||
</.breadcrumb>
|
||||
|
||||
For responsive breadcrumbs that collapse on mobile:
|
||||
|
||||
<.breadcrumb>
|
||||
<.breadcrumb_list>
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_link href="/">Home</.breadcrumb_link>
|
||||
</.breadcrumb_item>
|
||||
<.breadcrumb_separator />
|
||||
|
||||
<!-- Show on desktop only -->
|
||||
<div class="hidden md:flex md:items-center">
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_link href="/components">Components</.breadcrumb_link>
|
||||
</.breadcrumb_item>
|
||||
<.breadcrumb_separator />
|
||||
</div>
|
||||
|
||||
<!-- Show on mobile only -->
|
||||
<div class="md:hidden">
|
||||
<.breadcrumb_ellipsis />
|
||||
<.breadcrumb_separator />
|
||||
</div>
|
||||
|
||||
<.breadcrumb_item>
|
||||
<.breadcrumb_page>Breadcrumb</.breadcrumb_page>
|
||||
</.breadcrumb_item>
|
||||
</.breadcrumb_list>
|
||||
</.breadcrumb>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a breadcrumb.
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the breadcrumb container
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def breadcrumb(assigns) do
|
||||
~H"""
|
||||
<nav
|
||||
arial-label="breadcrumb"
|
||||
class={
|
||||
classes([
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders breadcrumb list.
|
||||
|
||||
Wraps the breadcrumb items in an ordered list to represent the hierarchical structure.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the list
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def breadcrumb_list(assigns) do
|
||||
~H"""
|
||||
<ol
|
||||
class={
|
||||
classes([
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</ol>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a breadcrumb item.
|
||||
|
||||
Individual item in the breadcrumb path that can contain a link or the current page.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the item
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def breadcrumb_item(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center gap-1.5",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a breadcrumb link.
|
||||
|
||||
Used for all breadcrumb items except the current page.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the link
|
||||
* Standard HTML link attributes (href, target, etc.) are supported
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(download href hreflang ping referrerpolicy rel target type)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def breadcrumb_link(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
class={
|
||||
classes([
|
||||
"transition-colors hover:text-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the current page breadcrumb item.
|
||||
|
||||
Used for the final item in the breadcrumb path, representing the current page.
|
||||
The current page is not a link but styled differently to indicate the current location.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the current page element
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def breadcrumb_page(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
role="link"
|
||||
class={
|
||||
classes([
|
||||
"font-normal text-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a separator between breadcrumb items.
|
||||
|
||||
Visual indicator that separates items in the breadcrumb path.
|
||||
By default, shows a chevron right icon.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the separator
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def breadcrumb_separator(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
role="presentation"
|
||||
class={
|
||||
classes([
|
||||
"[&>svg]:size-3.5",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-6 w-3"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an ellipsis indicator for collapsed breadcrumb items.
|
||||
|
||||
Used in responsive designs to indicate that some breadcrumb items are hidden.
|
||||
Typically used for middle items in a long breadcrumb path on mobile views.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the ellipsis
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def breadcrumb_ellipsis(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"flex h-9 w-9 items-center justify-center",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span class="sr-only">More</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Button do
|
||||
@moduledoc """
|
||||
Button component for user interactions.
|
||||
|
||||
Provides a versatile button component with various styles, sizes, and states
|
||||
to handle user interactions throughout the application.
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a button with configurable styles and behaviors.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:type` - HTML button type attribute (e.g., "button", "submit")
|
||||
* `:class` - Additional CSS classes
|
||||
* `:variant` - Visual style variant of the button:
|
||||
* `"default"` - Primary action button
|
||||
* `"secondary"` - Secondary action button
|
||||
* `"destructive"` - Buttons for destructive actions
|
||||
* `"outline"` - Button with outline style
|
||||
* `"ghost"` - Button with minimal styling
|
||||
* `"link"` - Button that appears as a link
|
||||
* `:size` - Size of the button:
|
||||
* `"default"` - Standard size
|
||||
* `"sm"` - Small size
|
||||
* `"lg"` - Large size
|
||||
* `"icon"` - Square button optimized for icons
|
||||
* `:rest` - Additional HTML attributes including `disabled`, `form`, `name`, `value`
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send</.button>
|
||||
<.button variant="destructive" phx-click="delete">Delete</.button>
|
||||
<.button variant="outline" size="sm">Cancel</.button>
|
||||
<.button variant="ghost" size="icon">
|
||||
<.icon name="hero-x-mark" />
|
||||
</.button>
|
||||
<.button type="submit" phx-disable-with="Saving...">Save Changes</.button>
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :any, default: nil
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(default secondary destructive outline ghost link),
|
||||
default: "default",
|
||||
doc: "the button variant style"
|
||||
|
||||
attr :size, :string, values: ~w(default sm lg icon), default: "default"
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def(button(assigns)) do
|
||||
assigns = assign(assigns, :variant_class, button_variant(assigns))
|
||||
|
||||
~H"""
|
||||
<button
|
||||
type={@type}
|
||||
class={
|
||||
classes([
|
||||
"phx-submit-loading:opacity-75",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Card do
|
||||
@moduledoc """
|
||||
Card components for containing content and actions.
|
||||
|
||||
Cards provide a flexible container for displaying content with support for headers,
|
||||
footers, and various content layouts. They organize information and actions consistently.
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a card container.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.card>
|
||||
<.card_header>
|
||||
<.card_title>Account Settings</.card_title>
|
||||
<.card_description>Manage your account settings.</.card_description>
|
||||
</.card_header>
|
||||
<.card_content>
|
||||
<p>Your account details and preferences.</p>
|
||||
</.card_content>
|
||||
<.card_footer>
|
||||
<.button variant="outline">Cancel</.button>
|
||||
<.button>Save</.button>
|
||||
</.card_footer>
|
||||
</.card>
|
||||
"""
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["rounded-xl border bg-card text-card-foreground shadow", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card header section for title and description.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card_header(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["flex flex-col space-y-1.5 p-6", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card title within the header section.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card_title(assigns) do
|
||||
~H"""
|
||||
<h3 class={classes(["text-2xl font-semibold leading-none tracking-tight", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</h3>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a card description within the header section.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card_description(assigns) do
|
||||
~H"""
|
||||
<p class={classes(["text-sm text-muted-foreground", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the main content area of the card.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card_content(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["p-6 pt-0", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a footer section for the card, typically containing actions.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def card_footer(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["flex items-center justify-between p-6 pt-0 ", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Chart do
|
||||
@moduledoc """
|
||||
Chart component.
|
||||
|
||||
This component displays a chart using a live component for real-time updates. Data and configuration are passed as attributes, and the rendering is managed on the client side through a hook called `ChartHook`. This hook initializes, manages the lifecycle, and renders the chart using a chart library. `SaladUI` comes with [Chart.js](https://www.chartjs.org/) as default, but you can rewrite `ChartHook` to integrate another chart library.
|
||||
|
||||
## Chart options
|
||||
|
||||
The `chart_options` map defines the appearance and behavior of the chart:
|
||||
|
||||
- Special keys
|
||||
- `labels`: A list of labels for the x-axis
|
||||
- `type`: Chart type (e.g., "line", "bar") - must match the chart library's supported types
|
||||
- `options`: A map of chart options following the chart library's API
|
||||
|
||||
- Dataset configuration:
|
||||
Any additional keys in the options map define datasets. For example:
|
||||
|
||||
```elixir
|
||||
%{
|
||||
labels: ["Jan", "Feb"],
|
||||
type: "line",
|
||||
options: %{responsive: true},
|
||||
desktop: %{ # Dataset configuration
|
||||
label: "Desktop Usage", # Chart.js dataset options
|
||||
datakey: "desktop_value" # Optional: custom key for data mapping
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chart data
|
||||
|
||||
The `chart_data` must be a list of maps. Each map represents a data point:
|
||||
|
||||
```elixir
|
||||
[
|
||||
%{desktop: 10, mobile: 20}, # First data point
|
||||
%{desktop: 15, mobile: 25} # Second data point
|
||||
]
|
||||
```
|
||||
|
||||
The data could be in any shape and you can map a dataset from the chart configuration to a data point in the chart data using the `datakey` key.
|
||||
|
||||
All data points that do not match any datasets will be ignored.
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :name, :string, default: "", doc: "name of the chart for screen readers"
|
||||
attr :"chart-type", :string, default: "line", doc: "type of the chart (e.g., line, bar)"
|
||||
attr :"chart-options", :map, required: true
|
||||
attr :"chart-data", :list, required: true
|
||||
|
||||
def chart(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:chart_options, assigns[:"chart-options"])
|
||||
|> assign(:chart_data, assigns[:"chart-data"])
|
||||
|> assign(:chart_type, assigns[:"chart-type"])
|
||||
|
||||
~H"""
|
||||
<canvas
|
||||
id={@id}
|
||||
name={@name}
|
||||
phx-hook="SaladUI"
|
||||
data-component="chart"
|
||||
data-part="root"
|
||||
data-chart-type={@chart_type}
|
||||
data-chart-options={Jason.encode!(@chart_options)}
|
||||
data-chart-data={Jason.encode!(@chart_data)}
|
||||
role="img"
|
||||
aria-label={@name}
|
||||
>
|
||||
</canvas>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Checkbox do
|
||||
@moduledoc """
|
||||
Implementation of checkbox component from https://ui.shadcn.com/docs/components/checkbox
|
||||
|
||||
## Examples:
|
||||
|
||||
<.checkbox name="terms" id="terms" />
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<.checkbox id="terms" name="terms" />
|
||||
<.label for="terms">Accept terms and conditions</.label>
|
||||
</div>
|
||||
|
||||
<.checkbox id="remember_me" name="remember_me" label="Remember me" />
|
||||
|
||||
<.form for={@form} as={:user} phx-change="validate">
|
||||
<.checkbox field={@form[:accept_terms]} label="I accept the terms and conditions" />
|
||||
</.form>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a checkbox input with SaladUI styling.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - The id to apply to the checkbox
|
||||
* `:name` - The name to apply to the input field
|
||||
* `:value` - The current value of the checkbox
|
||||
* `:default-value` - The default value of the checkbox, either `true`, `false`, "true", "false"
|
||||
* `:disabled` - Whether the checkbox is disabled
|
||||
* `:field` - A Phoenix form field
|
||||
* `:class` - Additional classes to add to the checkbox
|
||||
"""
|
||||
attr :name, :any, default: nil
|
||||
attr :value, :any, default: nil
|
||||
attr :"default-value", :any, values: [true, false, "true", "false"], default: false
|
||||
attr :field, Phoenix.HTML.FormField
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def checkbox(assigns) do
|
||||
assigns =
|
||||
prepare_assign(assigns)
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns.value)
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class={
|
||||
classes([
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 checked:bg-primary checked:focus:bg-primary checked:hover:bg-primary checked:text-primary-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Collapsible do
|
||||
@moduledoc """
|
||||
Implementation of Collapsible component for SaladUI framework.
|
||||
|
||||
This component allows content to be shown or hidden with smooth animations,
|
||||
accessibility support, and keyboard navigation.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.collapsible id="collapsible-1" open>
|
||||
<.collapsible_trigger>
|
||||
<.button variant="outline">Show content</.button>
|
||||
</.collapsible_trigger>
|
||||
<.collapsible_content>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua.
|
||||
</p>
|
||||
</.collapsible_content>
|
||||
</.collapsible>
|
||||
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main collapsible component.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the collapsible.
|
||||
* `:open` - Whether the collapsible is initially open. Defaults to `false`.
|
||||
* `:on-open` - Handler for collapsible open event.
|
||||
* `:on-close` - Handler for collapsible close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the collapsible"
|
||||
attr :open, :boolean, default: false, doc: "Whether the collapsible is initially open"
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for collapsible open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for collapsible close event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def collapsible(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, Jason.encode!(event_map))
|
||||
|> assign(:initial_state, if(assigns.open, do: "open", else: "closed"))
|
||||
|> assign(
|
||||
:options,
|
||||
Jason.encode!(%{
|
||||
open: assigns.open,
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative", @class])}
|
||||
data-component="collapsible"
|
||||
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 toggles the collapsible content.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :as, :any, default: "div"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def collapsible_trigger(assigns) do
|
||||
~H"""
|
||||
<.dynamic
|
||||
data-part="trigger"
|
||||
data-action="toggle"
|
||||
tag={@as}
|
||||
class={classes(["cursor-pointer", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The collapsible content that appears when triggered.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def collapsible_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="content"
|
||||
hidden
|
||||
class={
|
||||
classes([
|
||||
"transition-all duration-200 ease-in-out",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"closed_to_open" => %{
|
||||
animation: ["ease-out duration-200", "opacity-0", "opacity-100"],
|
||||
duration: 200,
|
||||
target_part: "content"
|
||||
},
|
||||
"open_to_closed" => %{
|
||||
animation: ["ease-out duration-200", "opacity-100", "opacity-70"],
|
||||
duration: 200,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,326 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Command do
|
||||
@moduledoc """
|
||||
Command palette components for RiverConnectWeb.Components.UI.
|
||||
|
||||
Provides a set of components to build a command palette or searchable menu, similar to those found in modern applications.
|
||||
|
||||
## Example
|
||||
|
||||
<.command id="command" class="rounded-lg border shadow-md md:min-w-[450px] w-full lg:max-w-[600px]">
|
||||
<.command_input placeholder="Type a command or search..." />
|
||||
<.command_empty>
|
||||
<span>No results found</span>
|
||||
</.command_empty>
|
||||
<.command_list>
|
||||
<.command_group heading="Suggestions">
|
||||
<.command_item phx-value-name="calendar" phx-click="select_command">
|
||||
<.calendar class="w-4 h-4"/>
|
||||
<span>Calendar</span>
|
||||
</.command_item>
|
||||
...
|
||||
</.command_group>
|
||||
<.command_group heading="Settings">
|
||||
<.command_item phx-value-name="profile" phx-click="select_command">
|
||||
<.user class="w-4 h-4"/>
|
||||
<span>Profile</span>
|
||||
<.command_shortcut>⌘P</.command_shortcut>
|
||||
</.command_item>
|
||||
...
|
||||
</.command_group>
|
||||
</.command_list>
|
||||
</.command>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
import RiverConnectWeb.Components.UI.Dialog
|
||||
import RiverConnectWeb.Components.UI.Icon
|
||||
|
||||
@doc """
|
||||
Renders the root command palette container.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:id` (required) - The unique id for the command palette.
|
||||
* `:class` - Additional classes to apply.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The content of the command palette.
|
||||
|
||||
## Example
|
||||
|
||||
<.command id="my-command">
|
||||
...
|
||||
</.command>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :class, :any, default: ""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
tabindex="-1"
|
||||
data-component="command"
|
||||
data-part="root"
|
||||
phx-hook="SaladUI"
|
||||
class={
|
||||
classes([
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a command palette inside a dialog.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:id` (required) - The unique id for the command palette.
|
||||
* `:open` - Whether the dialog is open.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The content of the command palette.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_dialog id="my-command" open={@show_command}>
|
||||
...
|
||||
</.command_dialog>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :open, :boolean, default: false
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_dialog(assigns) do
|
||||
~H"""
|
||||
<.dialog id={@id <> "_dialog"} open={@open}>
|
||||
<.dialog_content class="overflow-hidden p-0">
|
||||
<.command id={@id} class="[&_[data-part='input']]:h-12">
|
||||
{render_slot(@inner_block)}
|
||||
</.command>
|
||||
</.dialog_content>
|
||||
</.dialog>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the input field for searching/filtering commands.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional classes to apply.
|
||||
* All global attributes are passed to the `<input>` element.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_input placeholder="Type a command..." />
|
||||
"""
|
||||
attr :class, :any, default: ""
|
||||
attr :rest, :global, default: %{}
|
||||
|
||||
def command_input(assigns) do
|
||||
~H"""
|
||||
<div data-part="input-wrapper" class="flex items-center border-b px-3">
|
||||
<.icon name="hero-magnifying-glass" />
|
||||
<input
|
||||
type="text"
|
||||
data-part="input"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
class={
|
||||
classes([
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 border-none focus:ring-transparent",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the list container for command items and groups.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional classes to apply.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The content of the command list (groups/items).
|
||||
|
||||
## Example
|
||||
|
||||
<.command_list>
|
||||
<.command_group heading="Actions">
|
||||
<.command_item>...</.command_item>
|
||||
</.command_group>
|
||||
</.command_list>
|
||||
"""
|
||||
attr :class, :any, default: ""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_list(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="list"
|
||||
tabindex="-1"
|
||||
role="listbox"
|
||||
class={classes(["max-h-[300px] overflow-y-auto overflow-x-hidden", @class])}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a message when no command results are found.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional classes to apply.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The content to display when empty.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_empty>
|
||||
<span>No results found</span>
|
||||
</.command_empty>
|
||||
"""
|
||||
attr :class, :any, default: ""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_empty(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-visible="false"
|
||||
data-part="empty"
|
||||
class={classes(["py-6 text-center text-sm data-[visible=false]:hidden", @class])}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a group of command items with a heading.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:heading` (required) - The group heading.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The command items in the group.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_group heading="Settings">
|
||||
<.command_item>Profile</.command_item>
|
||||
</.command_group>
|
||||
"""
|
||||
attr :heading, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_group(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
role="presentation"
|
||||
data-part="group"
|
||||
class="overflow-hidden p-1 text-foreground data-[visible=false]:hidden"
|
||||
>
|
||||
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{@heading}
|
||||
</div>
|
||||
<div role="group" class="list-none">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single command item (button).
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:disabled` - Whether the item is disabled.
|
||||
* `:selected` - Whether the item is selected.
|
||||
* All global attributes are passed to the `<button>` element.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The content of the command item.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_item phx-click="select_command">
|
||||
<.icon name="calendar" />
|
||||
<span>Calendar</span>
|
||||
</.command_item>
|
||||
"""
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :selected, :boolean, default: false
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_item(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
data-part="item"
|
||||
class="[&_svg]:h-4 [&_svg]:w-4 relative flex cursor-default w-full gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none disabled:pointer-events-none hover:bg-accent/75 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[visible=false]:hidden disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"
|
||||
data-selected={@selected}
|
||||
aria-selected={@selected}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a keyboard shortcut hint for a command item.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional classes to apply.
|
||||
|
||||
## Slots
|
||||
|
||||
* `:inner_block` (required) - The shortcut text.
|
||||
|
||||
## Example
|
||||
|
||||
<.command_shortcut>⌘P</.command_shortcut>
|
||||
"""
|
||||
attr :class, :any, default: ""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def command_shortcut(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
data-part="shortcut"
|
||||
class={classes(["ml-auto text-xs tracking-widest text-muted-foreground", @class])}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Dialog do
|
||||
@moduledoc """
|
||||
Implement of Dialog components from https://ui.shadcn.com/docs/components/dialog
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Dialog component
|
||||
|
||||
## Examples:
|
||||
|
||||
<.dialog id="pro-dialog" open={true}>
|
||||
<.dialog_trigger>Click me</.dialog_trigger>
|
||||
<.dialog_content class="sm:max-w-[425px]">
|
||||
<.dialog_header>
|
||||
<.dialog_title>Edit profile</.dialog_title>
|
||||
<.dialog_description>
|
||||
Make changes to your profile here click save when you're done
|
||||
</.dialog_description>
|
||||
</.dialog_header>
|
||||
<div class_name="grid gap-4 py-4">
|
||||
<div class_name="grid grid-cols_4 items-center gap-4">
|
||||
<.label for="name" class-name="text-right">
|
||||
name
|
||||
</.label>
|
||||
<input id="name" value="pedro duarte" class-name="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items_center gap-4">
|
||||
<.label for="username" class="text-right">
|
||||
username
|
||||
</.label>
|
||||
<input id="username" value="@peduarte" class="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<.dialog_footer>
|
||||
<.button type="submit">save changes</.button>
|
||||
</.dialog_footer>
|
||||
</.dialog_content>
|
||||
</.dialog>
|
||||
"""
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :open, :boolean, default: false
|
||||
attr :class, :string, default: nil
|
||||
attr :"close-on-outside-click", :boolean, default: true
|
||||
|
||||
attr :"on-open", :any,
|
||||
default: nil,
|
||||
doc: "Handler for dialog open event. Support both server event handler and JS command struct"
|
||||
|
||||
attr :"on-close", :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"Handler for dialog closed event. Support both server event handler and JS command struct"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog(assigns) do
|
||||
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(%{
|
||||
closeOnOutsideClick: assigns[:"close-on-outside-click"],
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class="relative z-50 group/dialog"
|
||||
data-component="dialog"
|
||||
data-options={@options}
|
||||
data-open={to_string(@open)}
|
||||
data-event-mappings={@event_map}
|
||||
phx-hook="SaladUI"
|
||||
data-part="root"
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :as, :any, default: "div"
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def dialog_trigger(assigns) do
|
||||
~H"""
|
||||
<.dynamic data-part="trigger" data-action="open" tag={@as} class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog_content(assigns) do
|
||||
~H"""
|
||||
<div data-part="content" tabindex="0" hidden class="z-50">
|
||||
<div
|
||||
data-part="overlay"
|
||||
class="fixed inset-0 bg-black/80 data-[state=open]/dialog:animate-in data-[state=closed]/dialog:animate-out data-[state=closed]/dialog:fade-out-0 data-[state=open]/dialog:fade-in-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
data-part="content-panel"
|
||||
class={
|
||||
classes([
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
@class
|
||||
])
|
||||
}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
|
||||
<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]/dialog:bg-accent data-[state=open]/dialog:text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-x h-4 w-4"
|
||||
>
|
||||
<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
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog_header(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["flex flex-col space-y-1.5 text-center sm:text-left", @class])}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog_title(assigns) do
|
||||
~H"""
|
||||
<h3 class={classes(["text-lg font-semibold leading-none tracking-tight", @class])}>
|
||||
{render_slot(@inner_block)}
|
||||
</h3>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog_description(assigns) do
|
||||
~H"""
|
||||
<p class={classes(["text-sm text-muted-foreground", @class])}>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dialog_footer(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", @class])}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,462 @@
|
||||
defmodule RiverConnectWeb.Components.UI.DropdownMenu do
|
||||
@moduledoc """
|
||||
Implementation of dropdown menu component for SaladUI framework.
|
||||
|
||||
Dropdown menus display a list of options when a trigger element is clicked.
|
||||
They provide a way to select from multiple options while conserving screen space.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.dropdown_menu id="user-menu">
|
||||
<.dropdown_menu_trigger>
|
||||
<.button variant="outline">Open Menu</.button>
|
||||
</.dropdown_menu_trigger>
|
||||
<.dropdown_menu_content>
|
||||
<.dropdown_menu_label>My Account</.dropdown_menu_label>
|
||||
<.dropdown_menu_separator />
|
||||
<.dropdown_menu_group>
|
||||
<.dropdown_menu_item on-select={JS.push("profile_selected")}>
|
||||
Profile
|
||||
<.dropdown_menu_shortcut>⌘P</.dropdown_menu_shortcut>
|
||||
</.dropdown_menu_item>
|
||||
<.dropdown_menu_item on-select={JS.push("settings_selected")}>
|
||||
Settings
|
||||
<.dropdown_menu_shortcut>⌘S</.dropdown_menu_shortcut>
|
||||
</.dropdown_menu_item>
|
||||
<.dropdown_menu_item disabled>
|
||||
Disabled Option
|
||||
</.dropdown_menu_item>
|
||||
</.dropdown_menu_group>
|
||||
<.dropdown_menu_separator />
|
||||
<.dropdown_menu_item variant="destructive" on-select={JS.push("logout")}>
|
||||
Log out
|
||||
</.dropdown_menu_item>
|
||||
</.dropdown_menu_content>
|
||||
</.dropdown_menu>
|
||||
|
||||
## Example with checkbox items
|
||||
<.dropdown_menu id="options-menu">
|
||||
<.dropdown_menu_trigger>
|
||||
<.button variant="outline">Options</.button>
|
||||
</.dropdown_menu_trigger>
|
||||
<.dropdown_menu_content>
|
||||
<.dropdown_menu_checkbox_item
|
||||
checked={@is_bold}
|
||||
on-checked-change={JS.push("toggle_bold")}
|
||||
>
|
||||
Bold
|
||||
</.dropdown_menu_checkbox_item>
|
||||
<.dropdown_menu_checkbox_item
|
||||
checked={@is_italic}
|
||||
on-checked-change={JS.push("toggle_italic")}
|
||||
>
|
||||
Italic
|
||||
</.dropdown_menu_checkbox_item>
|
||||
</.dropdown_menu_content>
|
||||
</.dropdown_menu>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main dropdown menu component that manages state and positioning.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the dropdown menu.
|
||||
* `:open` - Whether the dropdown is initially open. Defaults to `false`.
|
||||
* `:use-portal` - Whether to render the dropdown in a portal. Defaults to `false`.
|
||||
* `:portal-container` - CSS selector for the portal container. Defaults to `nil`.
|
||||
* `:on-open` - Handler for dropdown menu open event.
|
||||
* `:on-close` - Handler for dropdown menu close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the dropdown menu"
|
||||
attr :open, :boolean, default: false, doc: "Whether the dropdown menu is initially open"
|
||||
attr :"use-portal", :boolean, default: false, doc: "Whether to render the content in a portal"
|
||||
attr :"portal-container", :string, default: nil, doc: "CSS selector for the portal container"
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for dropdown menu open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for dropdown menu close event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
%{}
|
||||
|> add_event_mapping(assigns, "opened", :"on-open")
|
||||
|> add_event_mapping(assigns, "closed", :"on-close")
|
||||
|
||||
# Convert kebab-case attributes to snake_case for use in the template
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, json(event_map))
|
||||
|> assign(:initial_state, if(assigns.open, do: "open", else: "closed"))
|
||||
|> assign(:use_portal, assigns[:"use-portal"])
|
||||
|> assign(:portal_container, assigns[:"portal-container"])
|
||||
|> assign(
|
||||
:options,
|
||||
json(%{
|
||||
usePortal: assigns[:"use-portal"],
|
||||
portalContainer: assigns[:"portal-container"],
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative inline-block", @class])}
|
||||
data-component="dropdown-menu"
|
||||
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 toggles the dropdown menu.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
* `:as` - The HTML tag to use for the trigger. Defaults to `"div"`.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :as, :any, default: "div"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_trigger(assigns) do
|
||||
~H"""
|
||||
<.dynamic tag={@as} data-part="trigger" tab-index="0" class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The dropdown menu content that appears when triggered.
|
||||
|
||||
## Options
|
||||
|
||||
* `:side` - Placement of the dropdown menu relative to the trigger (top, right, bottom, left). Defaults to `"bottom"`.
|
||||
* `:align` - Alignment of the dropdown menu (start, center, end). Defaults to `"start"`.
|
||||
* `:side-offset` - Distance from the trigger in pixels. Defaults to `4`.
|
||||
* `:align-offset` - Offset along the alignment axis. Defaults to `0`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :side, :string, values: ~w(top right bottom left), default: "bottom"
|
||||
attr :align, :string, values: ~w(start center end), default: "start"
|
||||
attr :"side-offset", :integer, default: 4, doc: "Distance from the trigger in pixels"
|
||||
attr :"align-offset", :integer, default: 0, doc: "Offset along the alignment axis"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_content(assigns) do
|
||||
assigns =
|
||||
assign(assigns, %{
|
||||
side_offset: assigns[:"side-offset"],
|
||||
align_offset: assigns[:"align-offset"]
|
||||
})
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="positioner"
|
||||
data-side={@side}
|
||||
data-align={@align}
|
||||
data-side-offset={@side_offset}
|
||||
data-align-offset={@align_offset}
|
||||
class="absolute z-50"
|
||||
style="min-width: var(--salad-reference-width)"
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
data-part="content"
|
||||
class={
|
||||
classes([
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A group of related dropdown menu items.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_group(assigns) do
|
||||
~H"""
|
||||
<div data-part="group" role="group" class={classes([@class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A label for a section in the dropdown menu.
|
||||
|
||||
## Options
|
||||
|
||||
* `:inset` - Whether to inset the label. Defaults to `false`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :inset, :boolean, default: false
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_label(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="label"
|
||||
class={classes(["px-2 py-1.5 text-sm font-semibold", @inset && "pl-8", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
An item in the dropdown menu.
|
||||
|
||||
## Options
|
||||
|
||||
* `:disabled` - Whether the item is disabled. Defaults to `false`.
|
||||
* `:variant` - Visual style variant of the item (default or destructive).
|
||||
* `:on-select` - Handler for item selection.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :value, :string, default: nil
|
||||
attr :variant, :string, values: ~w(default destructive), default: "default"
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :"on-select", :any, default: nil, doc: "Handler for item selection"
|
||||
attr :as, :any, default: "div"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_item(assigns) do
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "item-selected", :"on-select")
|
||||
|
||||
assigns =
|
||||
assign(assigns, :event_map, json(event_map))
|
||||
|
||||
~H"""
|
||||
<.dynamic
|
||||
tag={@as}
|
||||
data-part="item"
|
||||
data-value={@value}
|
||||
data-disabled={@disabled}
|
||||
data-event-mappings={@event_map}
|
||||
class={
|
||||
classes([
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:mr-2",
|
||||
@variant == "destructive" &&
|
||||
"text-destructive focus:bg-destructive/10 focus:text-destructive dark:focus:bg-destructive/20",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex={if @disabled, do: "-1", else: "0"}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
An item in the dropdown menu.
|
||||
|
||||
## Options
|
||||
|
||||
* `:disabled` - Whether the item is disabled. Defaults to `false`.
|
||||
* `:variant` - Visual style variant of the item (default or destructive).
|
||||
* `:on-select` - Handler for item selection.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :value, :string, default: nil
|
||||
attr :variant, :string, values: ~w(default destructive), default: "default"
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :rest, :global, include: ~w(href method)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_link_item(assigns) do
|
||||
# Collect event mappings
|
||||
|
||||
~H"""
|
||||
<.link
|
||||
data-part="item"
|
||||
data-value={@value}
|
||||
data-disabled={@disabled}
|
||||
class={
|
||||
classes([
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:mr-2",
|
||||
@variant == "destructive" &&
|
||||
"text-destructive focus:bg-destructive/10 focus:text-destructive dark:focus:bg-destructive/20",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex={if @disabled, do: "-1", else: "0"}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A checkbox item in the dropdown menu that can be toggled on/off.
|
||||
|
||||
## Options
|
||||
|
||||
* `:checked` - Whether the item is initially checked. Defaults to `false`.
|
||||
* `:disabled` - Whether the item is disabled. Defaults to `false`.
|
||||
* `:on-checked-change` - Handler for when checked state changes.
|
||||
* `:on-select` - Handler for item selection.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :value, :string, default: nil
|
||||
attr :checked, :boolean, default: false
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :"on-select", :any, default: nil, doc: "Handler for item selected event"
|
||||
attr :"on-checked-change", :any, default: nil, doc: "Handler for when checked state changes"
|
||||
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_checkbox_item(assigns) do
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
%{}
|
||||
|> add_event_mapping(assigns, "checked-changed", :"on-checked-change")
|
||||
|> add_event_mapping(assigns, "item-selected", :"on-select")
|
||||
|
||||
assigns =
|
||||
assign(assigns, :event_map, json(event_map))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="checkbox-item"
|
||||
data-value={@value}
|
||||
data-disabled={@disabled}
|
||||
data-checked={@checked}
|
||||
data-state={(@checked && "checked") || "unchecked"}
|
||||
data-event-mappings={@event_map}
|
||||
class={
|
||||
classes([
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex={if @disabled, do: "-1", else: "0"}
|
||||
{@rest}
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span
|
||||
data-part="item-indicator"
|
||||
data-state={(@checked && "checked") || "unchecked"}
|
||||
hidden={!@checked}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A separator for visually dividing sections of the dropdown menu.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: false
|
||||
|
||||
def dropdown_menu_separator(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="separator"
|
||||
role="separator"
|
||||
class={classes(["-mx-1 my-1 h-px bg-muted", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A keyboard shortcut hint displayed in a dropdown menu item.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_menu_shortcut(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
data-part="shortcut"
|
||||
class={classes(["ml-auto text-xs tracking-widest opacity-60", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Form do
|
||||
@moduledoc """
|
||||
Form-related components that help build accessible forms.
|
||||
|
||||
SaladUI doesn't define its own form component, but instead provides a set of
|
||||
form-related components to enhance the native Phoenix LiveView form.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form
|
||||
class="space-y-6"
|
||||
for={@form}
|
||||
id="project-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.form_item>
|
||||
<.form_label>What is your project's name?</.form_label>
|
||||
<.form_control>
|
||||
<.input field={@form[:name]} type="text" phx-debounce="500" />
|
||||
</.form_control>
|
||||
<.form_description>
|
||||
This is your public display name.
|
||||
</.form_description>
|
||||
<.form_message field={@form[:name]} />
|
||||
</.form_item>
|
||||
|
||||
<div class="w-full flex flex-row-reverse">
|
||||
<.button
|
||||
class="btn btn-secondary btn-md"
|
||||
icon="inbox_arrow_down"
|
||||
phx-disable-with="Saving..."
|
||||
>
|
||||
Save project
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Form item component that acts as a container for a form field.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form_item>
|
||||
<.form_label>Email</.form_label>
|
||||
<.form_control>
|
||||
<.input field={@form[:email]} type="email" />
|
||||
</.form_control>
|
||||
<.form_message field={@form[:email]} />
|
||||
</.form_item>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def form_item(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["space-y-2", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Form label component that renders a label for a form field.
|
||||
|
||||
Shows validation errors through text color when a field has errors.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form_label>Email</.form_label>
|
||||
<.form_label field={@form[:email]}>Email</.form_label>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
default: nil,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :for, :string, default: nil
|
||||
|
||||
attr :error, :boolean, default: false, doc: "whether the field has an error"
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def form_label(assigns) do
|
||||
assigns = assign(assigns, error: assigns[:error] || has_error?(assigns[:field]))
|
||||
|
||||
~H"""
|
||||
<RiverConnectWeb.Components.UI.Label.label
|
||||
for={(@field && @field.id) || @for}
|
||||
class={
|
||||
classes([
|
||||
@error && "text-destructive",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</RiverConnectWeb.Components.UI.Label.label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Form control component that wraps input elements.
|
||||
|
||||
This is a simple pass-through component that renders its inner block.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form_control>
|
||||
<.input field={@form[:email]} type="email" />
|
||||
</.form_control>
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def form_control(assigns) do
|
||||
~H"""
|
||||
{render_slot(@inner_block)}
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Form description component that provides additional context for a form field.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form_description>
|
||||
We'll only use your email for account-related purposes.
|
||||
</.form_description>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def form_description(assigns) do
|
||||
~H"""
|
||||
<p class={classes(["text-muted-foreground text-sm", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Form message component that displays validation errors for a form field.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.form_message field={@form[:email]} />
|
||||
|
||||
<.form_message>
|
||||
Please enter a valid email address.
|
||||
</.form_message>
|
||||
"""
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
default: nil,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :any,
|
||||
default: [],
|
||||
doc: "a list of error messages to display"
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: false
|
||||
attr :rest, :global
|
||||
|
||||
def form_message(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(error: has_error?(assigns[:field]) || not Enum.empty?(assigns[:errors]))
|
||||
|> assign(:message, List.first(assigns[:errors] || field_errors(assigns[:field])))
|
||||
|
||||
~H"""
|
||||
<p
|
||||
:if={(msg = render_slot(@inner_block)) || not is_nil(@error)}
|
||||
class={classes(["text-sm font-medium", @error && "text-destructive", @class])}
|
||||
{@rest}
|
||||
>
|
||||
{msg || @message}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,344 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Helpers do
|
||||
@moduledoc false
|
||||
use Phoenix.Component
|
||||
|
||||
import Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Prepare input assigns for use in a form. Extract required attribute from the Form.Field struct and update current assigns.
|
||||
"""
|
||||
def prepare_assign(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns[:id] || field.id)
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign(
|
||||
:name,
|
||||
assigns[:name] || if(assigns[:multiple], do: field.name <> "[]", else: field.name)
|
||||
)
|
||||
|> assign(:value, assigns[:value] || field.value)
|
||||
|> prepare_assign()
|
||||
end
|
||||
|
||||
# use default value if value is not provided or empty
|
||||
def prepare_assign(assigns) do
|
||||
value =
|
||||
if assigns[:value] in [nil, "", []] do
|
||||
assigns[:"default-value"]
|
||||
else
|
||||
assigns[:value]
|
||||
end
|
||||
|
||||
assign(assigns, value: value)
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function return list of error message from form field
|
||||
"""
|
||||
def field_errors(%Phoenix.HTML.FormField{} = field) do
|
||||
Enum.map(field.errors, &translate_error(&1))
|
||||
end
|
||||
|
||||
def field_errors(_), do: []
|
||||
|
||||
@doc """
|
||||
This function return true if the field has error
|
||||
"""
|
||||
def has_error?(%Phoenix.HTML.FormField{} = field) do
|
||||
not Enum.empty?(field.errors)
|
||||
end
|
||||
|
||||
def has_error?(_field), do: false
|
||||
|
||||
# Helper function to add event mappings
|
||||
def add_event_mapping(map \\ %{}, assigns, event, key) do
|
||||
if assigns[key] do
|
||||
Map.put(map, event, assigns[key])
|
||||
else
|
||||
map
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to encode data to JSON
|
||||
def json(data) do
|
||||
Phoenix.json_library().encode!(data)
|
||||
end
|
||||
|
||||
# normalize_integer
|
||||
def normalize_integer(value) when is_integer(value), do: value
|
||||
|
||||
def normalize_integer(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{integer, _} -> integer
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_integer(_), do: nil
|
||||
|
||||
def normalize_boolean(value) do
|
||||
case value do
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
true -> true
|
||||
false -> false
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Normalize id to be used in HTML id attribute
|
||||
It will replace all non-alphanumeric characters with `-` and downcase the string
|
||||
"""
|
||||
def id(id) do
|
||||
id
|
||||
|> String.replace(~r/[^a-zA-Z0-9]/, "-")
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"default" => "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
"destructive" =>
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"outline" =>
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"secondary" => "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
"ghost" => "hover:bg-accent hover:text-accent-foreground",
|
||||
"link" => "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: %{
|
||||
"default" => "h-9 px-4 py-2",
|
||||
"sm" => "h-8 rounded-md px-3 text-xs",
|
||||
"lg" => "h-10 rounded-md px-8",
|
||||
"icon" => "h-9 w-9"
|
||||
}
|
||||
}
|
||||
|
||||
@default_variants %{
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
|
||||
@doc """
|
||||
Reuseable button variant helper. Support 2 variant
|
||||
- size: `default|sm|lg|icon`
|
||||
- variant: `default|destructive|outline|secondary|ghost|link`
|
||||
"""
|
||||
def button_variant(props \\ %{}) do
|
||||
variants = Map.take(props, ~w(variant size)a)
|
||||
variants = Map.merge(@default_variants, variants)
|
||||
|
||||
variation_classes = Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
|
||||
shared_classes =
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
"#{shared_classes} #{variation_classes}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Common function for building variant
|
||||
|
||||
## Examples
|
||||
|
||||
```elixir
|
||||
config =
|
||||
%{
|
||||
variants: %{
|
||||
variant: %{
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: %{
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
default_variants: %{
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
|
||||
class_input = %{variant: "outline", size: "lg"}
|
||||
variant_class(config, class_input)
|
||||
```
|
||||
|
||||
"""
|
||||
def variant_class(config, class_input) do
|
||||
variants = Map.get(config, :variants, %{})
|
||||
default_variants = Map.get(config, :default_variants, %{})
|
||||
|
||||
variants
|
||||
|> Map.keys()
|
||||
|> Enum.map(fn variant_key ->
|
||||
# Get the variant value from input or use default
|
||||
variant_value =
|
||||
Map.get(class_input, variant_key) ||
|
||||
Map.get(default_variants, variant_key)
|
||||
|
||||
# Get the variant options map
|
||||
variant_options = Map.get(variants, variant_key, %{})
|
||||
|
||||
# Get the CSS classes for this variant value
|
||||
Map.get(variant_options, String.to_existing_atom(variant_value))
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function build css style string from map of css style
|
||||
|
||||
## Examples
|
||||
|
||||
```elixir
|
||||
css_style = %{
|
||||
"background-color": "red",
|
||||
"color": "white",
|
||||
"font-size": "16px",
|
||||
}
|
||||
|
||||
style(css_style)
|
||||
|
||||
# => "background-color: red; color: white; font-size: 16px;"
|
||||
```
|
||||
"""
|
||||
def style(items) when is_list(items) do
|
||||
{acc_map, acc_list} =
|
||||
Enum.reduce(items, {%{}, []}, fn item, {acc_map, acc_list} ->
|
||||
cond do
|
||||
is_map(item) ->
|
||||
{Map.merge(acc_map, item), acc_list}
|
||||
|
||||
is_binary(item) ->
|
||||
{acc_map, [item | acc_list]}
|
||||
|
||||
true ->
|
||||
{acc_map, [item | acc_list]}
|
||||
end
|
||||
end)
|
||||
|
||||
style = Enum.map_join(acc_map, "; ", fn {k, v} -> "#{k}: #{v}" end) <> ";"
|
||||
Enum.join([style | acc_list], "; ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function build js script to invoke JS stored in given attribute.
|
||||
Similar to JS.exec/2 but this function target the nearest ancestor element.
|
||||
|
||||
## Examples
|
||||
|
||||
```heex
|
||||
<button click={exec_closest("phx-hide-sheet", ".ancestor_class")}>
|
||||
Close
|
||||
</button>
|
||||
```
|
||||
"""
|
||||
def exec_closest(attribute, ancestor_selector) do
|
||||
"""
|
||||
var el = this.closest("#{ancestor_selector}"); liveSocket.execJS(el, el.getAttribute("#{attribute}"));
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
This component is used to render dynamic tag based on the `tag` attribute. `tag` attribute can be a string or a function component.
|
||||
|
||||
This is just a wrapper around `dynamic_tag` function from Phoenix LiveView which only support string tag.
|
||||
|
||||
## Examples
|
||||
|
||||
```heex
|
||||
<.dynamic tag={@tag} class="bg-primary text-primary-foreground">
|
||||
Hello World
|
||||
</.dynamic>
|
||||
```
|
||||
"""
|
||||
def dynamic(%{tag: name} = assigns) when is_function(name, 1) do
|
||||
assigns = Map.delete(assigns, :tag)
|
||||
name.(assigns)
|
||||
end
|
||||
|
||||
def dynamic(assigns) do
|
||||
name = assigns[:tag] || "div"
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> Map.delete(:tag)
|
||||
|> assign(:tag_name, name)
|
||||
|> assign(:name, name)
|
||||
|
||||
dynamic_tag(assigns)
|
||||
end
|
||||
|
||||
@doc """
|
||||
This component mimic behavior of `asChild` attribute from shadcn/ui.
|
||||
It works by passing all attribute from `as_child` tag to `tag` function component, add pass `child` attribute to the `as` attribute of the `tag` function component.
|
||||
|
||||
The `tag` function component should accept `as_tag` attribute to render the child component.
|
||||
|
||||
## Examples
|
||||
|
||||
```heex
|
||||
<.as_child tag={&dropdown_menu_trigger/1} child={&sidebar_menu_button/1} class="bg-primary text-primary-foreground">
|
||||
Hello World
|
||||
</.as_child>
|
||||
```
|
||||
|
||||
Normally this can be archieved by using `dropdown_menu_trigger` component directly but this will fire copile warning.
|
||||
|
||||
```heex
|
||||
<.dropdown_menu_trigger as={&sidebar_menu_button/1} class="bg-primary text-primary-foreground">
|
||||
Hello World
|
||||
</.dropdown_menu_trigger>
|
||||
"""
|
||||
def as_child(%{tag: tag, child: child_tag} = assigns) when is_function(tag, 1) do
|
||||
assigns
|
||||
|> Map.drop([:tag, :child])
|
||||
|> assign(:as, child_tag)
|
||||
|> tag.()
|
||||
end
|
||||
|
||||
# Translate error message
|
||||
# borrowed from https://github.com/petalframework/petal_components/blob/main/lib/petal_components/field.ex#L414
|
||||
defp translate_error({msg, opts}) do
|
||||
config_translator = get_translator_from_config() || (&fallback_translate_error/1)
|
||||
|
||||
config_translator.({msg, opts})
|
||||
end
|
||||
|
||||
defp fallback_translate_error({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
try do
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
rescue
|
||||
e ->
|
||||
IO.warn(
|
||||
"""
|
||||
the fallback message translator for the form_field_error function cannot handle the given value.
|
||||
|
||||
Hint: you can set up the `error_translator_function` to route all errors to your application helpers:
|
||||
|
||||
config :salad_ui, :error_translator_function, {MyAppWeb.CoreComponents, :translate_error}
|
||||
|
||||
Given value: #{inspect(value)}
|
||||
|
||||
Exception: #{Exception.message(e)}
|
||||
""",
|
||||
__STACKTRACE__
|
||||
)
|
||||
|
||||
"invalid value"
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_translator_from_config do
|
||||
case Application.get_env(:salad_ui, :error_translator_function) do
|
||||
{module, function} -> &apply(module, function, [&1])
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
defmodule RiverConnectWeb.Components.UI.HoverCard do
|
||||
@moduledoc """
|
||||
Implementation of hover card component with positioning and hover interactions.
|
||||
|
||||
When a user hovers over the trigger element, the content card appears with a small delay.
|
||||
The card disappears when the user moves away from both the trigger and the content.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.hover_card id="user-hover-card">
|
||||
<.hover_card_trigger>
|
||||
<.button variant="link">@salad_ui</.button>
|
||||
</.hover_card_trigger>
|
||||
<.hover_card_content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/images/avatar.png" alt="" class="h-10 w-10 rounded-full" />
|
||||
<div>
|
||||
<div class="font-semibold">SaladUI</div>
|
||||
<div class="text-sm text-muted-foreground">@salad_ui</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
UI component library for modern web applications.
|
||||
</p>
|
||||
</div>
|
||||
</.hover_card_content>
|
||||
</.hover_card>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main hover card component that manages state and positioning.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the hover card.
|
||||
* `:open-delay` - Delay in milliseconds before opening the hover card. Defaults to `300`.
|
||||
* `:close-delay` - Delay in milliseconds before closing the hover card. Defaults to `200`.
|
||||
* `:on-open` - Handler for hover card open event.
|
||||
* `:on-close` - Handler for hover card close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the hover card"
|
||||
|
||||
attr :"open-delay", :integer,
|
||||
default: 300,
|
||||
doc: "Delay in milliseconds before opening the hover card"
|
||||
|
||||
attr :"close-delay", :integer,
|
||||
default: 200,
|
||||
doc: "Delay in milliseconds before closing the hover card"
|
||||
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for hover card open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for hover card close event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def hover_card(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, Jason.encode!(event_map))
|
||||
|> assign(
|
||||
:options,
|
||||
Jason.encode!(%{
|
||||
openDelay: assigns[:"open-delay"],
|
||||
closeDelay: assigns[:"close-delay"],
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["inline-block relative", @class])}
|
||||
data-component="hover-card"
|
||||
data-state="closed"
|
||||
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 activates the hover card when hovered.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def hover_card_trigger(assigns) do
|
||||
~H"""
|
||||
<div data-part="trigger" class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The hover card content that appears when triggered.
|
||||
|
||||
## Options
|
||||
|
||||
* `:side` - Placement of the hover card relative to the trigger (top, right, bottom, left). Defaults to `"top"`.
|
||||
* `:align` - Alignment of the hover card (start, center, end). Defaults to `"center"`.
|
||||
* `:side-offset` - Distance from the trigger in pixels. Defaults to `4`.
|
||||
* `:align-offset` - Offset along the alignment axis. Defaults to `0`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :side, :string, values: ~w(top right bottom left), default: "top"
|
||||
attr :align, :string, values: ~w(start center end), default: "center"
|
||||
attr :"side-offset", :integer, default: 8, doc: "Distance from the trigger in pixels"
|
||||
attr :"align-offset", :integer, default: 0, doc: "Offset along the alignment axis"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def hover_card_content(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
side_offset: assigns[:"side-offset"],
|
||||
align_offset: assigns[:"align-offset"]
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="content"
|
||||
data-side={@side}
|
||||
data-align={@align}
|
||||
data-side-offset={@side_offset}
|
||||
data-align-offset={@align_offset}
|
||||
style="postion: fixed;"
|
||||
class="z-50"
|
||||
hidden
|
||||
{@rest}
|
||||
>
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
data-part="content-wrapper"
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Icon do
|
||||
@moduledoc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from your `assets/vendor/heroicons` directory and bundled
|
||||
within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
|
||||
|
||||
## Examples
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
"""
|
||||
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]}></span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Input do
|
||||
@moduledoc """
|
||||
Implementation of an input component for various form input types.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.input type="text" placeholder="Enter your name" />
|
||||
<.input type="email" placeholder="Enter your email" />
|
||||
<.input type="password" placeholder="Enter your password" />
|
||||
<.input field={f[:email]} type="email" placeholder="Enter your email" />
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a form input field or a component that looks like an input field.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - The id to use for the input
|
||||
* `:name` - The name to use for the input
|
||||
* `:value` - The value to pre-populate the input with
|
||||
* `:type` - The type of input (text, email, password, etc.)
|
||||
* `:default-value` - The default value of the input
|
||||
* `:field` - A form field struct to build the input from
|
||||
* `:class` - Additional CSS classes to apply
|
||||
* `:placeholder` - Placeholder text (passed through as rest)
|
||||
* `:disabled` - Whether the input is disabled (passed through as rest)
|
||||
* `:required` - Whether the input is required (passed through as rest)
|
||||
* `:readonly` - Whether the input is readonly (passed through as rest)
|
||||
* `:autocomplete` - Hints for form autofill feature (passed through as rest)
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(date datetime-local email file hidden month number password tel text time url week)
|
||||
|
||||
attr :"default-value", :any
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :class, :any, default: nil
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
rest =
|
||||
Map.merge(assigns.rest, Map.take(assigns, [:id, :name, :value, :type]))
|
||||
|
||||
assigns = assign(assigns, :rest, rest)
|
||||
|
||||
~H"""
|
||||
<input
|
||||
class={
|
||||
classes([
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Label do
|
||||
@moduledoc false
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
|
||||
## Examples
|
||||
|
||||
<.label>Send!</.label>
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value for)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label
|
||||
class={
|
||||
classes([
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
defmodule RiverConnectWeb.Components.UI.LiveView do
|
||||
@moduledoc """
|
||||
Helper functions for integrating SaladUI with Phoenix LiveView.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Send a command to a SaladUI component.
|
||||
This is useful for programmatically controlling components from server-side code.
|
||||
## Parameters
|
||||
- `socket`: The LiveView socket.
|
||||
- `component_id`: The ID of the component to send the command to.
|
||||
- `command`: The name of the command to send. Command could be any transitions that component state machine support or custom commands that supported by component.
|
||||
- `params`: Optional parameters for the command.
|
||||
|
||||
## Example
|
||||
```elixir
|
||||
send_command(socket, "dialog", "open")
|
||||
```
|
||||
"""
|
||||
def send_command(socket, component_id, command, params \\ %{}) do
|
||||
Phoenix.LiveView.push_event(socket, "saladui:command", %{
|
||||
command: command,
|
||||
params: params,
|
||||
target: component_id
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
defmodule RiverConnectWeb.Components.UI.JS do
|
||||
@moduledoc """
|
||||
Helper functions for integrating SaladUI with Phoenix LiveView using JavaScript commands.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Dispatch a command to a SaladUI component using JavaScript.
|
||||
This is useful for programmatically controlling components from client-side code.
|
||||
|
||||
## Parameters
|
||||
- `js`: The JavaScript object.
|
||||
- `command_name`: The name of the command to dispatch.
|
||||
- `opts`: Options for the command, including `:detail` for additional parameters.
|
||||
|
||||
## How it works
|
||||
This function use JS.dispatch to send a command to the component similar to `send_command/4`.
|
||||
|
||||
Each component listens for the `salad_ui:command` event and executes the corresponding command similar to the way it handle `send_command/4` from server side .
|
||||
|
||||
## Example
|
||||
```elixir
|
||||
<button
|
||||
phx-click={%JS{} |> RiverConnectWeb.Components.UI.JS.dispatch_command("open", to: "#dialog")}> Click me </button>
|
||||
```
|
||||
"""
|
||||
def dispatch_command(js \\ %Phoenix.LiveView.JS{}, command_name, opts \\ []) do
|
||||
details = %{
|
||||
command: command_name,
|
||||
params: opts[:detail]
|
||||
}
|
||||
|
||||
Phoenix.LiveView.JS.dispatch(
|
||||
js,
|
||||
"salad_ui:command",
|
||||
opts |> Keyword.put(:detail, details) |> IO.inspect(label: "dispatch_command")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Phoenix.LiveView.JS do
|
||||
def encode(value, opts) do
|
||||
Jason.Encode.list(value.ops, opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Menu do
|
||||
@moduledoc """
|
||||
Implement menu components
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Render menu
|
||||
|
||||
|
||||
## Examples:
|
||||
|
||||
|
||||
<.menu>
|
||||
<.menu_label>Account</.menu_label>
|
||||
<.menu_separator />
|
||||
|
||||
<.menu_group>
|
||||
<.menu_item>
|
||||
Profile
|
||||
<.menu_shortcut>⌘P</.menu_shortcut>
|
||||
</.menu_item>
|
||||
|
||||
<.menu_item>
|
||||
Billing
|
||||
<.menu_shortcut>⌘B</.menu_shortcut>
|
||||
</.menu_item>
|
||||
|
||||
<.menu_item>
|
||||
Settings
|
||||
<.menu_shortcut>⌘S</.menu_shortcut>
|
||||
</.menu_item>
|
||||
</.menu_group>
|
||||
</.menu>
|
||||
"""
|
||||
|
||||
attr :class, :string, default: "top-0 left-full"
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def menu(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={[
|
||||
"min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def menu_item(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"hover:bg-accent",
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{%{"data-disabled" => @disabled}}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :inset, :boolean, default: false
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def menu_label(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["px-2 py-1.5 text-sm font-semibold", @inset && "pl-8", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block
|
||||
|
||||
def menu_separator(assigns) do
|
||||
~H"""
|
||||
<div role="separator" class={classes(["-mx-1 my-1 h-px bg-muted", @class])}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def menu_shortcut(assigns) do
|
||||
~H"""
|
||||
<span class={classes(["ml-auto text-xs tracking-widest opacity-60", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def menu_group(assigns) do
|
||||
~H"""
|
||||
<div class={classes([@class])} role="group" {@rest}>{render_slot(@inner_block)}</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Pagination do
|
||||
@moduledoc """
|
||||
Implementation of pagination component for navigation between pages of content.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.pagination>
|
||||
<.pagination_content>
|
||||
<.pagination_item>
|
||||
<.pagination_previous href="#" />
|
||||
</.pagination_item>
|
||||
<.pagination_item>
|
||||
<.pagination_link href="#" is_active={@page == 1}>1</.pagination_link>
|
||||
</.pagination_item>
|
||||
<.pagination_item>
|
||||
<.pagination_link href="#" is_active={@page == 2}>2</.pagination_link>
|
||||
</.pagination_item>
|
||||
<.pagination_item>
|
||||
<.pagination_link href="#" is_active={@page == 3}>3</.pagination_link>
|
||||
</.pagination_item>
|
||||
<.pagination_item>
|
||||
<.pagination_ellipsis />
|
||||
</.pagination_item>
|
||||
<.pagination_item>
|
||||
<.pagination_next href="#" />
|
||||
</.pagination_item>
|
||||
</.pagination_content>
|
||||
</.pagination>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a pagination component.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def pagination(assigns) do
|
||||
~H"""
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
role="pagination"
|
||||
class={
|
||||
classes([
|
||||
"mx-auto flex w-full justify-center",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders pagination content wrapper.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def pagination_content(assigns) do
|
||||
~H"""
|
||||
<ul
|
||||
class={
|
||||
classes([
|
||||
"flex flex-row items-center gap-1",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a pagination item container.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def pagination_item(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
class={
|
||||
classes([
|
||||
"",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a pagination link.
|
||||
|
||||
## Options
|
||||
|
||||
* `:is_active` - Whether this link represents the current page
|
||||
* `:size` - Link size variant (default, sm, lg, icon)
|
||||
"""
|
||||
attr :"is-active", :boolean, default: false
|
||||
attr :size, :string, default: "icon", values: ~w(default sm lg icon)
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def pagination_link(assigns) do
|
||||
is_active = assigns[:"is-active"] in [true, "true"]
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(
|
||||
:variation_class,
|
||||
variant(%{size: assigns[:size], variant: (is_active && "outline") || "ghost"})
|
||||
)
|
||||
|> assign(:"is-active", is_active)
|
||||
|
||||
~H"""
|
||||
<.link
|
||||
aria-current={(assigns[:"is-active"] && "page") || ""}
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
|
||||
@variation_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a next page button.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def pagination_next(assigns) do
|
||||
~H"""
|
||||
<.pagination_link
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
class={classes(["gap-1 pr-2.5", @class])}
|
||||
{@rest}
|
||||
>
|
||||
<span>Next</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-6 w-3.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</.pagination_link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a previous page button.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def pagination_previous(assigns) do
|
||||
~H"""
|
||||
<.pagination_link
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
class={classes(["gap-1 pr-2.5", @class])}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-6 w-3.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
<span>Previous</span>
|
||||
</.pagination_link>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an ellipsis for page ranges.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def pagination_ellipsis(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
class={
|
||||
classes([
|
||||
"flex h-9 w-9 items-center justify-center",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-6 w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span class="sr-only">More pages</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"outline" =>
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"ghost" => "hover:bg-accent hover:text-accent-foreground"
|
||||
},
|
||||
size: %{
|
||||
"default" => "h-9 px-4 py-2",
|
||||
"sm" => "h-8 rounded-md px-3 text-xs",
|
||||
"lg" => "h-10 rounded-md px-8",
|
||||
"icon" => "h-9 w-9"
|
||||
}
|
||||
}
|
||||
|
||||
defp variant(props) do
|
||||
variants =
|
||||
Map.take(props, ~w(variant size)a)
|
||||
|
||||
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Popover do
|
||||
@moduledoc """
|
||||
Enhanced implementation of popover component from https://ui.shadcn.com/docs/components/popover
|
||||
|
||||
## Example:
|
||||
|
||||
<.popover id="profile-popover">
|
||||
<.popover_trigger>
|
||||
<.button variant="outline">Open Popover</.button>
|
||||
</.popover_trigger>
|
||||
<.popover_content side="bottom" align="center">
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium">Profile</h3>
|
||||
<p class="mt-2">View and edit your profile details</p>
|
||||
</div>
|
||||
</.popover_content>
|
||||
</.popover>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main popover component that manages state and positioning.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the popover.
|
||||
* `:open` - Whether the popover is initially open. Defaults to `false`.
|
||||
* `:animation` - Whether to animate the popover. Defaults to `true`.
|
||||
* `:on-open` - Handler for popover open event.
|
||||
* `:on-close` - Handler for popover close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the popover"
|
||||
attr :open, :boolean, default: false, doc: "Whether the popover is initially open"
|
||||
|
||||
attr :"portal-container", :string,
|
||||
default: nil,
|
||||
doc: "The portal container to render the popover in"
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for popover open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for popover close event"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def popover(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, Jason.encode!(event_map))
|
||||
|> assign(:initial_state, if(assigns.open, do: "open", else: "closed"))
|
||||
|> assign(
|
||||
:options,
|
||||
Jason.encode!(%{
|
||||
animations: get_animation_config(),
|
||||
portalContainer: assigns[:"portal-container"]
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative inline-block", @class])}
|
||||
data-component="popover"
|
||||
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 toggles the popover.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def popover_trigger(assigns) do
|
||||
~H"""
|
||||
<div data-part="trigger" data-action="toggle" class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The popover content that appears when triggered.
|
||||
|
||||
## Options
|
||||
|
||||
* `:side` - Placement of the popover relative to the trigger (top, right, bottom, left). Defaults to `"bottom"`.
|
||||
* `:align` - Alignment of the popover (start, center, end). Defaults to `"center"`.
|
||||
* `:side-offset` - Distance from the trigger in pixels. Defaults to `8`.
|
||||
* `:align-offset` - Offset along the alignment axis. Defaults to `0`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :side, :string, values: ~w(top right bottom left), default: "bottom"
|
||||
attr :align, :string, values: ~w(start center end), default: "center"
|
||||
attr :"side-offset", :integer, default: 8, doc: "Distance from the trigger in pixels"
|
||||
attr :"align-offset", :integer, default: 0, doc: "Offset along the alignment axis"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def popover_content(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
side_offset: assigns[:"side-offset"],
|
||||
align_offset: assigns[:"align-offset"]
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="positioner"
|
||||
data-side={@side}
|
||||
data-align={@align}
|
||||
data-side-offset={@side_offset}
|
||||
data-align-offset={@align_offset}
|
||||
class="absolute z-50"
|
||||
hidden
|
||||
>
|
||||
<div
|
||||
data-part="content"
|
||||
data-side={@side}
|
||||
data-align={@align}
|
||||
class={
|
||||
classes([
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Progress do
|
||||
@moduledoc """
|
||||
Implementation of Progress component for displaying completion percentages or loading states.
|
||||
|
||||
Progress bars visually represent the completion status of an operation, providing
|
||||
immediate feedback about how far along a task or process is.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.progress value={50} />
|
||||
<.progress value={75} class="w-[60%]" />
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a progress bar.
|
||||
|
||||
## Options
|
||||
|
||||
* `:value` - The current progress value (0-100)
|
||||
* `:class` - Additional CSS classes
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :value, :integer, default: 0, doc: "Current progress value (0-100)"
|
||||
attr :max, :integer, default: 100, doc: "Maximum value"
|
||||
attr :indeterminate, :boolean, default: false, doc: "Whether the progress is indeterminate"
|
||||
attr :rest, :global
|
||||
|
||||
def progress(assigns) do
|
||||
# Normalize and clamp value between 0 and 100
|
||||
value = normalize_integer(assigns[:value] || 0)
|
||||
value = min(max(value, 0), assigns[:max] || 100)
|
||||
|
||||
assigns = assign(assigns, :value, value)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={@max || 100}
|
||||
aria-valuenow={if @indeterminate, do: nil, else: @value}
|
||||
class={classes(["relative h-4 w-full overflow-hidden rounded-full bg-secondary", @class])}
|
||||
data-indeterminate={to_string(@indeterminate)}
|
||||
{@rest}
|
||||
>
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
@indeterminate && "animate-indeterminate-progress"
|
||||
])
|
||||
}
|
||||
style={if @indeterminate, do: nil, else: "transform: translateX(-#{100 - @value}%)"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
defmodule RiverConnectWeb.Components.UI.RadioGroup do
|
||||
@moduledoc """
|
||||
Implementation of radio group component from https://ui.shadcn.com/docs/components/radio-group
|
||||
|
||||
## Examples:
|
||||
|
||||
<.radio_group name="subscription" default="monthly" on_value_changed={JS.push("plan_changed")}>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<.radio_group_item value="monthly" id="monthly"/>
|
||||
<.label for="monthly">Monthly</label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<.radio_group_item value="yearly" id="yearly"/>
|
||||
<.label for="yearly">Yearly</label>
|
||||
</div>
|
||||
</div>
|
||||
</.radio_group>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Radio group component that allows selection of one option from a set.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :name, :string, default: nil
|
||||
attr :value, :any, default: nil, doc: "The current value of the radio group"
|
||||
attr :"default-value", :any, default: nil, doc: "The default value of the radio group"
|
||||
attr :"on-value-changed", :any, default: nil, doc: "Handler for value changed event"
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def radio_group(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
assigns = assign_new(assigns, :id, fn -> "radio-group-#{System.unique_integer()}" end)
|
||||
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "value-changed", :"on-value-changed")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, Jason.encode!(event_map))
|
||||
|> assign(
|
||||
:options,
|
||||
Jason.encode!(%{
|
||||
initialValue: assigns.value
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["grid gap-2", @class])}
|
||||
data-component="radio-group"
|
||||
data-state="idle"
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
data-part="root"
|
||||
phx-hook="SaladUI"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Individual radio button in a radio group.
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :value, :string, required: true
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def radio_group_item(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="item"
|
||||
data-value={@value}
|
||||
data-state="unchecked"
|
||||
data-disabled={to_string(@disabled)}
|
||||
class={
|
||||
classes([
|
||||
"group/item",
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 inline-grid",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex="-1"
|
||||
{@rest}
|
||||
>
|
||||
<input type="radio" id={@id} value={@value} disabled={@disabled} class="sr-only" tabindex="-1" />
|
||||
<span class="hidden group-data-[state=checked]/item:flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-circle h-2.5 w-2.5 fill-current text-current"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
defmodule RiverConnectWeb.Components.UI.ScrollArea do
|
||||
@moduledoc """
|
||||
Implementation of a scroll area component with custom styling.
|
||||
|
||||
The scroll area provides a consistent scrolling experience across different browsers
|
||||
and platforms, with customizable styling for the scrollbar.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.scroll_area class="h-[200px]">
|
||||
<div class="p-4">
|
||||
<h4 class="mb-4 text-sm font-medium leading-none">Tags</h4>
|
||||
<%= for tag <- 1..50 do %>
|
||||
<div class="text-sm">
|
||||
v1.2.0-beta.<%= tag %>
|
||||
</div>
|
||||
<.separator class="my-2" />
|
||||
<% end %>
|
||||
</div>
|
||||
</.scroll_area>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a custom scrollable area.
|
||||
|
||||
## Options
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the scroll area container
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block
|
||||
|
||||
def scroll_area(assigns) do
|
||||
~H"""
|
||||
<div class={classes(["relative overflow-hidden", @class])} {@rest}>
|
||||
<div class="salad-scroll-area rounded-[inherit] h-full w-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="-mr-3" style="min-width: 100%;">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,280 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Select do
|
||||
@moduledoc """
|
||||
Implement of select components from https://ui.shadcn.com/docs/components/select
|
||||
|
||||
## Examples:
|
||||
|
||||
<form>
|
||||
<.select default="banana" id="fruit-select">
|
||||
<.select_trigger class="w-[180px]">
|
||||
<.select_value placeholder=".select a fruit"/>
|
||||
</.select_trigger>
|
||||
<.select_content>
|
||||
<.select_group>
|
||||
<.select_label>Fruits</.select_label>
|
||||
<.select_item value="apple">Apple</.select_item>
|
||||
<.select_item value="banana">Banana</.select_item>
|
||||
<.select_item value="blueberry">Blueberry</.select_item>
|
||||
<.select_separator />
|
||||
<.select_item disabled value="grapes">Grapes</.select_item>
|
||||
<.select_item value="pineapple">Pineapple</.select_item>
|
||||
</.select_group>
|
||||
</.select_content>
|
||||
</.select>
|
||||
|
||||
<.button type="submit">Submit</.button>
|
||||
</form>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Ready to use select component with all required parts.
|
||||
"""
|
||||
|
||||
attr :id, :string, default: nil
|
||||
attr :name, :any, default: nil
|
||||
attr :value, :any, default: nil, doc: "The value of the select"
|
||||
attr :"default-value", :any, default: nil, doc: "The default value of the select"
|
||||
attr :multiple, :boolean, default: false, doc: "Allow multiple selection"
|
||||
attr :"use-portal", :boolean, default: false, doc: "Whether to render the content in a portal"
|
||||
attr :"portal-container", :string, default: nil, doc: "CSS selector for the portal container"
|
||||
attr :"on-value-changed", :any, default: nil, doc: "Handler for value changed event"
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for select open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for select closed event"
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :label, :string,
|
||||
default: nil,
|
||||
doc: "The display label of the select value. If not provided, the value will be used."
|
||||
|
||||
attr :placeholder, :string, default: nil, doc: "The placeholder text when no value is selected."
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def select(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
%{}
|
||||
|> add_event_mapping(assigns, "value-changed", :"on-value-changed")
|
||||
|> add_event_mapping(assigns, "opened", :"on-open")
|
||||
|> add_event_mapping(assigns, "closed", :"on-close")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, json(event_map))
|
||||
|> assign(
|
||||
:options,
|
||||
json(%{
|
||||
defaultValue: assigns[:"default-value"],
|
||||
value: assigns.value,
|
||||
name: assigns.name,
|
||||
multiple: assigns.multiple,
|
||||
usePortal: assigns[:"use-portal"],
|
||||
portalContainer: assigns[:"portal-container"],
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative inline-flex", @class])}
|
||||
data-part="root"
|
||||
data-component="select"
|
||||
data-state="closed"
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
phx-hook="SaladUI"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def select_trigger(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
data-part="trigger"
|
||||
class={
|
||||
classes([
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
<span class="h-4 w-4 opacity-50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-down h-4 w-4"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :placeholder, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def select_value(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
data-part="value"
|
||||
class={
|
||||
classes(["select-value pointer-events-none before:content-[attr(data-content)]", @class])
|
||||
}
|
||||
data-content={@placeholder}
|
||||
data-placeholder={@placeholder}
|
||||
{@rest}
|
||||
>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :side, :string, values: ~w(top bottom), default: "bottom"
|
||||
slot :inner_block, required: true
|
||||
|
||||
attr :rest, :global
|
||||
|
||||
def select_content(assigns) do
|
||||
position_class =
|
||||
case assigns.side do
|
||||
"top" -> "bottom-full mb-1"
|
||||
"bottom" -> "top-full mt-1"
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns, :position_class, position_class)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="content"
|
||||
data-side={@side}
|
||||
style="min-width: var(--salad-reference-width)"
|
||||
hidden
|
||||
class={
|
||||
classes([
|
||||
"absolute min-w-full",
|
||||
"z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@position_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
<div class="relative w-full p-1">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def select_group(assigns) do
|
||||
~H"""
|
||||
<div data-part="group" class={classes([@class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def select_label(assigns) do
|
||||
~H"""
|
||||
<div data-part="label" class={classes(["py-1.5 pl-8 pr-2 text-sm font-semibold", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :value, :string, required: true
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global
|
||||
|
||||
def select_item(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="item"
|
||||
data-value={@value}
|
||||
data-disabled={@disabled}
|
||||
class={
|
||||
classes([
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex={(@disabled && "-1") || "0"}
|
||||
{@rest}
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span data-part="item-indicator" hidden>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-part="item-text">{render_slot(@inner_block)}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def select_separator(assigns) do
|
||||
~H"""
|
||||
<div data-part="separator" class={classes(["-mx-1 my-1 h-px bg-muted"])}></div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Separator do
|
||||
@moduledoc """
|
||||
Implementation of a separator component for dividing content.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.separator orientation="horizontal" />
|
||||
<.separator orientation="vertical" class="h-6" />
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a separator.
|
||||
|
||||
## Options
|
||||
|
||||
* `:orientation` - The orientation of the separator (`horizontal` or `vertical`). Defaults to `horizontal`.
|
||||
* `:class` - Additional CSS classes
|
||||
"""
|
||||
attr :orientation, :string, values: ~w(vertical horizontal), default: "horizontal"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
|
||||
def separator(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={@orientation}
|
||||
class={
|
||||
classes([
|
||||
"shrink-0 bg-border",
|
||||
(@orientation == "horizontal" && "h-[1px] w-full") || "h-full w-[1px]",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,282 @@
|
||||
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
|
||||
@@ -0,0 +1,769 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Sidebar do
|
||||
@moduledoc false
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
import RiverConnectWeb.Components.UI.Input
|
||||
import RiverConnectWeb.Components.UI.Separator
|
||||
import RiverConnectWeb.Components.UI.Sheet
|
||||
import RiverConnectWeb.Components.UI.Skeleton
|
||||
import RiverConnectWeb.Components.UI.Tooltip
|
||||
|
||||
@sidebar_width "16rem"
|
||||
@sidebar_width_mobile "18rem"
|
||||
@sidebar_width_icon "3rem"
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr :style, :map, default: %{}
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_provider(assigns) do
|
||||
assigns =
|
||||
assign(assigns, %{sidebar_width: @sidebar_width, sidebar_width_icon: @sidebar_width_icon})
|
||||
|
||||
~H"""
|
||||
<div
|
||||
style={
|
||||
style([
|
||||
%{
|
||||
"--sidebar-width": @sidebar_width,
|
||||
"--sidebar-width-icon": @sidebar_width_icon
|
||||
},
|
||||
@style
|
||||
])
|
||||
}
|
||||
class={
|
||||
classes([
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
|
||||
attr :id, :string,
|
||||
required: true,
|
||||
doc: "The id of the sidebar, used for the trigger to identify the target sidebar"
|
||||
|
||||
attr :side, :string, values: ~w(left right), default: "left"
|
||||
attr :variant, :string, values: ~w(sidebar floating inset), default: "sidebar"
|
||||
attr :collapsible, :string, values: ~w(offcanvas icon none), default: "offcanvas"
|
||||
attr :is_mobile, :boolean, default: false
|
||||
attr :state, :string, values: ~w(expanded collapsed), default: "expanded"
|
||||
attr(:class, :string, default: nil)
|
||||
attr :style, :map, default: %{}
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar(%{collapsible: "none"} = assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
data-variant={@variant}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def sidebar(%{is_mobile: true} = assigns) do
|
||||
assigns = assign(assigns, :sidebar_width_mobile, @sidebar_width_mobile)
|
||||
|
||||
~H"""
|
||||
<.sheet id={@id}>
|
||||
<.sheet_content
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
data-variant={@variant}
|
||||
style={
|
||||
style([
|
||||
%{
|
||||
"--sidebar-width": @sidebar_width_mobile
|
||||
},
|
||||
@style
|
||||
])
|
||||
}
|
||||
side={@side}
|
||||
>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</.sheet_content>
|
||||
</.sheet>
|
||||
"""
|
||||
end
|
||||
|
||||
def sidebar(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="group peer hidden md:block text-sidebar-foreground sidebar-root"
|
||||
data-state={@state}
|
||||
data-collapsible={(@state == "collapsed" && @collapsible) || "none"}
|
||||
data-variant={@variant}
|
||||
data-side={@side}
|
||||
id={@id}
|
||||
phx-toggle-sidebar={toggle_sidebar({"none", @collapsible})}
|
||||
>
|
||||
<div class={
|
||||
classes([
|
||||
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
((@variant == "floating" || @variant == "inset") &&
|
||||
"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]") ||
|
||||
"group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
])
|
||||
} />
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
||||
(@side == "left" &&
|
||||
"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]") ||
|
||||
"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
((@variant == "floating" || @variant == "inset") &&
|
||||
"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]") ||
|
||||
"group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr :target, :string, required: true, doc: "The id of the target sidebar"
|
||||
attr :as, :any, default: "button"
|
||||
attr(:rest, :global)
|
||||
|
||||
def sidebar_trigger(assigns) do
|
||||
~H"""
|
||||
<.dynamic
|
||||
tag={@as}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={classes(["h-7 w-7", @class])}
|
||||
phx-click={JS.exec("phx-toggle-sidebar", to: "#" <> @target)}
|
||||
{@rest}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
||||
<path d="M9 3v18"></path>
|
||||
</svg>
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
|
||||
def sidebar_rail(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tab-index={-1}
|
||||
onclick={exec_closest("phx-toggle-sidebar", ".sidebar-root")}
|
||||
title="Toggle Sidebar"
|
||||
class={
|
||||
classes([
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def sidebar_inset(assigns) do
|
||||
~H"""
|
||||
<main
|
||||
class={
|
||||
classes([
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</main>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
|
||||
def sidebar_input(assigns) do
|
||||
~H"""
|
||||
<.input
|
||||
data-sidebar="input"
|
||||
class={
|
||||
classes([
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_header(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="header"
|
||||
class={
|
||||
classes([
|
||||
"flex flex-col gap-2 p-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def sidebar_footer(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="footer"
|
||||
class={
|
||||
classes([
|
||||
"flex flex-col gap-2 p-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
|
||||
def sidebar_separator(assigns) do
|
||||
~H"""
|
||||
<.separator
|
||||
data-sidebar="separator"
|
||||
class={classes(["mx-2 w-auto bg-sidebar-border", @class])}
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="content"
|
||||
class={
|
||||
classes([
|
||||
"salad-scroll-area",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_group(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="group"
|
||||
class={
|
||||
classes([
|
||||
"relative flex w-full min-w-0 flex-col p-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
TODO: class merge not work well here
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr :as, :any, default: "div"
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_group_label(assigns) do
|
||||
~H"""
|
||||
<.dynamic
|
||||
data-sidebar="group-label"
|
||||
tag={@as}
|
||||
class={
|
||||
Enum.join(
|
||||
[
|
||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 text-xs",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
@class
|
||||
],
|
||||
" "
|
||||
)
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_group_action(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
data-sidebar="group-action"
|
||||
class={
|
||||
classes([
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_group_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="group-content"
|
||||
class={
|
||||
classes([
|
||||
"w-full text-sm",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu(assigns) do
|
||||
~H"""
|
||||
<ul
|
||||
data-sidebar="menu"
|
||||
class={
|
||||
classes([
|
||||
"flex w-full min-w-0 flex-col gap-1",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_item(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
data-sidebar="menu-item"
|
||||
class={
|
||||
classes([
|
||||
"group/menu-item relative",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr :variant, :string, values: ~w(default outline), default: "default"
|
||||
attr :size, :string, values: ~w(default sm lg), default: "default"
|
||||
attr :is_active, :boolean, default: false
|
||||
attr(:class, :string, default: nil)
|
||||
attr :is_mobile, :boolean, default: false
|
||||
attr :state, :string, default: "expanded"
|
||||
attr :as, :any, default: "button"
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
attr :tooltip, :string, required: false
|
||||
|
||||
def sidebar_menu_button(assigns) do
|
||||
button = ~H"""
|
||||
<.dynamic
|
||||
tag={@as}
|
||||
data-sidebar="menu-button"
|
||||
data-size={@size}
|
||||
data-active={"#{@is_active}"}
|
||||
class={classes([sidebar_button_variant(%{variant: @variant, size: @size}), @class])}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
|
||||
assigns = assign(assigns, :button, button)
|
||||
|
||||
if assigns[:tooltip] do
|
||||
~H"""
|
||||
<.tooltip class="block">
|
||||
<.tooltip_trigger>
|
||||
{@button}
|
||||
</.tooltip_trigger>
|
||||
<.tooltip_content side="right" hidden={@state != "collapsed" || @is_mobile}>
|
||||
{@tooltip}
|
||||
</.tooltip_content>
|
||||
</.tooltip>
|
||||
"""
|
||||
else
|
||||
button
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr :show_on_hover, :boolean, default: false
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_action(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
data-sidebar="menu-action"
|
||||
class={
|
||||
classes([
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@show_on_hover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_badge(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="menu-badge"
|
||||
class={
|
||||
classes([
|
||||
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr :show_icon, :boolean, default: false
|
||||
attr(:rest, :global)
|
||||
|
||||
def sidebar_menu_skeleton(assigns) do
|
||||
width = :rand.uniform(40) + 50
|
||||
assigns = assign(assigns, :width, width)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-sidebar="menu-skeleton"
|
||||
class={classes(["rounded-md h-8 flex gap-2 px-2 items-center", @class])}
|
||||
{@rest}
|
||||
>
|
||||
<.skeleton :if={@show_icon} class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
|
||||
<.skeleton
|
||||
class="h-4 flex-1 max-w-[--skeleton-width]"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
style([
|
||||
%{
|
||||
"--skeleton-width": @width
|
||||
}
|
||||
])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_sub(assigns) do
|
||||
~H"""
|
||||
<ul
|
||||
data-sidebar="menu-sub"
|
||||
class={
|
||||
classes([
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_sub_item(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
class={
|
||||
classes([
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render
|
||||
"""
|
||||
attr :size, :string, values: ~w(sm md), default: "md"
|
||||
attr :is_active, :boolean, default: false
|
||||
attr(:class, :string, default: nil)
|
||||
attr :as, :any, default: "a"
|
||||
attr(:rest, :global)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def sidebar_menu_sub_button(assigns) do
|
||||
~H"""
|
||||
<.dynamic
|
||||
tag={@as}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={@size}
|
||||
data-active={"#{@is_active}"}
|
||||
class={
|
||||
classes([
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
@size == "sm" && "text-xs",
|
||||
@size == "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</.dynamic>
|
||||
"""
|
||||
end
|
||||
|
||||
@variant_config %{
|
||||
variants: %{
|
||||
variant: %{
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]"
|
||||
},
|
||||
size: %{
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0"
|
||||
}
|
||||
},
|
||||
default_variants: %{
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
@shared_classes "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0"
|
||||
defp sidebar_button_variant(input) do
|
||||
@shared_classes <> " " <> variant_class(@variant_config, input)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Toggle sidebar between collapsed and expanded state.
|
||||
"""
|
||||
def toggle_sidebar({state1, state2} = _collapsible_states) do
|
||||
{"data-state", "collapsed", "expanded"}
|
||||
|> JS.toggle_attribute()
|
||||
|> JS.toggle_attribute({"data-collapsible", state1, state2})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Skeleton do
|
||||
@moduledoc """
|
||||
Skeleton loading component for showing placeholder content while data is loading.
|
||||
|
||||
Skeleton loaders provide visual feedback to users during data loading, improving perceived performance.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.skeleton class="h-4 w-full" />
|
||||
|
||||
# Card loading skeleton
|
||||
<div class="space-y-2">
|
||||
<.skeleton class="h-32 w-full rounded-lg" />
|
||||
<.skeleton class="h-4 w-2/3" />
|
||||
<.skeleton class="h-4 w-full" />
|
||||
<.skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
|
||||
# Avatar and text loading skeleton
|
||||
<div class="flex items-center gap-4">
|
||||
<.skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<.skeleton class="h-4 w-32" />
|
||||
<.skeleton class="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a skeleton loading placeholder.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes to apply to the skeleton element. Use this to control
|
||||
dimensions (width, height), border radius and other visual aspects.
|
||||
* `:rest` - Additional HTML attributes to apply to the skeleton element.
|
||||
|
||||
## Examples
|
||||
|
||||
<.skeleton class="h-6 w-24" />
|
||||
<.skeleton class="h-12 w-12 rounded-full" />
|
||||
<.skeleton class="h-4 w-full max-w-sm" />
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def skeleton(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class={
|
||||
classes([
|
||||
"animate-pulse rounded-md bg-muted",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Slider do
|
||||
@moduledoc """
|
||||
Implementation of slider component for selecting values within a range.
|
||||
|
||||
Sliders provide users with a visual representation of a value within a range,
|
||||
and allow them to adjust it by dragging a thumb or pressing arrow keys.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.slider id="volume-slider" min={0} max={100} value={50} on-value-changed={JS.push("volume_changed")} />
|
||||
|
||||
<.slider id="price-range" min={10} max={1000} step={10} value={500} class="w-[300px]" />
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a slider component.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Required unique identifier for the slider.
|
||||
* `:min` - Minimum value (defaults to 0).
|
||||
* `:max` - Maximum value (defaults to 100).
|
||||
* `:step` - Step size for value changes (defaults to 1).
|
||||
* `:value` - Current value of the slider (defaults to min).
|
||||
* `:default-value` - Default value if value is not provided.
|
||||
* `:disabled` - Whether the slider is disabled (defaults to false).
|
||||
* `:on-value-changed` - Handler for value changed event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the slider"
|
||||
attr :name, :any, default: nil, doc: "Name of the slider for form submission"
|
||||
attr :min, :integer, default: 0, doc: "Minimum value"
|
||||
attr :max, :integer, default: 100, doc: "Maximum value"
|
||||
attr :step, :integer, default: 1, doc: "Step size for value changes"
|
||||
attr :value, :integer, default: nil, doc: "Current value of the slider"
|
||||
attr :"default-value", :integer, default: nil, doc: "Default value if value is not provided"
|
||||
attr :disabled, :boolean, default: false, doc: "Whether the slider is disabled"
|
||||
attr :"on-value-changed", :any, default: nil, doc: "Handler for value changed event"
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "A form field struct retrieved from the form, for example: @form[:volume]"
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def slider(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
# Set value from default-value or min if value is not provided
|
||||
value =
|
||||
cond do
|
||||
not is_nil(assigns.value) -> assigns.value
|
||||
not is_nil(assigns[:"default-value"]) -> assigns[:"default-value"]
|
||||
true -> assigns.min
|
||||
end
|
||||
|
||||
# Ensure value is within bounds and snapped to step
|
||||
value =
|
||||
value
|
||||
|> max(assigns.min)
|
||||
|> min(assigns.max)
|
||||
|> snap_to_step(assigns.step)
|
||||
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "value-changed", :"on-value-changed")
|
||||
|
||||
# Create options object
|
||||
options = %{
|
||||
min: assigns.min,
|
||||
max: assigns.max,
|
||||
step: assigns.step,
|
||||
defaultValue: assigns[:"default-value"],
|
||||
disabled: assigns.disabled
|
||||
}
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:value, value)
|
||||
|> assign(:event_map, json(event_map))
|
||||
|> assign(:options, json(options))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative", @class])}
|
||||
data-component="slider"
|
||||
data-state="idle"
|
||||
data-value={@value}
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
tabindex={if @disabled, do: "-1", else: "0"}
|
||||
phx-hook="SaladUI"
|
||||
data-part="root"
|
||||
phx-no-format
|
||||
{@rest}
|
||||
>
|
||||
<div
|
||||
class="relative flex w-full touch-none select-none items-center"
|
||||
data-part="root"
|
||||
>
|
||||
<div data-part="track" class="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<div data-part="range" class="absolute h-full bg-primary" />
|
||||
</div>
|
||||
<div
|
||||
disabled={@disabled}
|
||||
data-part="thumb"
|
||||
class="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 absolute"
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name={@name} value={@value} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Snap a value to the nearest step
|
||||
defp snap_to_step(value, step) do
|
||||
step_count = round(value / step)
|
||||
step * step_count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Switch do
|
||||
@moduledoc """
|
||||
Implementation of a switch/toggle component.
|
||||
|
||||
A switch is a control that allows users to toggle between checked and not checked states.
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a switch component.
|
||||
|
||||
## Props
|
||||
* `:id` - The id to be applied to the input element
|
||||
* `:name` - The name to be applied to the input element
|
||||
* `:class` - Additional CSS classes
|
||||
* `:value` - The current value of the switch
|
||||
* `:default-value` - The default value of the switch
|
||||
* `:field` - Phoenix form field
|
||||
* `:disabled` - Whether the switch is disabled
|
||||
* `:on-checked-changed` - Handler for value change event
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
attr :name, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :checked, :boolean, default: false
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :"on-checked-changed", :any, default: nil, doc: "Handler for value change event"
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:active]"
|
||||
|
||||
attr :rest, :global
|
||||
|
||||
def switch(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
# Normalize value for boolean
|
||||
assigns = assign(assigns, :checked, normalize_boolean(assigns.checked))
|
||||
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "checked-changed", :"on-checked-changed")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, json(event_map))
|
||||
|> assign(:initial_state, if(assigns.checked, do: "checked", else: "unchecked"))
|
||||
|> assign(
|
||||
:options,
|
||||
json(%{
|
||||
disabled: assigns.disabled
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
data-component="switch"
|
||||
data-state={@initial_state}
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
data-part="root"
|
||||
phx-hook="SaladUI"
|
||||
data-disabled={@disabled}
|
||||
class={
|
||||
classes([
|
||||
"switch-root inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
@class
|
||||
])
|
||||
}
|
||||
tabindex={if @disabled, do: "-1", else: "0"}
|
||||
{@rest}
|
||||
>
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={"#{@id}-input"}
|
||||
name={@name}
|
||||
value="true"
|
||||
class="sr-only"
|
||||
checked={@checked}
|
||||
disabled={@disabled}
|
||||
aria-checked={@checked}
|
||||
data-part="input"
|
||||
/>
|
||||
<span
|
||||
data-part="thumb"
|
||||
data-state={@initial_state}
|
||||
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,209 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Table do
|
||||
@moduledoc """
|
||||
Implementation of table components from https://ui.shadcn.com/docs/components/table
|
||||
with proper ARIA attributes for accessibility.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.table aria-label="Invoices">
|
||||
<.table_caption>A list of your recent invoices.</.table_caption>
|
||||
<.table_header>
|
||||
<.table_row>
|
||||
<.table_head class="w-[100px]">Invoice</.table_head>
|
||||
<.table_head>Status</.table_head>
|
||||
<.table_head>Method</.table_head>
|
||||
<.table_head class="text-right">Amount</.table_head>
|
||||
</.table_row>
|
||||
</.table_header>
|
||||
<.table_body>
|
||||
<.table_row :for={invoice <- @invoices}>
|
||||
<.table_cell class="font-medium"><%= invoice.number %></.table_cell>
|
||||
<.table_cell><%= invoice.status %></.table_cell>
|
||||
<.table_cell><%= invoice.method %></.table_cell>
|
||||
<.table_cell class="text-right"><%= invoice.amount %></.table_cell>
|
||||
</.table_row>
|
||||
</.table_body>
|
||||
</.table>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a data table.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes for the table
|
||||
* `:aria-label` - Accessible name for the table when no caption is present
|
||||
* `:aria-describedby` - ID of an element that describes the table
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :"aria-label", :string, default: nil
|
||||
attr :"aria-describedby", :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table(assigns) do
|
||||
~H"""
|
||||
<table
|
||||
class={classes(["w-full caption-bottom text-sm", @class])}
|
||||
aria-label={assigns[:"aria-label"]}
|
||||
aria-describedby={assigns[:"aria-describedby"]}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the table header container.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_header(assigns) do
|
||||
~H"""
|
||||
<thead class={classes(["[&_tr]:border-b", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</thead>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table row.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes
|
||||
* `:aria-rowindex` - Numeric index of the row
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :"aria-rowindex", :integer, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_row(assigns) do
|
||||
~H"""
|
||||
<tr
|
||||
class={
|
||||
classes([
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
@class
|
||||
])
|
||||
}
|
||||
aria-rowindex={assigns[:"aria-rowindex"]}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</tr>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table column header.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes
|
||||
* `:scope` - Scope of the header cell (default: "col")
|
||||
* `:aria-sort` - Sort direction for screen readers (ascending, descending, or none)
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :scope, :string, default: "col"
|
||||
attr :"aria-sort", :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_head(assigns) do
|
||||
~H"""
|
||||
<th
|
||||
class={
|
||||
classes([
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
@class
|
||||
])
|
||||
}
|
||||
scope={@scope}
|
||||
aria-sort={assigns[:"aria-sort"]}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</th>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the table body.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_body(assigns) do
|
||||
~H"""
|
||||
<tbody class={classes(["[&_tr:last-child]:border-0", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</tbody>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table data cell.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `:class` - Additional CSS classes
|
||||
"""
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_cell(assigns) do
|
||||
~H"""
|
||||
<td class={classes(["p-4 align-middle [&:has([role=checkbox])]:pr-0", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</td>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table footer.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_footer(assigns) do
|
||||
~H"""
|
||||
<tfoot
|
||||
class={
|
||||
classes([
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</tfoot>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table caption.
|
||||
|
||||
A caption provides an accessible name for the table.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def table_caption(assigns) do
|
||||
~H"""
|
||||
<caption class={classes(["mt-4 text-sm text-muted-foreground", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</caption>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Tabs do
|
||||
@moduledoc """
|
||||
Implementation of tabs components from https://ui.shadcn.com/docs/components/tabs
|
||||
|
||||
## Example:
|
||||
|
||||
<.tabs id="settings" default="account" on_tab_changed={JS.push("tab_changed")}>
|
||||
<.tabs_list>
|
||||
<.tabs_trigger value="account">Account</.tabs_trigger>
|
||||
<.tabs_trigger value="password">Password</.tabs_trigger>
|
||||
</.tabs_list>
|
||||
<.tabs_content value="account">
|
||||
<.card>
|
||||
<.card_content class="p-6">
|
||||
Account settings go here
|
||||
</.card_content>
|
||||
</.card>
|
||||
</.tabs_content>
|
||||
<.tabs_content value="password">
|
||||
<.card>
|
||||
<.card_content class="p-6">
|
||||
Password settings go here
|
||||
</.card_content>
|
||||
</.card>
|
||||
</.tabs_content>
|
||||
</.tabs>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Primary tabs component that serves as a container for tab triggers and content.
|
||||
"""
|
||||
attr :id, :string, required: true, doc: "Unique identifier for the tabs component"
|
||||
attr :default, :string, default: nil, doc: "Default selected tab value"
|
||||
attr :class, :string, default: nil
|
||||
attr :"on-tab-changed", :any, default: nil, doc: "Handler for tab change events"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tabs(assigns) do
|
||||
# Collect event mappings
|
||||
event_map =
|
||||
add_event_mapping(%{}, assigns, "tab-changed", :"on-tab-changed")
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:event_map, json(event_map))
|
||||
|> assign(
|
||||
:options,
|
||||
json(%{
|
||||
defaultValue: assigns.default
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["", @class])}
|
||||
data-component="tabs"
|
||||
data-state="idle"
|
||||
data-options={@options}
|
||||
data-event-mappings={@event_map}
|
||||
data-part="root"
|
||||
phx-hook="SaladUI"
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Container for tab triggers that provides proper styling and ARIA attributes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tabs_list(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="list"
|
||||
class={
|
||||
classes([
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Individual tab button that activates its corresponding content panel.
|
||||
"""
|
||||
attr :value, :string, required: true, doc: "Unique value that identifies this tab"
|
||||
attr :class, :string, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tabs_trigger(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
data-part="trigger"
|
||||
data-value={@value}
|
||||
data-state="inactive"
|
||||
data-disabled={to_string(@disabled)}
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
@class
|
||||
])
|
||||
}
|
||||
disabled={@disabled}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Content panel that corresponds to a tab trigger.
|
||||
"""
|
||||
attr :value, :string, required: true, doc: "Value that matches a tab trigger"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tabs_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-part="content"
|
||||
data-value={@value}
|
||||
data-state="inactive"
|
||||
hidden
|
||||
class={
|
||||
classes([
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Textarea do
|
||||
@moduledoc """
|
||||
Implementation of a textarea component for multi-line text input.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.textarea field={f[:description]} placeholder="Type your message here" />
|
||||
<.textarea rows="6" placeholder="Add your comments" value={@description} />
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Renders a form textarea.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - The id to use for the textarea
|
||||
* `:name` - The name to use for the textarea
|
||||
* `:value` - The value to pre-populate the textarea with
|
||||
* `:field` - A form field struct to build the textarea from
|
||||
* `:class` - Additional CSS classes to apply
|
||||
* `:rows` - Number of visible text lines (passed through as rest)
|
||||
* `:placeholder` - Placeholder text (passed through as rest)
|
||||
* `:disabled` - Whether the textarea is disabled (passed through as rest)
|
||||
* `:required` - Whether the textarea is required (passed through as rest)
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :class, :any, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form rows cols)
|
||||
|
||||
def textarea(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
~H"""
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={
|
||||
classes([
|
||||
"min-h-[80px] border-input bg-background ring-offset-background flex w-full rounded-md border px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@class
|
||||
])
|
||||
}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Toggle do
|
||||
@moduledoc false
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
Toggle component, A two-state button that can be either on or off.
|
||||
|
||||
## Example:
|
||||
|
||||
<.toggle value="true" size="sm" variant="outline">Bold</.toggle>
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any, default: nil
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :value, :boolean, default: false
|
||||
attr :"default-value", :any, values: [true, false, "true", "false"], default: false
|
||||
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :variant, :string, values: ~w(default outline), default: "default"
|
||||
attr :size, :string, values: ~w(default sm lg), default: "default"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def toggle(assigns) do
|
||||
assigns =
|
||||
prepare_assign(assigns)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns.value)
|
||||
end)
|
||||
|> assign(:variant_class, variant(assigns))
|
||||
|
||||
~H"""
|
||||
<button
|
||||
onclick="this.querySelector('.toggle-input').click()"
|
||||
disabled={@disabled}
|
||||
type="button"
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 has-[:checked]:bg-accent has-[:checked]:text-accent-foreground",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
>
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-input hidden"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
{@rest}
|
||||
/>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"default" => "bg-transparent",
|
||||
"outline" =>
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground"
|
||||
},
|
||||
size: %{
|
||||
"default" => "h-10 px-3",
|
||||
"sm" => "h-9 px-2.5",
|
||||
"lg" => "h-11 px-5"
|
||||
}
|
||||
}
|
||||
|
||||
@default_variants %{
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
|
||||
defp variant(props) do
|
||||
variants = Map.take(props, ~w(variant size)a)
|
||||
variants = Map.merge(@default_variants, variants)
|
||||
|
||||
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
defmodule RiverConnectWeb.Components.UI.ToggleGroup do
|
||||
@moduledoc false
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
A set of two-state buttons that can be toggled on or off.
|
||||
|
||||
|
||||
## Example:
|
||||
|
||||
<.toggle_group name="style" type="single" value="bold">
|
||||
<.toggle_group_item value="bold" builder={builder} aria-label="Toggle bold">
|
||||
<.icon name="hero-bold" class="h-4 w-4" />
|
||||
</.toggle_group_item>
|
||||
<.toggle_group_item value="italic" builder={builder} aria-label="Toggle italic">
|
||||
<.icon name="hero-italic" class="h-4 w-4" />
|
||||
</.toggle_group_item>
|
||||
<.toggle_group_item value="underline" builder={builder} aria-label="Toggle underline">
|
||||
<.icon name="hero-underline" class="h-4 w-4" />
|
||||
</.toggle_group_item>
|
||||
</.toggle_group>
|
||||
"""
|
||||
attr :name, :string, default: nil
|
||||
attr :multiple, :any, values: [true, false, "true", "false"], default: false
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :"default-value", :any, values: [true, false, "true", "false"]
|
||||
|
||||
attr :value, :string,
|
||||
default: nil,
|
||||
doc:
|
||||
"The value of the toggle group. It's a single value for single type and a list of values for multiple type."
|
||||
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :class, :string, default: nil
|
||||
attr :variant, :string, default: "default"
|
||||
attr :size, :string, default: "default"
|
||||
attr :rest, :global
|
||||
slot :inner_block
|
||||
|
||||
def toggle_group(assigns) do
|
||||
assigns = prepare_assign(assigns)
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns.multiple)
|
||||
end)
|
||||
|
||||
ensure_valid_value_type!(assigns)
|
||||
|
||||
~H"""
|
||||
<div class={classes(["flex items-center justify-center gap-1", @class])}>
|
||||
{render_slot(@inner_block, assigns)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp ensure_valid_value_type!(%{value: value, multiple: multiple} = _assigns) do
|
||||
cond do
|
||||
multiple and not is_list(value) ->
|
||||
raise ArgumentError, "The value of the toggle group must be a list for multiple type."
|
||||
|
||||
not multiple and not (is_nil(value) or is_binary(value)) ->
|
||||
raise ArgumentError,
|
||||
"The value of the toggle group must be a single value for single type."
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :value, :string, default: nil
|
||||
attr :builder, :map, required: true, doc: "The builder context of toggle group."
|
||||
attr :rest, :global
|
||||
slot :inner_block
|
||||
|
||||
def toggle_group_item(%{builder: %{multiple: true}} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:variant_class, variant(assigns.builder))
|
||||
|> assign(:checked, assigns.value in assigns.builder.value)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
onclick="this.querySelector('.toggle-input').click()"
|
||||
type="button"
|
||||
disabled={@disabled || @builder.disabled}
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 has-[:checked]:bg-accent has-[:checked]:text-accent-foreground",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-input hidden"
|
||||
name={@builder.name}
|
||||
value={@value}
|
||||
checked={@checked}
|
||||
{@rest}
|
||||
/>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
# single type
|
||||
def toggle_group_item(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:variant_class, variant(assigns.builder))
|
||||
|> assign(:checked, assigns.value == assigns.builder.value)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
onclick="this.querySelector('.toggle-input').click()"
|
||||
type="button"
|
||||
disabled={@disabled || @builder.disabled}
|
||||
class={
|
||||
classes([
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 has-[:checked]:bg-accent has-[:checked]:text-accent-foreground",
|
||||
@variant_class,
|
||||
@class
|
||||
])
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="toggle-input hidden"
|
||||
name={@builder.name}
|
||||
value={@value}
|
||||
checked={@checked}
|
||||
{@rest}
|
||||
/>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@variants %{
|
||||
variant: %{
|
||||
"default" => "bg-transparent",
|
||||
"outline" =>
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground"
|
||||
},
|
||||
size: %{
|
||||
"default" => "h-10 px-3",
|
||||
"sm" => "h-9 px-2.5",
|
||||
"lg" => "h-11 px-5"
|
||||
}
|
||||
}
|
||||
|
||||
@default_variants %{
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
|
||||
defp variant(props) do
|
||||
variants = Map.take(props, ~w(variant size)a)
|
||||
variants = Map.merge(@default_variants, variants)
|
||||
|
||||
Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
defmodule RiverConnectWeb.Components.UI.Tooltip do
|
||||
@moduledoc """
|
||||
Implementation of Tooltip component for SaladUI framework.
|
||||
|
||||
Tooltips provide additional information about an element when hovering over it.
|
||||
|
||||
## Examples:
|
||||
|
||||
<.tooltip id="help-tooltip">
|
||||
<.tooltip_trigger>
|
||||
<.button variant="ghost" size="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-help-circle h-4 w-4">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
||||
<path d="M12 17h.01"></path>
|
||||
</svg>
|
||||
</.button>
|
||||
</.tooltip_trigger>
|
||||
<.tooltip_content side="top" align="center">
|
||||
Need help? Click here for more information.
|
||||
</.tooltip_content>
|
||||
</.tooltip>
|
||||
"""
|
||||
use RiverConnectWeb.Components.UI, :component
|
||||
|
||||
@doc """
|
||||
The main tooltip component that manages state and positioning.
|
||||
|
||||
## Options
|
||||
|
||||
* `:id` - Unique identifier for the tooltip (optional, auto-generated if not provided).
|
||||
* `:open-delay` - Delay in milliseconds before opening the tooltip. Defaults to `150`.
|
||||
* `:close-delay` - Delay in milliseconds before closing the tooltip. Defaults to `100`.
|
||||
* `:on-open` - Handler for tooltip open event.
|
||||
* `:on-close` - Handler for tooltip close event.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :id, :string, default: nil
|
||||
|
||||
attr :"open-delay", :integer,
|
||||
default: 150,
|
||||
doc: "Delay in milliseconds before opening the tooltip"
|
||||
|
||||
attr :"close-delay", :integer,
|
||||
default: 100,
|
||||
doc: "Delay in milliseconds before closing the tooltip"
|
||||
|
||||
attr :"on-open", :any, default: nil, doc: "Handler for tooltip open event"
|
||||
attr :"on-close", :any, default: nil, doc: "Handler for tooltip close event"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tooltip(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "tooltip-#{System.unique_integer()}" end)
|
||||
|
||||
# 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(
|
||||
:options,
|
||||
json(%{
|
||||
openDelay: assigns[:"open-delay"],
|
||||
closeDelay: assigns[:"close-delay"],
|
||||
animations: get_animation_config()
|
||||
})
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class={classes(["relative inline-block", @class])}
|
||||
data-component="tooltip"
|
||||
data-state="closed"
|
||||
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 activates the tooltip when hovered.
|
||||
|
||||
If not provided, the first child of the tooltip will be used as trigger.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tooltip_trigger(assigns) do
|
||||
~H"""
|
||||
<div data-part="trigger" class={classes(["", @class])} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
The tooltip content that appears when triggered.
|
||||
|
||||
## Options
|
||||
|
||||
* `:side` - Placement of the tooltip relative to the trigger (top, right, bottom, left). Defaults to `"top"`.
|
||||
* `:align` - Alignment of the tooltip (start, center, end). Defaults to `"center"`.
|
||||
* `:side-offset` - Distance from the trigger in pixels. Defaults to `4`.
|
||||
* `:align-offset` - Offset along the alignment axis. Defaults to `0`.
|
||||
* `:class` - Additional CSS classes.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :side, :string, values: ~w(top right bottom left), default: "top"
|
||||
attr :align, :string, values: ~w(start center end), default: "center"
|
||||
attr :"side-offset", :integer, default: 8, doc: "Distance from the trigger in pixels"
|
||||
attr :"align-offset", :integer, default: 0, doc: "Offset along the alignment axis"
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tooltip_content(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
side_offset: assigns[:"side-offset"],
|
||||
align_offset: assigns[:"align-offset"]
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
data-part="content"
|
||||
data-side={@side}
|
||||
data-align={@align}
|
||||
data-side-offset={@side_offset}
|
||||
data-align-offset={@align_offset}
|
||||
class={
|
||||
classes([
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@class
|
||||
])
|
||||
}
|
||||
hidden
|
||||
{@rest}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_animation_config do
|
||||
%{
|
||||
"closed_to_open" => %{
|
||||
duration: 200,
|
||||
target_part: "content"
|
||||
},
|
||||
"open_to_closed" => %{
|
||||
duration: 130,
|
||||
target_part: "content"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user