Initial Commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
defmodule RiverConnectWeb.AudioChannel do
|
||||
use RiverConnectWeb, :channel
|
||||
alias RiverConnect.Audio
|
||||
|
||||
@impl true
|
||||
def join("audio:lobby", _payload, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_in("audio_start", %{"id" => id}, socket) do
|
||||
# Ensure upload directory exists
|
||||
upload_path = Application.app_dir(:river_connect, "priv/static/uploads")
|
||||
File.mkdir_p!(upload_path)
|
||||
|
||||
user_id = socket.assigns[:user_id] || "guest"
|
||||
|
||||
# Create DB record immediately with "recording" status
|
||||
{:ok, message} =
|
||||
Audio.create_message(%{
|
||||
user_id: user_id,
|
||||
file_path: "/uploads/#{id}.webm",
|
||||
status: "recording"
|
||||
})
|
||||
|
||||
# Store temporary file path and message_id in socket assigns
|
||||
temp_file_path = Path.join(upload_path, "#{id}.webm")
|
||||
|
||||
# Broadcast start and new message to other listeners
|
||||
broadcast_from!(socket, "audio_start", %{id: id})
|
||||
broadcast!(socket, "audio_message_created", %{message: message})
|
||||
|
||||
{:noreply,
|
||||
assign(socket, :current_recording, %{id: id, path: temp_file_path, message_id: message.id})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_in("audio_chunk", {:binary, payload}, socket) do
|
||||
case socket.assigns[:current_recording] do
|
||||
%{path: path} ->
|
||||
# Append binary chunk to file
|
||||
File.write!(path, payload, [:append])
|
||||
# Broadcast chunk to other listeners for live playback
|
||||
broadcast_from!(socket, "audio_chunk", %{data: Base.encode64(payload)})
|
||||
{:noreply, socket}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_in("audio_end", %{"duration_ms" => duration_ms}, socket) do
|
||||
case socket.assigns[:current_recording] do
|
||||
%{message_id: message_id} ->
|
||||
message = Audio.get_message!(message_id)
|
||||
|
||||
# Update DB record to "processing" status
|
||||
{:ok, message} =
|
||||
Audio.update_message(message, %{
|
||||
duration_ms: duration_ms,
|
||||
status: "processing"
|
||||
})
|
||||
|
||||
# Queue Oban job for processing
|
||||
%{id: message.id}
|
||||
|> RiverConnect.Workers.ProcessRecording.new()
|
||||
|> Oban.insert!()
|
||||
|
||||
# Notify UI that message status changed
|
||||
broadcast!(socket, "audio_message_ready", %{id: message.id})
|
||||
|
||||
{:noreply, assign(socket, :current_recording, nil)}
|
||||
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
defmodule RiverConnectWeb.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
## Channels
|
||||
channel "audio:*", RiverConnectWeb.AudioChannel
|
||||
|
||||
# Socket params are passed from the client and can
|
||||
# be used to verify and authenticate a user. After
|
||||
# verification, you can put default assigns into
|
||||
# the socket that will be set for all channels, ie
|
||||
#
|
||||
# {:ok, assign(socket, :user_id, verified_user_id)}
|
||||
#
|
||||
# To deny connection, return `:error`.
|
||||
#
|
||||
# See `Phoenix.Token` documentation for examples in
|
||||
# performing token verification on connect.
|
||||
@impl true
|
||||
def connect(params, socket, _connect_info) do
|
||||
# Simple user identification using client-provided ID or generating one
|
||||
user_id = params["user_id"] || Ecto.UUID.generate()
|
||||
{:ok, assign(socket, :user_id, user_id)}
|
||||
end
|
||||
|
||||
# Socket id's are topics that allow you to identify all sockets for a given user:
|
||||
#
|
||||
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
|
||||
#
|
||||
# Would allow you to broadcast a "disconnect" event and terminate
|
||||
# all active sockets and channels for a given user:
|
||||
#
|
||||
# Elixir.RiverConnectWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
|
||||
#
|
||||
# Returning `nil` makes this socket anonymous.
|
||||
@impl true
|
||||
def id(_socket), do: nil
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
defmodule RiverConnectWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use RiverConnectWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/river_connect_web/controllers/error_html/404.html.heex
|
||||
# * lib/river_connect_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
defmodule RiverConnectWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
defmodule RiverConnectWeb.PageController do
|
||||
use RiverConnectWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule RiverConnectWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use RiverConnectWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
@@ -0,0 +1,202 @@
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
<path
|
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||
fill="#FF9F92"
|
||||
/>
|
||||
<path
|
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||
fill="#FA8372"
|
||||
/>
|
||||
<path
|
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||
fill="#E96856"
|
||||
fill-opacity=".6"
|
||||
/>
|
||||
<path
|
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||
fill="#C42652"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mt-10 flex justify-between items-center">
|
||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="badge badge-warning badge-sm ml-3">
|
||||
v{Application.spec(:phoenix, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
<Layouts.theme_toggle />
|
||||
</div>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 leading-7 text-base-content/70">
|
||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Guides & Docs
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Source Code
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill="currentColor"
|
||||
fill-opacity=".15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Changelog
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixir-slack.community/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
||||
</svg>
|
||||
Join us on Slack
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
defmodule RiverConnectWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :river_connect
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_river_connect_key",
|
||||
signing_salt: "s1r9IzVS",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/socket", RiverConnectWeb.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve uploads/ directory
|
||||
plug Plug.Static,
|
||||
at: "/uploads",
|
||||
from: Path.expand("./priv/static/uploads"),
|
||||
gzip: false
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# When code reloading is disabled (e.g., in production),
|
||||
# the `gzip` option is enabled to serve compressed
|
||||
# static files generated by running `phx.digest`.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :river_connect,
|
||||
gzip: not code_reloading?,
|
||||
only: RiverConnectWeb.static_paths() ++ ["uploads"],
|
||||
raise_on_missing_only: code_reloading?
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :river_connect
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug RiverConnectWeb.Router
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
defmodule RiverConnectWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||
that you can use in your application. To use this Gettext backend module,
|
||||
call `use Gettext` and pass it as an option:
|
||||
|
||||
use Gettext, backend: RiverConnectWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext.Backend, otp_app: :river_connect
|
||||
end
|
||||
@@ -0,0 +1,140 @@
|
||||
defmodule RiverConnectWeb.AudioLive do
|
||||
use RiverConnectWeb, :live_view
|
||||
alias RiverConnect.Audio
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
RiverConnectWeb.Endpoint.subscribe("audio:lobby")
|
||||
end
|
||||
|
||||
# Fetch initial messages
|
||||
messages = Audio.list_messages()
|
||||
|
||||
{:ok, assign(socket, messages: messages, recording: false, page_title: "Voxer Audio")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("recording_started", %{"id" => _id}, socket) do
|
||||
{:noreply, assign(socket, recording: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("recording_stopped", _params, socket) do
|
||||
{:noreply, assign(socket, recording: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(%{event: "audio_message_created", payload: %{message: message}}, socket) do
|
||||
# Prepend new message
|
||||
messages = [message | socket.assigns.messages]
|
||||
{:noreply, assign(socket, messages: messages)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(%{event: "audio_message_ready", payload: %{id: _id}}, socket) do
|
||||
# Reload message to get updated status/path
|
||||
# In a real app we'd update just the one message, here we refresh all for simplicity
|
||||
messages = Audio.list_messages()
|
||||
{:noreply, assign(socket, messages: messages)}
|
||||
end
|
||||
|
||||
# Forward other broadcasts (like chunks) which are handled by JS, but if they come here we ignore
|
||||
@impl true
|
||||
def handle_info(_, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="p-4 max-w-md mx-auto h-screen flex flex-col">
|
||||
<h1 class="text-2xl font-bold mb-4 text-center">Voxer Clone</h1>
|
||||
|
||||
<div class="flex-1 overflow-y-auto mb-4 space-y-4 pr-2">
|
||||
<h2 class="text-xl font-semibold sticky top-0 bg-white p-2 border-b">Recent Messages</h2>
|
||||
|
||||
<%= if Enum.empty?(@messages) do %>
|
||||
<p class="text-gray-500 text-center py-8">No messages yet. Start talking!</p>
|
||||
<% end %>
|
||||
|
||||
<%= for message <- @messages do %>
|
||||
<div class="bg-gray-50 p-3 rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-2">
|
||||
<span class="font-mono truncate w-1/2" title={message.user_id}>{message.user_id}</span>
|
||||
<span>{Calendar.strftime(message.inserted_at, "%H:%M:%S")}</span>
|
||||
</div>
|
||||
|
||||
<%= if message.status == "ready" do %>
|
||||
<audio controls src={message.file_path} class="w-full h-8"></audio>
|
||||
<div class="text-right text-xs text-gray-400 mt-1">{message.duration_ms}ms</div>
|
||||
<% else %>
|
||||
<div class={[
|
||||
"flex items-center justify-center p-2 text-xs rounded animate-pulse",
|
||||
message.status == "recording" && "bg-red-50 text-red-700",
|
||||
message.status != "recording" && "bg-yellow-50 text-yellow-700"
|
||||
]}>
|
||||
<%= if message.status == "recording" do %>
|
||||
<span class="flex h-2 w-2 mr-2">
|
||||
<span class="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-red-400 opacity-75">
|
||||
</span> <span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
🔴 LIVE
|
||||
<button
|
||||
type="button"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("play_live", to: "#audio-recorder")}
|
||||
class="ml-3 bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded text-[10px] font-bold shadow-sm active:scale-95 transition-all"
|
||||
>
|
||||
▶ PLAY
|
||||
</button>
|
||||
<% else %>
|
||||
<svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
>
|
||||
</circle>
|
||||
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
Processing...
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="audio-recorder"
|
||||
phx-hook="AudioRecorder"
|
||||
class="pb-8 pt-4 border-t bg-gray-50 -mx-4 px-4 flex flex-col items-center justify-center"
|
||||
>
|
||||
<button
|
||||
id="record-btn"
|
||||
class={"w-24 h-24 rounded-full flex items-center justify-center text-white text-4xl shadow-lg transition-all transform active:scale-95 " <>
|
||||
if(@recording, do: "bg-red-600 animate-pulse ring-4 ring-red-200", else: "bg-blue-600 hover:bg-blue-700 hover:shadow-xl")}
|
||||
>
|
||||
<span class={
|
||||
if @recording,
|
||||
do:
|
||||
"animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75",
|
||||
else: "hidden"
|
||||
}>
|
||||
</span> {if @recording, do: "🎤", else: "🎙️"}
|
||||
</button>
|
||||
<p class={"mt-3 font-medium transition-colors " <> if(@recording, do: "text-red-600", else: "text-gray-600")}>
|
||||
{if @recording, do: "Click to finish", else: "Click to Talk"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule RiverConnectWeb.Router do
|
||||
use RiverConnectWeb, :router
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {RiverConnectWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
scope "/", RiverConnectWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
live "/audio", AudioLive
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", RiverConnectWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:river_connect, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard", metrics: RiverConnectWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
defmodule RiverConnectWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.start.system_time",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.start.system_time",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.exception.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
sum("phoenix.socket_drain.count"),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("river_connect.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("river_connect.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("river_connect.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("river_connect.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("river_connect.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {RiverConnectWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user