Initial Commit

This commit is contained in:
2026-02-12 17:10:03 -06:00
commit 6992a06105
135 changed files with 18014 additions and 0 deletions
@@ -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
+154
View File
@@ -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">&rarr;</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>
+220
View File
@@ -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
+116
View File
@@ -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
+187
View File
@@ -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
+123
View File
@@ -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
+154
View File
@@ -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 &amp; 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>
+65
View File
@@ -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
+25
View File
@@ -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
+140
View File
@@ -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
+45
View File
@@ -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
+93
View File
@@ -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