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
+6
View File
@@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]
+37
View File
@@ -0,0 +1,37 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
river_connect-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
+449
View File
@@ -0,0 +1,449 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->
+18
View File
@@ -0,0 +1,18 @@
# RiverConnect
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix
+264
View File
@@ -0,0 +1,264 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@import "./salad_ui.css";
@source "../css";
@source "../js";
@source "../../lib/river_connect_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 1rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {
@apply border-border !important;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 1rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
* {
@apply border-border !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
/* Scroll area */
/* firefox */
.salad-scroll-area {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.salad-scroll-area:hover {
scrollbar-color: #c2c2c2 transparent;
}
/* Chrome, Edge, and Safari */
.salad-scroll-area::-webkit-scrollbar {
width: 6px;
background-color: transparent;
}
.salad-scroll-area::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
.salad-scroll-area::-webkit-scrollbar-thumb {
background-color: #c2c2c2;
border-radius: 14px;
}
.animate-indeterminate-progress {
animation: indeterminate-progress 1.5s infinite linear;
background: linear-gradient(
to right,
transparent,
currentColor,
transparent
);
background-size: 200% 100%;
width: 100%;
}
+98
View File
@@ -0,0 +1,98 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { hooks as colocatedHooks } from "phoenix-colocated/river_connect"
import topbar from "../vendor/topbar"
import { AudioRecorder } from "./hooks/audio_recorder"
import SaladUI from "./ui/index.js";
import "./ui/components/dialog.js";
import "./ui/components/select.js";
import "./ui/components/tabs.js";
import "./ui/components/radio_group.js";
import "./ui/components/popover.js";
import "./ui/components/hover-card.js";
import "./ui/components/collapsible.js";
import "./ui/components/tooltip.js";
import "./ui/components/accordion.js";
import "./ui/components/slider.js";
import "./ui/components/switch.js";
import "./ui/components/dropdown_menu.js";
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: { ...colocatedHooks, AudioRecorder },
})
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({ detail: reloader }) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if (keyDown === "c") {
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if (keyDown === "d") {
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}
+169
View File
@@ -0,0 +1,169 @@
import socket from "../user_socket"
export const AudioRecorder = {
mounted() {
this.mediaRecorder = null;
this.isRecording = false;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.queue = [];
this.sourceBuffer = null;
this.mediaSource = null;
this.liveAudio = new Audio();
this.liveAudio.autoplay = true;
// Join channel
this.channel = socket.channel("audio:lobby", {})
this.channel.join()
.receive("ok", resp => { console.log("Joined audio lobby", resp) })
.receive("error", resp => { console.error("Unable to join audio lobby", resp) })
// Listen for start of others' audio
this.channel.on("audio_start", (payload) => {
this.prepareLivePlayback();
});
// Listen for incoming chunks (others talking)
this.channel.on("audio_chunk", (payload) => {
this.queueChunk(payload.data)
})
// UI Events
const btn = this.el.querySelector("button#record-btn");
btn.addEventListener("click", (e) => {
e.preventDefault();
if (this.isRecording) {
this.stopRecording();
} else {
this.startRecording(crypto.randomUUID());
}
});
// External event to play live stream (from UI button)
this.el.addEventListener("play_live", () => {
console.log("Play Live triggered");
console.log("MediaSource state:", this.mediaSource ? this.mediaSource.readyState : "null");
console.log("SourceBuffer state:", this.sourceBuffer ? (this.sourceBuffer.updating ? "updating" : "ready") : "null");
console.log("Buffer Queue length:", this.queue.length);
if (this.liveAudio) {
console.log("Attempting to play live audio...");
this.liveAudio.play()
.then(() => console.log("Live audio playing started"))
.catch(err => console.error("Error playing live audio", err));
} else {
console.error("Live audio object not initialized");
}
});
},
prepareLivePlayback() {
console.log("Preparing live playback MediaSource...");
this.queue = [];
this.mediaSource = new MediaSource();
this.liveAudio.src = URL.createObjectURL(this.mediaSource);
this.mediaSource.addEventListener('sourceopen', () => {
console.log("MediaSource open");
try {
if (this.mediaSource.sourceBuffers.length > 0) return;
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"');
this.sourceBuffer.mode = 'sequence';
this.sourceBuffer.addEventListener('updateend', () => {
this.processQueue();
});
console.log("SourceBuffer added and mode set to sequence");
} catch (e) {
console.error("Error adding SourceBuffer", e);
}
});
},
queueChunk(base64) {
// console.log("Received chunk, queueing...");
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
this.queue.push(bytes);
this.processQueue();
},
processQueue() {
if (!this.sourceBuffer) {
// console.warn("No SourceBuffer available to process queue");
return;
}
if (this.sourceBuffer.updating) {
// console.log("SourceBuffer updating, waiting...");
return;
}
if (this.queue.length === 0) {
return;
}
const chunk = this.queue.shift();
try {
this.sourceBuffer.appendBuffer(chunk);
// console.log("Appended buffer. Queue size:", this.queue.length);
} catch (e) {
console.error("Error appending to SourceBuffer", e);
}
},
startRecording(id) {
console.log("Starting recording...", id);
if (this.isRecording) return;
this.isRecording = true;
this.currentId = id;
this.startTime = Date.now();
this.el.setAttribute("data-recording", "true");
// Notify channel we are starting
this.channel.push("audio_start", { id: this.currentId });
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
console.log("Microphone access granted");
this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && this.channel) {
const reader = new FileReader();
reader.onload = () => {
this.channel.push("audio_chunk", reader.result);
};
reader.readAsArrayBuffer(e.data);
}
};
this.mediaRecorder.start(100); // chunk every 100ms
this.pushEvent("recording_started", { id });
})
.catch(err => {
console.error("Error accessing microphone", err);
this.isRecording = false;
});
},
stopRecording() {
console.log("Stopping recording...");
if (!this.isRecording) return;
this.isRecording = false;
this.el.removeAttribute("data-recording");
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
const duration = Date.now() - this.startTime;
this.channel.push("audio_end", { id: this.currentId, duration_ms: duration });
this.pushEvent("recording_stopped", { id: this.currentId, duration: duration });
}
}
+241
View File
@@ -0,0 +1,241 @@
// saladui/components/accordion.js
import Component from "../core/component";
import Collection from "../core/collection";
import SaladUI from "../index";
/**
* AccordionItem class to manage individual accordion items
* Handles state transitions and events for a single accordion item
*/
class AccordionItem extends Component {
constructor(itemElement, parentComponent, options) {
const { initialState = "closed" } = options || {};
super(itemElement, { initialState, ignoreItems: false });
this.parent = parentComponent;
this.value = itemElement.dataset.value;
this.disabled = itemElement.dataset.disabled === "true";
this.trigger = itemElement.querySelector("[data-part='item-trigger']");
this.content = itemElement.querySelector("[data-part='item-content']");
this.initialize();
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
closed: {
transitions: {
open: "open",
},
},
open: {
transitions: {
close: "closed",
},
},
},
events: {
closed: {
mouseMap: {
"item-trigger": {
click: "handleTriggerActivation",
},
},
keyMap: {
Enter: "handleTriggerActivation",
" ": "handleTriggerActivation",
},
},
open: {
mouseMap: {
"item-trigger": {
click: "handleTriggerActivation",
},
},
keyMap: {
Enter: "handleTriggerActivation",
" ": "handleTriggerActivation",
},
},
},
hiddenConfig: {
closed: {
"item-content": true,
},
open: {
"item-content": false,
},
},
ariaConfig: {
"item-trigger": {
all: {
controls: () => this.content?.id,
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
"item-content": {
all: {
labelledby: () => this.trigger?.id,
},
},
},
};
}
initialize() {
if (this.disabled) {
this.trigger.setAttribute("tabindex", "-1");
} else {
this.trigger.setAttribute("tabindex", "0");
}
}
handleEvent(eventType) {
switch (eventType) {
case "select":
return this.transition("open");
case "unselect":
return this.transition("close");
case "focus":
if (this.trigger && !this.disabled) {
this.trigger.focus();
}
return true;
case "blur":
return true;
}
}
handleTriggerActivation(event) {
event.preventDefault();
if (!this.disabled && !this.parent.disabled) {
this.parent.toggleItem(this);
}
}
}
/**
* AccordionComponent class for SaladUI framework
* Manages a collection of accordion items with state transitions
*/
class AccordionComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize properties
this.type = this.options.type || "single";
this.disabled = this.options.disabled || false;
// Initialize collection manager
this.collection = new Collection({
type: this.type,
defaultValue: this.options.defaultValue,
value: this.options.value,
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled || this.disabled,
});
// Set keyboard navigation defaults
this.config.preventDefaultKeys = [
"ArrowUp",
"ArrowDown",
"Home",
"End",
"Enter",
" ",
];
// Initialize accordion items
this.initializeItems();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
enter: () => {},
exit: () => {},
transitions: {},
},
},
events: {
idle: {
keyMap: {
ArrowUp: () => this.navigateItem("prev"),
ArrowDown: () => this.navigateItem("next"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
},
},
};
}
initializeItems() {
const itemElements = Array.from(
this.el.querySelectorAll("[data-part='item']"),
);
this.items = itemElements.map((element) => {
// Initialize AccordionItem without hook context
const itemValue = element.dataset.value;
element.id = `${this.el.id}-item-${itemValue}`;
// Check if this item is initially open
const isOpen = this.collection.getValue(true).includes(itemValue);
const item = new AccordionItem(element, this, {
initialState: isOpen ? "open" : "closed",
});
this.collection.add(item);
return item;
});
}
toggleItem(item) {
const collectionItem = this.collection.getItemByInstance(item);
if (!collectionItem) return;
// Toggle item selection
this.collection.select(collectionItem);
// Emit event with current value
const value = this.collection.getValue();
this.pushEvent("value-changed", { value });
}
navigateItem(direction) {
const currentFocus = document.activeElement;
const currentItemElement = currentFocus?.closest("[data-part='item']");
let currentItem = null;
if (currentItemElement) {
currentItem = this.items.find((item) => item.el === currentItemElement);
}
let referenceCollectionItem = null;
if (currentItem) {
referenceCollectionItem = this.collection.getItemByInstance(currentItem);
}
// Get the target item using collection manager's navigation methods
const targetItem = this.collection.getItem(
direction,
referenceCollectionItem,
);
if (targetItem) {
this.collection.focus(targetItem);
}
}
}
// Register the component
SaladUI.register("accordion", AccordionComponent);
export default AccordionComponent;
+98
View File
@@ -0,0 +1,98 @@
// saladui/components/chart.js
import Component from "../core/component";
import SaladUI from "../index";
import Chart from "chart.js/auto";
function cssvar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name);
}
const RESERVED_CONFIG_KEYS = ["labels", "type", "options"];
const RESERVED_DATASET_KEYS = ["datakey"];
const DEFAULT_CHART_TYPE = "line";
class ChartComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
this.chartOptions = JSON.parse(this.el.dataset.chartOptions || "{}");
this.chartType = this.el.dataset.chartType || DEFAULT_CHART_TYPE;
this.initializeChart();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
transitions: { select: "idle" },
},
},
};
}
handleCommand(command, params) {
if (command === "update") {
this.updateChart(params);
return true;
}
}
initializeChart() {
const data = JSON.parse(this.el.dataset.chartData);
this.chart = new Chart(this.el, {
type: this.chartType,
data: data,
options: this.chartOptions,
});
}
updateChart(payload) {
if (payload.data) {
this.chart.data = { ...this.chart.data, ...payload.data };
}
if (payload.options) {
this.chartOptions = { ...this.chartOptions, ...payload.options };
this.chart.options = this.chartOptions;
}
this.chart.update();
}
extractDatasets(config) {
return Object.entries(config)
.filter(([key]) => !RESERVED_CONFIG_KEYS.includes(key))
.map(([, value]) => {
const dataset = { ...value };
Object.keys(dataset).forEach((key) => {
// support css variables for colors
if (dataset[key].includes("var(--")) {
const colorName = dataset[key].split("--")[1].split(")")[0].trim();
dataset[key] = dataset[key].replace(
`var(--${colorName})`,
cssvar(`--${colorName}`),
);
}
});
return Object.fromEntries(
Object.entries(dataset).filter(
([key]) => !RESERVED_DATASET_KEYS.includes(key),
),
);
});
}
destroy() {
if (this.chart) {
this.chart.destroy();
this.chart = null;
}
}
}
// Register the component
SaladUI.register("chart", ChartComponent);
export default ChartComponent;
+92
View File
@@ -0,0 +1,92 @@
// saladui/components/collapsible.js
import Component from "../core/component";
import SaladUI from "../index";
class CollapsibleComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.content = this.getPart("content");
// Set keyboard navigation defaults
this.config.preventDefaultKeys = ["Enter", " "];
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
transitions: {
toggle: "open",
open: "open",
},
},
open: {
enter: "onOpenEnter",
transitions: {
toggle: "closed",
close: "closed",
},
},
},
events: {
closed: {
keyMap: {
Enter: "toggle",
" ": "toggle",
},
},
open: {
keyMap: {
Enter: "toggle",
" ": "toggle",
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
trigger: {
all: {
controls: () => this.getPartId("content"),
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
labelledby: () => this.getPartId("trigger"),
role: "region",
},
},
},
};
}
// State handlers
onOpenEnter() {
this.pushEvent("opened");
}
onClosedEnter() {
this.pushEvent("closed");
}
}
// Register the component
SaladUI.register("collapsible", CollapsibleComponent);
export default CollapsibleComponent;
+129
View File
@@ -0,0 +1,129 @@
import Component from "../core/component";
import SaladUI from "..";
/**
* CommandComponent for SaladUI
* Implements filtering, keyboard navigation, and selection for a command palette/list.
*/
class CommandComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext, ignoreItems: false });
// Set default field state
this.currentItemIdx = 0;
// Core elements
this.input = this.getPart("input");
this.list = this.getPart("list");
this.empty = this.getPart("empty");
this.groups = this.getAllParts("group");
this.items = this.getAllParts("item");
// Bind event handlers
this.input.addEventListener("input", this.handleSearch);
// Initial search/filter
this.handleSearch();
// Prevent default for navigation keys
this.config.preventDefaultKeys = ["Escape", "ArrowDown", "ArrowUp"];
}
getComponentConfig() {
return {
stateMachine: {
idle: { transitions: {} },
},
events: {
idle: {
keyMap: {
Enter: "selectItem",
ArrowDown: "focusNextItem",
ArrowUp: "focusPrevItem",
Escape: "blurInput",
},
},
},
};
}
// Focus item by index, wrap around if needed
focusItem(index) {
if (!this.selectableItems?.length) return;
// Wrap index
if (index < 0) index = this.selectableItems.length - 1;
if (index >= this.selectableItems.length) index = 0;
this.currentItemIdx = index;
// Deselect all
this.items.forEach((item) => {
item.setAttribute("data-selected", "false");
item.setAttribute("aria-selected", "false");
});
// Select current
const selectedItem = this.selectableItems[index];
selectedItem.setAttribute("data-selected", "true");
selectedItem.setAttribute("aria-selected", "true");
}
focusNextItem = () => this.focusItem(this.currentItemIdx + 1);
focusPrevItem = () => this.focusItem(this.currentItemIdx - 1);
blurInput = () => this.input?.blur();
selectItem = () => {
if (this.currentItemIdx === -1) return;
const item = this.selectableItems[this.currentItemIdx];
item.click();
};
// Handle search/filtering
handleSearch = () => {
const query = this.input.value.trim().toLowerCase();
// Filter items
this.items.forEach((item) => {
const text = item.textContent.trim().toLowerCase();
const visible = query === "" || text.includes(query);
item.setAttribute("data-visible", visible ? "true" : "false");
});
this.visibleItems = this.items.filter(
(el) => el.getAttribute("data-visible") === "true",
);
this.selectableItems = this.visibleItems.filter(
(el) => !el.hasAttribute("disabled"),
);
this.groups.forEach((group) => {
const visibleOptions = group.querySelectorAll("[data-visible='true']");
group.setAttribute(
"data-visible",
visibleOptions.length > 0 ? "true" : "false",
);
});
this.focusItem(0);
const noItems = this.visibleItems.length === 0;
if (noItems) {
this.empty.setAttribute("data-visible", "true");
} else {
this.empty.setAttribute("data-visible", "false");
}
};
beforeDestroy() {
this.input.removeEventListener("input", this.handleSearch);
}
}
// Register the component
SaladUI.register("command", CommandComponent);
export default CommandComponent;
+165
View File
@@ -0,0 +1,165 @@
// saladui/components/dialog.js
import Component from "../core/component";
import SaladUI from "../index";
import FocusTrap from "../core/focus-trap";
import ClickOutsideMonitor from "../core/click-outside";
class DialogComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext, initialState: "closed" });
// Initialize properties
this.root = this.el;
this.content = this.getPart("content");
this.contentPanel = this.getPart("content-panel");
this.config.preventDefaultKeys = ["Escape"];
this.setupEvents();
this.transition(this.el.dataset.open == "true" ? "open" : "close");
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
exit: "onClosedExit",
transitions: {
open: "open",
},
},
open: {
enter: "onOpenEnter",
transitions: {
close: "closed",
},
},
},
events: {
closed: {
keyMap: {},
},
open: {
keyMap: {
Escape: "close",
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
content: {
all: {
role: "dialog",
},
open: {
hidden: "false",
modal: "true",
},
closed: {
hidden: "true",
},
},
"content-panel": {
open: {
labelledby: () => this.getPartId("title"),
describedby: () => this.getPartId("description"),
},
},
"close-trigger": {
all: {
label: "Close dialog",
},
},
},
};
}
// Setup component events
setupComponentEvents() {
super.setupComponentEvents();
// Only setup click handler if closeOnOutsideClick is enabled
if (this.options.closeOnOutsideClick) {
this.setupOutsideClickDetection();
}
}
setupOutsideClickDetection() {
// Create click outside monitor to handle clicks on the overlay
this.clickOutsideMonitor = new ClickOutsideMonitor(
[this.contentPanel],
(event) => {
// Only close if click was directly on the content container (overlay area)
if (
event.target === this.content ||
event.target.dataset.part === "overlay"
) {
this.transition("close");
}
},
);
}
// State machine handlers
onClosedEnter() {
// Clean up focus trap
if (this.focusTrap) {
this.focusTrap.deactivate();
}
// Clean up click outside monitor
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.stop();
}
// Notify the server of the state change
this.pushEvent("closed");
}
onClosedExit() {
// No special handling needed
}
onOpenEnter() {
// Initialize focus trap if not already created
this.el.focus();
if (!this.focusTrap) {
this.focusTrap = new FocusTrap(this.contentPanel);
}
// Activate focus trap
this.focusTrap.activate();
// Activate click outside monitor if enabled
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.start();
}
// Setup escape key handling - now handled by the component's keyMap
// Notify the server of the state change
this.pushEvent("opened");
}
beforeDestroy() {
// Clean up focus trap
this.focusTrap?.destroy();
this.focusTrap = null;
// Clean up click outside monitor
this.clickOutsideMonitor?.destroy();
this.clickOutsideMonitor = null;
}
}
// Register the component
SaladUI.register("dialog", DialogComponent);
export default DialogComponent;
+175
View File
@@ -0,0 +1,175 @@
// saladui/components/dropdown_menu.js
import Component from "../core/component";
import SaladUI from "../index";
import PositionedElement from "../core/positioned-element";
import Menu from "./menu";
/**
* DropdownMenuComponent class for SaladUI framework
* Manages a dropdown menu with support for keyboard navigation and accessibility
*/
class DropdownMenuComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.positioner = this.getPart("positioner");
this.content = this.positioner.querySelector("[data-part='content']");
this.menu = new Menu(this.content, {
hookContext,
onItemSelect: this.onItemSelect.bind(this),
});
// Set keyboard navigation defaults
this.config.preventDefaultKeys = ["Escape", "ArrowDown", " ", "Enter"];
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
transitions: {
open: "open",
toggle: "open",
},
},
open: {
enter: "onOpenEnter",
transitions: {
close: "closed",
toggle: "closed",
},
},
},
events: {
closed: {
keyMap: {
ArrowDown: "open",
" ": "open",
Enter: "open",
},
mouseMap: {
trigger: {
click: (_e) => {
this.transition("open");
},
},
},
},
open: {
keyMap: {
Escape: "close",
},
},
},
hiddenConfig: {
closed: {
positioner: true, // Hide the positioner in closed state
},
open: {
positioner: false, // Show the positioner in open state
},
},
ariaConfig: {
trigger: {
all: {
haspopup: "menu",
controls: () =>
this.content ? this.content.id || `${this.el.id}-content` : null,
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
role: "menu",
},
},
},
};
}
initializePositionedElement() {
if (this.positioner && this.trigger && !this.positionedElement) {
const side = this.positioner.getAttribute("data-side") || "bottom";
const align = this.positioner.getAttribute("data-align") || "start";
const sideOffset = parseInt(
this.positioner.getAttribute("data-side-offset") || "4",
10,
);
const alignOffset = parseInt(
this.positioner.getAttribute("data-align-offset") || "0",
10,
);
// Get portal options
const usePortal = this.options.usePortal === true;
let portalContainer = null;
if (this.options.portalContainer) {
portalContainer = document.querySelector(this.options.portalContainer);
}
this.positionedElement = new PositionedElement(
this.positioner,
this.trigger,
{
placement: side,
alignment: align,
sideOffset,
alignOffset,
flip: true,
usePortal,
portalContainer: portalContainer || document.body,
trapFocus: false,
onOutsideClick: () => this.transition("close"),
},
);
}
}
onOpenEnter() {
this.previousFocusEl = document.activeElement;
this.initializePositionedElement();
this.positionedElement?.activate();
this.menu.activate();
this.pushEvent("opened");
}
onClosedEnter() {
this.positionedElement?.deactivate();
this.pushEvent("closed");
this.previousFocusEl?.focus();
this.previousFocusEl = null;
}
onItemSelect(_item) {
this.transition("close");
}
beforeDestroy() {
// Clean up the positioned element
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
// Clean up menu items
if (this.menu) {
this.menu.destroy();
this.menu = null;
}
}
}
// Register the component
SaladUI.register("dropdown-menu", DropdownMenuComponent);
export default DropdownMenuComponent;
+205
View File
@@ -0,0 +1,205 @@
// saladui/components/hover_card.js
import Component from "../core/component";
import SaladUI from "../index";
import PositionedElement from "../core/positioned-element";
// Define constants at the module level outside the class
const DEFAULT_POSITION_CONFIG = {
placement: "top",
alignment: "center",
sideOffset: 8,
alignOffset: 0,
};
const DEFAULT_TIMING_CONFIG = {
openDelay: 300,
closeDelay: 200,
};
class HoverCardComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.content = this.getPart("content");
// Set config from options with fallbacks to defaults
this.config.openDelay =
this.options.openDelay || DEFAULT_TIMING_CONFIG.openDelay;
this.config.closeDelay =
this.options.closeDelay || DEFAULT_TIMING_CONFIG.closeDelay;
// Track timer IDs for delayed open/close
this.openTimer = null;
this.closeTimer = null;
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
transitions: {
open: "open",
},
},
open: {
enter: "onOpenEnter",
exit: "onOpenExit",
transitions: {
close: "closed",
},
},
},
events: {
closed: {
mouseMap: {
trigger: {
mouseenter: "delayOpen",
focus: "delayOpen",
},
},
},
open: {
mouseMap: {
trigger: {
mouseleave: "delayClose",
blur: "delayClose",
},
content: {
mouseenter: "clearTimers",
mouseleave: "delayClose",
},
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
trigger: {
all: {
haspopup: "dialog",
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
role: "dialog",
},
},
},
};
}
// Generic methods for delayed state transitions
delayOpen() {
this.clearTimers();
this.openTimer = setTimeout(() => {
this.transition("open");
}, this.config.openDelay);
}
delayClose() {
this.clearTimers();
this.closeTimer = setTimeout(() => {
this.transition("close");
}, this.config.closeDelay);
}
clearTimers() {
if (this.openTimer) {
clearTimeout(this.openTimer);
this.openTimer = null;
}
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
}
// Initialize the positioned element
initializePositionedElement() {
if (this.positionedElement) return;
if (!this.trigger || !this.content) return;
// Get positioning configuration from content attributes with fallbacks to defaults
const positionConfig = {
placement:
this.content.getAttribute("data-side") ||
DEFAULT_POSITION_CONFIG.placement,
alignment:
this.content.getAttribute("data-align") ||
DEFAULT_POSITION_CONFIG.alignment,
sideOffset:
parseInt(this.content.getAttribute("data-side-offset"), 10) ||
DEFAULT_POSITION_CONFIG.sideOffset,
alignOffset:
parseInt(this.content.getAttribute("data-align-offset"), 10) ||
DEFAULT_POSITION_CONFIG.alignOffset,
flip: true,
usePortal: false, // Don't use portal to keep DOM structure
trapFocus: false,
};
// Create positioned element
this.positionedElement = new PositionedElement(
this.content,
this.trigger,
positionConfig,
);
}
// State machine handlers
onOpenEnter() {
// Initialize positioned element if needed
this.initializePositionedElement();
// Activate positioned element
if (this.positionedElement) {
this.positionedElement.activate();
}
// Notify the server of the state change
this.pushEvent("opened");
}
onOpenExit() {
// Deactivate positioned element
if (this.positionedElement) {
this.positionedElement.deactivate();
}
}
onClosedEnter() {
// Notify the server of the state change
this.pushEvent("closed");
}
beforeDestroy() {
this.clearTimers();
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
}
}
// Register the component
SaladUI.register("hover-card", HoverCardComponent);
export default HoverCardComponent;
+324
View File
@@ -0,0 +1,324 @@
// saladui/components/dropdown_menu.js
import Component from "../core/component";
import Collection from "../core/collection";
/**
* Base class for dropdown menu items that provides common functionality
*/
class MenuItemBase extends Component {
constructor(itemElement, parentComponent, options) {
super(itemElement, {
...options,
initialState: "idle",
ignoreItems: false,
});
this.parent = parentComponent;
// share the same hook context with the parent
this.hook = this.parent.hook;
this.value =
itemElement.value ||
itemElement.getAttribute("data-value") ||
itemElement.textContent.trim();
this.disabled = itemElement.getAttribute("data-disabled") !== null;
this.config.preventDefaultKeys = [" ", "Enter"];
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
idle: {},
},
events: {
idle: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
},
ariaConfig: {
item: {
all: {
role: "menuitem",
disabled: () => (this.disabled ? "true" : null),
},
},
},
};
}
handleEvent(eventType) {
switch (eventType) {
case "focus":
if (!this.disabled) {
this.transition("focus");
this.el.focus();
}
return true;
case "blur":
this.transition("blur");
return true;
}
}
handleActivation(event) {
if (this.disabled) return;
this.pushEvent(
"item-selected",
{
value: this.value,
},
this.parent.el,
);
this.parent.selectItem(this);
}
handleMouseEnter() {
if (!this.disabled) {
this.parent.handleItemFocus(this);
}
}
}
/**
* Regular dropdown menu item implementation
*/
class MenuItem extends MenuItemBase {
constructor(itemElement, parentComponent, options) {
super(itemElement, parentComponent, options);
}
}
/**
* Checkbox item implementation that can toggle between checked states
*/
class MenuCheckboxItem extends MenuItemBase {
constructor(itemElement, parentComponent, options) {
super(itemElement, parentComponent, options);
}
getComponentConfig() {
return {
stateMachine: {
checked: {
transitions: {
toggle: "unchecked",
},
},
unchecked: {
transitions: {
toggle: "checked",
},
},
},
events: {
checked: {
mouseMap: {
"checkbox-item": {
click: "handleActivation",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
unchecked: {
mouseMap: {
"checkbox-item": {
click: "handleActivation",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
" ": "handleActivation",
Enter: "handleActivation",
},
},
},
hiddenConfig: {
checked: {
"item-indicator": false,
},
unchecked: {
"item-indicator": true,
},
},
ariaConfig: {
item: {
all: {
role: "menuitemcheckbox",
disabled: () => (this.disabled ? "true" : null),
checked: () => (this.state == "checked" ? "true" : "false"),
},
},
},
};
}
handleActivation(event) {
super.handleActivation(event);
if (event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}
if (this.disabled) return;
this.transition("toggle");
this.pushEvent(
"checked-changed",
{
value: this.value,
checked: this.state == "checked",
},
this.parent.el,
);
}
}
/**
* MenuComponent class for SaladUI framework
* Manages a dropdown menu with support for keyboard navigation and accessibility
*/
class Menu extends Component {
constructor(el, { hookContext, onItemSelect }) {
super(el, { hookContext });
// callback for item selection
this.onItemSelect = onItemSelect || (() => {});
this.menuItems = [];
// Set keyboard navigation defaults
this.config.preventDefaultKeys = ["ArrowDown", "ArrowUp", "Home", "End"];
// Initialize items and collection
this.initializeItems();
this.initializeCollection();
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
transitions: {},
},
},
events: {
_all: {
keyMap: {
ArrowDown: () => this.navigateItem("next"),
ArrowUp: () => this.navigateItem("prev"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
},
},
ariaConfig: {},
};
}
initializeItems() {
// Get all items in the correct DOM order
const allItemElements = Array.from(
this.el.querySelectorAll(
"[data-part='item'], [data-part='checkbox-item']",
),
);
// Create appropriate item components while preserving original order
this.menuItems = allItemElements.map((element) => {
const itemType = element.getAttribute("data-part");
switch (itemType) {
case "checkbox-item":
return new MenuCheckboxItem(element, this, {
initialState: "normal",
});
default: // Regular item
return new MenuItem(element, this, {
initialState: "normal",
});
}
});
}
initializeCollection() {
// Initialize collection manager for navigation
this.collection = new Collection({
type: "single",
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled,
});
// Register items with the collection
this.menuItems.forEach((item) => {
this.collection.add(item);
});
}
// Activate menu, focusthe first item
activate() {
const firstItem = this.collection.getItem("first");
if (firstItem) {
this.collection.focus(firstItem);
}
}
selectItem(item) {
if (item.disabled) return;
this.onItemSelect(item);
}
handleItemFocus(item) {
const collectionItem = this.collection.getItemByInstance(item);
if (!collectionItem) return;
this.collection.focus(collectionItem);
}
navigateItem(direction) {
// Check if we have an active focused item
let currentItem = this.collection.focusedItem;
// Get target item using collection's navigation methods
const targetItem = this.collection.getItem(direction, currentItem);
if (targetItem) {
this.collection.focus(targetItem);
}
}
beforeDestroy() {
// Clean up menu items
if (this.menuItems) {
this.menuItems.forEach((item) => {
if (typeof item.destroy === "function") {
item.destroy();
}
});
this.menuItems = null;
}
// Clean up collection
this.collection = null;
}
}
// Register the component
export default Menu;
+133
View File
@@ -0,0 +1,133 @@
// saladui/components/popover.js
import Component from "../core/component";
import SaladUI from "../index";
import PositionedElement from "../core/positioned-element";
class PopoverComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.positioner = this.getPart("positioner");
this.content = this.positioner
? this.positioner.querySelector("[data-part='content']")
: null;
// Set keyboard navigation defaults
this.config.preventDefaultKeys = ["Escape"];
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
transitions: {
open: "open",
toggle: "open",
},
},
open: {
enter: "onOpenEnter",
transitions: {
close: "closed",
toggle: "closed",
},
},
},
events: {
closed: {
keyMap: {},
},
open: {
keyMap: {
Escape: "close",
},
},
},
hiddenConfig: {
closed: {
positioner: true, // Hide the positioner in closed state
},
open: {
positioner: false, // Show the positioner in open state
},
},
ariaConfig: {
trigger: {
all: {
haspopup: "dialog",
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
role: "dialog",
},
},
},
};
}
/**
* Initializes the positioned element if the positioner and trigger exist and the positioned element is not already created.
* Extracts placement configuration from DOM attributes and creates a new PositionedElement instance.
*/
initializePositionedElement() {
if (this.positioner && this.trigger && !this.positionedElement) {
const placement = this.positioner.getAttribute("data-side") || "bottom";
const alignment = this.positioner.getAttribute("data-align") || "center";
const sideOffset = parseInt(
this.positioner.getAttribute("data-side-offset") || "8",
10,
);
const alignOffset = parseInt(
this.positioner.getAttribute("data-align-offset") || "0",
10,
);
this.positionedElement = new PositionedElement(
this.positioner,
this.trigger,
{
placement,
alignment,
sideOffset,
alignOffset,
flip: true,
usePortal: true,
portalContainer: document.querySelector(this.options.portalContainer),
trapFocus: true,
onOutsideClick: () => this.transition("close"),
},
);
}
}
onOpenEnter(params = {}) {
this.initializePositionedElement();
this.positionedElement?.activate();
this.pushEvent("opened");
}
onClosedEnter() {
this.positionedElement?.deactivate();
this.pushEvent("closed");
}
beforeDestroy() {
this.positionedElement?.destroy();
this.positionedElement = null;
}
}
// Register the component
SaladUI.register("popover", PopoverComponent);
export default PopoverComponent;
+219
View File
@@ -0,0 +1,219 @@
// saladui/components/radio_group.js
import Component from "../core/component";
import SaladUI from "../index";
import Collection from "../core/collection";
class RadioGroupComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext, ignoreItems: false });
// Initialize properties
this.items = this.getAllParts("item");
// Set keyboard navigation defaults
this.config.preventDefaultKeys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
];
// Initialize collection manager for radio items
this.initializeCollection();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
enter: "onIdleEnter",
transitions: {
valueChanged: "idle",
},
},
},
events: {
idle: {
keyMap: {
ArrowLeft: () => this.navigateItem("prev"),
ArrowRight: () => this.navigateItem("next"),
ArrowUp: () => this.navigateItem("prev"),
ArrowDown: () => this.navigateItem("next"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
mouseMap: {
item: {
click: "handleItemClick",
},
},
},
},
ariaConfig: {
root: {
all: {
role: "radiogroup",
},
},
item: {
all: {
role: "radio",
},
},
},
};
}
initializeCollection() {
this.collection = new Collection({
type: "single",
defaultValue: this.options.initialValue,
getItemValue: (item) => item.getAttribute("data-value"),
isItemDisabled: (item) => item.getAttribute("data-disabled") === "true",
});
// Register items with the collection
this.items.forEach((item) => {
this.collection.add(item);
// Set initial ID if not present
if (!item.id) {
const value = item.getAttribute("data-value");
item.id = `${this.el.id}-item-${value}`;
}
});
// Initialize UI state
this.updateItemStates();
}
handleItemClick(event) {
const item = event.currentTarget;
if (item.getAttribute("data-disabled") === "true") return;
this.selectItem(item);
}
selectItem(item) {
const value = item.getAttribute("data-value");
const previousValue = this.collection.getValue();
// Get the collection item
const collectionItem = this.collection.getItemByInstance(item);
// Only proceed if we have a valid item and it's not already selected
if (collectionItem && value !== previousValue) {
// Transition the state machine to apply any state-specific behavior
this.transition("valueChanged", { value, previousValue });
this.collection.select(collectionItem);
this.updateItemStates();
// Emit value changed event
this.pushEvent("value-changed", {
value,
previousValue,
});
}
}
updateItemStates() {
const selectedValue = this.collection.getValue();
// Loop over collection items instead of DOM elements directly
this.collection.items.forEach((collectionItem) => {
const item = collectionItem.instance;
const value = collectionItem.value;
const isSelected = value === selectedValue;
const isDisabled = item.getAttribute("data-disabled") === "true";
// Update visual state
item.setAttribute("data-state", isSelected ? "checked" : "unchecked");
// Update ARIA attributes
item.setAttribute("aria-checked", isSelected.toString());
// Update tabindex for keyboard navigation
item.setAttribute("tabindex", isSelected ? "0" : "-1");
// Update native radio input if present
const input = item.querySelector('input[type="radio"]');
if (input) {
input.checked = isSelected;
input.disabled = isDisabled;
// Ensure name attribute is set for form submission
if (!input.name && this.options.name) {
input.name = this.options.name;
}
}
});
}
navigateItem(direction) {
const currentValue = this.collection.getValue();
const currentItem = this.collection.getItemByValue(currentValue);
// Get next item based on direction
const nextItem = this.collection.getItem(direction, currentItem);
if (!nextItem) return;
// Focus the item
if (typeof nextItem.instance.focus === "function") {
nextItem.instance.focus();
} else if (nextItem.instance) {
// Focus the item element directly
nextItem.instance.focus();
}
// Automatically select the focused item
this.selectItem(nextItem.instance);
}
onIdleEnter() {
// If no item is selected, make first enabled item focusable
if (!this.collection.getValue()) {
const firstItem = this.collection.getItem("first");
if (firstItem && firstItem.instance) {
firstItem.instance.setAttribute("tabindex", "0");
}
}
}
// Handle focus management for the entire group
setupComponentEvents() {
super.setupComponentEvents();
this.el.addEventListener("focus", (e) => {
// Only handle focus if the group itself was focused (not a child)
if (e.target === this.el) {
const selectedValue = this.collection.getValue();
if (selectedValue) {
// Focus the selected item
const selectedItem = this.collection.getItemByValue(selectedValue);
if (selectedItem && selectedItem.instance) {
selectedItem.instance.focus();
}
} else {
// Focus the first enabled item if none is selected
const firstItem = this.collection.getItem("first");
if (firstItem && firstItem.instance) {
firstItem.instance.focus();
}
}
}
});
}
// Clean up when the component is destroyed
beforeDestroy() {
this.collection = null;
}
}
// Register the component
SaladUI.register("radio-group", RadioGroupComponent);
export default RadioGroupComponent;
+481
View File
@@ -0,0 +1,481 @@
// saladui/components/select.js
import Component from "../core/component";
import SaladUI from "../index";
import Collection from "../core/collection";
import PositionedElement from "../core/positioned-element";
/**
* SelectItem class to manage individual select options
* Handles state transitions and events for a single select item
*/
class SelectItem extends Component {
constructor(itemElement, parentComponent, options) {
const { initialState = "normal" } = options || {};
super(itemElement, { initialState, ignoreItems: false });
this.parent = parentComponent;
this.value = itemElement.dataset.value;
this.disabled = itemElement.dataset.disabled === "true";
this.label = itemElement.textContent.trim();
this.setupEvents();
}
getComponentConfig() {
return {
stateMachine: {
unchecked: {
transitions: {
check: "checked",
},
},
checked: {
transitions: {
uncheck: "unchecked",
},
},
},
events: {
unchecked: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
Enter: "handleActivation",
" ": "handleActivation",
},
},
checked: {
mouseMap: {
item: {
click: "handleActivation",
mouseenter: "handleMouseEnter",
mouseleave: "handleMouseLeave",
},
},
keyMap: {
Enter: "handleActivation",
" ": "handleActivation",
},
},
},
hiddenConfig: {
checked: {
"item-indicator": false,
},
unchecked: {
"item-indicator": true,
},
},
ariaConfig: {
item: {
all: {
role: "option",
},
checked: {
selected: "true",
},
unchecked: {
selected: "false",
},
},
},
};
}
handleEvent(eventType) {
switch (eventType) {
case "select":
return this.transition("check");
case "unselect":
return this.transition("uncheck");
case "focus":
if (!this.disabled) {
// Just mark as highlighted without direct focus
this.el.focus();
}
return true;
case "blur":
return true;
}
}
handleActivation(event) {
event.preventDefault();
event.stopPropagation();
if (!this.disabled) {
this.parent.selectValue(this.value);
}
}
handleMouseEnter() {
if (!this.disabled) {
this.parent.handleItemFocus(this);
}
}
}
/**
* SelectComponent class for SaladUI framework
* Manages a collection of select items with state transitions
*/
class SelectComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger = this.getPart("trigger");
this.valueDisplay = this.getPart("value");
this.content = this.getPart("content");
this.disabled = this.el.dataset.disabled === "true";
// Get configuration from options
this.multiple = this.options.multiple || false;
this.usePortal = this.options.hasOwnProperty("usePortal")
? this.options.usePortal
: false;
this.portalContainer = this.options.portalContainer || null;
// Initialize collection manager
this.collection = new Collection({
type: this.multiple ? "multiple" : "single",
defaultValue: this.options.defaultValue,
value: this.options.value,
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled || this.disabled,
});
// Set keyboard navigation defaults
this.config.preventDefaultKeys = [
"ArrowUp",
"ArrowDown",
"Home",
"End",
"Enter",
" ",
"Escape",
];
// Initialize select items
this.initializeItems();
this.initializePlaceholder();
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
exit: "onClosedExit",
transitions: {
open: "open",
toggle: "open",
},
},
open: {
enter: "onOpenEnter",
exit: "onOpenExit",
transitions: {
close: "closed",
toggle: "closed",
select: "closed",
},
},
},
events: {
closed: {
keyEventTarget: "content",
keyMap: {
ArrowDown: "open",
ArrowUp: "open",
Enter: "open",
" ": "open",
},
mouseMap: {
trigger: {
click: "toggle",
},
},
},
open: {
keyEventTarget: "content",
keyMap: {
Escape: "close",
ArrowUp: () => this.navigateItem("prev"),
ArrowDown: () => this.navigateItem("next"),
Home: () => this.navigateItem("first"),
End: () => this.navigateItem("last"),
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
trigger: {
all: {
haspopup: "listbox",
},
open: {
expanded: "true",
},
closed: {
expanded: "false",
},
},
content: {
all: {
role: "listbox",
},
},
},
};
}
initializeItems() {
const itemElements = Array.from(
this.el.querySelectorAll("[data-part='item']"),
);
itemElements.map((element) => {
// Create a SelectItem instance for each item
const value = element.dataset.value;
// Check if this item is initially selected
const isSelected = this.collection.isValueSelected(value);
const initialState = isSelected ? "checked" : "unchecked";
const item = new SelectItem(element, this, { initialState });
this.collection.add(item);
});
// Update value display based on initial selection
this.updateValueDisplay();
}
initializePlaceholder() {
if (!this.valueDisplay) return;
const placeholder =
this.valueDisplay.getAttribute("data-placeholder") || "Select an option";
// If no selection, display the placeholder
if (this.collection.getValue(true).length === 0) {
this.valueDisplay.setAttribute("data-content", placeholder);
}
}
initializePositionedElement() {
if (this.content && this.trigger && !this.positionedElement) {
// Extract position config from content attributes
const side = this.content.getAttribute("data-side") || "bottom";
// Get portal container if specified
let portalContainer = null;
if (this.portalContainer) {
portalContainer = document.querySelector(this.portalContainer);
}
// Create positioned element with modular architecture
this.positionedElement = new PositionedElement(this.content, this.el, {
placement: side,
alignment: "start",
sideOffset: 4,
flip: true,
usePortal: this.usePortal,
portalContainer: portalContainer || document.body,
trapFocus: false,
onOutsideClick: () => this.transition("close"),
});
}
}
// State machine handlers
onClosedEnter() {
// Update hidden input(s)
this.syncHiddenInputs();
this.pushEvent("closed");
}
onOpenEnter() {
// Initialize positioned element
this.initializePositionedElement();
// Activate positioned element
if (this.positionedElement) {
this.positionedElement.activate();
}
// Highlight first selected item or first item
this.highlightFirstSelectedOrFirstItem();
this.pushEvent("opened");
}
onOpenExit() {
// Deactivate positioned element
if (this.positionedElement) {
this.positionedElement.deactivate();
}
}
// Item management
selectValue(value) {
const collectionItem = this.collection.getItemByValue(value);
if (!collectionItem) return;
// Toggle item selection
this.collection.select(collectionItem);
// Update value display
this.updateValueDisplay();
// Close dropdown if single select
if (!this.multiple) {
this.transition("select");
}
// Emit event with current value
const selectedValue = this.collection.getValue();
this.pushEvent("value-changed", { value: selectedValue });
}
handleItemFocus(item) {
const collectionItem = this.collection.getItemByInstance(item);
if (!collectionItem) return;
this.collection.focus(collectionItem);
}
updateValueDisplay() {
if (!this.valueDisplay) return;
const selectedValues = this.collection.getValue(true);
const placeholder =
this.valueDisplay.getAttribute("data-placeholder") || "Select an option";
if (selectedValues.length === 0) {
// No selection, show placeholder
this.valueDisplay.setAttribute("data-content", placeholder);
} else if (this.multiple) {
// Multiple selection
if (selectedValues.length === 1) {
// Get the label from the selected item
const selectedItem = this.collection.getItemByValue(selectedValues[0]);
this.valueDisplay.setAttribute(
"data-content",
selectedItem.instance.label,
);
} else {
// Show count for multiple selections
this.valueDisplay.setAttribute(
"data-content",
`${selectedValues.length} items selected`,
);
}
} else {
// Single selection - get label from the selected item
const selectedItem = this.collection.getItemByValue(selectedValues[0]);
this.valueDisplay.setAttribute(
"data-content",
selectedItem.instance.label,
);
}
}
// Navigation methods
navigateItem(direction) {
// Check if we have an active highlighted item
let currentItem = this.collection.focusedItem;
// If not, use the first selected item or null
if (!currentItem) {
currentItem =
this.collection.getValue(true).length > 0
? this.collection.getItemByValue(this.collection.getValue(true)[0])
: null;
}
// Get target item using collection's navigation methods
const targetItem = this.collection.getItem(direction, currentItem);
if (targetItem) {
this.collection.focus(targetItem);
}
}
highlightFirstSelectedOrFirstItem() {
// Try to highlight the first selected item
const selectedValue = this.collection.getValue();
const selectedItem =
this.collection.getItemByValue(selectedValue) ||
this.collection.getItem("first");
if (selectedItem) {
this.collection.focus(selectedItem);
}
}
// Form integration
syncHiddenInputs() {
// Get the selected values
const values = this.collection.getValue(true);
const name = this.options.name || "";
// Remove existing hidden inputs
const existingInputs = this.el.querySelectorAll("input[type='hidden']");
existingInputs.forEach((input) => input.remove());
// Create new hidden inputs
if (this.multiple) {
// Multiple select - create multiple inputs with array notation
const inputName = name ? `${name}[]` : "";
values.forEach((value) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = inputName;
input.value = value;
this.el.appendChild(input);
});
} else if (values.length > 0) {
// Single select - create one input
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = values[0];
this.el.appendChild(input);
}
}
// Cleanup
beforeDestroy() {
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
// Clean up item instances
this.collection.each((item) => {
if (typeof item.destroy === "function") {
item.destroy();
}
});
this.collection = null;
}
}
// Register the component
SaladUI.register("select", SelectComponent);
export default SelectComponent;
+256
View File
@@ -0,0 +1,256 @@
// saladui/components/slider.js
import Component from "../core/component";
import SaladUI from "../index";
class SliderComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Get slider elements
this.track = this.getPart("track");
this.range = this.getPart("range");
this.thumb = this.getPart("thumb");
// Initialize values
this.parseValues();
// Add drag handling
this.isDragging = false;
this.setupDragHandling();
// Set initial position
this.updatePosition();
}
parseValues() {
// Get values from options with defaults
this.min = parseFloat(this.options.min || 0);
this.max = parseFloat(this.options.max || 100);
this.step = parseFloat(this.options.step || 1);
this.disabled = !!this.options.disabled;
// Get value from data attribute, fallback to defaultValue from options, then to min
const dataValue = this.el.dataset.value;
const defaultValue = this.options.defaultValue;
this.value = parseFloat(
dataValue !== undefined && dataValue !== null
? dataValue
: (defaultValue !== undefined ? defaultValue : this.min),
);
// Clamp value to min/max
this.value = Math.max(this.min, Math.min(this.max, this.value));
}
getComponentConfig() {
return {
stateMachine: {
idle: {
enter: "onIdleEnter",
transitions: {
drag: "dragging",
},
},
dragging: {
enter: "onDraggingEnter",
exit: "onDraggingExit",
transitions: {
end: "idle",
},
},
},
events: {
idle: {
keyMap: {
ArrowLeft: () => this.decrementValue(),
ArrowRight: () => this.incrementValue(),
ArrowDown: () => this.decrementValue(),
ArrowUp: () => this.incrementValue(),
Home: () => this.setValueAndUpdate(this.min),
End: () => this.setValueAndUpdate(this.max),
},
},
dragging: {
// These are handled by event listeners
},
},
ariaConfig: {
root: {
all: {
role: "slider",
valuemin: () => this.min?.toString(),
valuemax: () => this.max?.toString(),
valuenow: () => this.value?.toString(),
valuetext: () => this.value?.toString(),
orientation: "horizontal",
disabled: () => (this.disabled ? "true" : null),
},
},
},
};
}
setupDragHandling() {
// Set up event handlers with proper binding
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
// Mouse events
this.el.addEventListener("mousedown", this.onPointerDown);
// Touch events
this.el.addEventListener("touchstart", this.onPointerDown, {
passive: false,
});
}
onIdleEnter() {
// Set proper tabindex when component is idle (not being dragged)
this.el.setAttribute("tabindex", "0");
}
onDraggingEnter() {
// Add global event listeners
document.addEventListener("mousemove", this.onPointerMove);
document.addEventListener("touchmove", this.onPointerMove, {
passive: false,
});
document.addEventListener("mouseup", this.onPointerUp);
document.addEventListener("touchend", this.onPointerUp);
}
onDraggingExit() {
// Remove global event listeners
document.removeEventListener("mousemove", this.onPointerMove);
document.removeEventListener("touchmove", this.onPointerMove);
document.removeEventListener("mouseup", this.onPointerUp);
document.removeEventListener("touchend", this.onPointerUp);
}
onPointerDown(event) {
// Skip if disabled
if (this.disabled) return;
// Prevent default to avoid text selection during drag
event.preventDefault();
// Handle mousedown or touchstart
this.transition("drag");
// Update value based on pointer position
this.updateValueFromPointer(event);
}
onPointerMove(event) {
// Prevent default to avoid scrolling during drag
event.preventDefault();
// Update value based on pointer position
this.updateValueFromPointer(event);
}
onPointerUp() {
// End dragging
this.transition("end");
// Notify of value change
this.pushEvent("value-changed", { value: this.value });
}
updateValueFromPointer(event) {
// Get coordinates depending on event type
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
// Get track bounds
const trackRect = this.track.getBoundingClientRect();
// Calculate percentage within track
let percentage = Math.max(
0,
Math.min(1, (clientX - trackRect.left) / trackRect.width),
);
// Calculate value based on percentage
const rawValue = this.min + percentage * (this.max - this.min);
// Snap to step
const steppedValue = Math.round(rawValue / this.step) * this.step;
// Set and update
this.setValueAndUpdate(steppedValue);
}
incrementValue() {
this.setValueAndUpdate(Math.min(this.max, this.value + this.step));
this.pushEvent("value-changed", { value: this.value });
}
decrementValue() {
this.setValueAndUpdate(Math.max(this.min, this.value - this.step));
this.pushEvent("value-changed", { value: this.value });
}
setValueAndUpdate(newValue) {
// Ensure value is within bounds
this.value = Math.max(this.min, Math.min(this.max, newValue));
// Update visual position
this.updatePosition();
// Update ARIA attributes
this.el.setAttribute("aria-valuenow", this.value.toString());
this.el.setAttribute("aria-valuetext", this.value.toString());
}
updatePosition() {
// Calculate percentage for positioning
const percentage = ((this.value - this.min) / (this.max - this.min)) * 100;
// Update range width
this.range.style.width = `${percentage}%`;
// Get track and thumb dimensions
const trackRect = this.track.getBoundingClientRect();
const thumbRect = this.thumb.getBoundingClientRect();
// Calculate the percentage offset needed to keep thumb fully within track
// This accounts for the thumb's width relative to the track
const thumbHalfWidthPercentage =
(thumbRect.width / 2 / trackRect.width) * 100;
// Constrain the thumb position to keep it fully inside the track
const thumbPercentage = Math.max(
thumbHalfWidthPercentage,
Math.min(100 - thumbHalfWidthPercentage, percentage),
);
// Update thumb position
this.thumb.style.left = `${thumbPercentage}%`;
this.thumb.style.transform = "translateX(-50%)";
}
// Handle direct server-side commands
handleCommand(command, params) {
if (command === "setValue") {
this.setValueAndUpdate(parseFloat(params.value));
return true;
}
return super.handleCommand(command, params);
}
// Clean up
beforeDestroy() {
document.removeEventListener("mousemove", this.onPointerMove);
document.removeEventListener("touchmove", this.onPointerMove);
document.removeEventListener("mouseup", this.onPointerUp);
document.removeEventListener("touchend", this.onPointerUp);
// Remove local event listeners
this.el.removeEventListener("mousedown", this.onPointerDown);
this.el.removeEventListener("touchstart", this.onPointerDown);
}
}
// Register component
SaladUI.register("slider", SliderComponent);
export default SliderComponent;
+112
View File
@@ -0,0 +1,112 @@
// saladui/components/switch.js
import Component from "../core/component";
import SaladUI from "../index";
class SwitchComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize state based on checked attribute or data-state
this.initialState = this.el.getAttribute("data-state");
}
getComponentConfig() {
return {
stateMachine: {
checked: {
enter: "onCheckedEnter",
transitions: {
toggle: "unchecked",
},
},
unchecked: {
enter: "onUncheckedEnter",
transitions: {
toggle: "checked",
},
},
},
events: {
checked: {
mouseMap: {
root: {
click: "toggleSwitch",
},
},
keyMap: {
" ": "toggleSwitch",
Enter: "toggleSwitch",
},
},
unchecked: {
mouseMap: {
root: {
click: "toggleSwitch",
},
},
keyMap: {
" ": "toggleSwitch",
Enter: "toggleSwitch",
},
},
},
ariaConfig: {
root: {
all: {
role: "switch",
},
checked: {
checked: "true",
},
unchecked: {
checked: "false",
},
},
},
};
}
toggleSwitch(e) {
if (this.disabled) return;
this.transition("toggle");
// prevent click event handler from handling twice after the first one toggle the state
// the second one reverse state immediately
e.stopImmediatePropagation();
}
setupComponentEvents() {
// Set up keyboard navigation
this.el.setAttribute(
"tabindex",
this.el.getAttribute("disabled") === "true" ? "-1" : "0",
);
this.config.preventDefaultKeys = [" ", "Enter"];
}
onCheckedEnter(e) {
// Update hidden checkbox input
const checkbox = this.el.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = true;
}
// Notify of value change
this.pushEvent("checked-changed", { value: true });
}
onUncheckedEnter(e) {
// Update hidden checkbox input
const checkbox = this.el.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = false;
}
// Notify of value change
this.pushEvent("checked-changed", { value: false });
}
}
// Register the component
SaladUI.register("switch", SwitchComponent);
export default SwitchComponent;
+176
View File
@@ -0,0 +1,176 @@
// saladui/components/tabs.js
import Component from "../core/component";
import SaladUI from "../index";
import Collection from "../core/collection";
class TabsComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.list = this.getPart("list");
this.triggers = this.getAllParts("trigger");
this.contents = this.getAllParts("content");
// Set keyboard navigation defaults
this.config.preventDefaultKeys = [
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"Enter",
" ",
];
// Initialize tabs
this.initialize();
}
initialize() {
// Initialize collection manager for tabs
this.collection = new Collection({
type: "single",
defaultValue: this.options.defaultValue,
value: this.options.value,
getItemValue: (item) => item.getAttribute("data-value"),
isItemDisabled: (item) => item.getAttribute("data-disabled") === "true",
});
// Register triggers with collection manager
this.triggers.forEach((trigger) => this.collection.add(trigger));
// Setup accessibility attributes
this.setupAriaAttributes();
// Select first tab if none selected
if (!this.collection.getValue() && this.triggers.length > 0) {
const firstTrigger = this.collection.getItem("first");
if (firstTrigger) this.collection.select(firstTrigger);
}
// Initial UI update
this.updateActiveTab();
}
getComponentConfig() {
return {
stateMachine: {
idle: {
transitions: { select: "idle" },
},
},
events: {
idle: {
keyMap: {
ArrowLeft: () => this.navigateTab("prev"),
ArrowRight: () => this.navigateTab("next"),
Home: () => this.navigateTab("first"),
End: () => this.navigateTab("last"),
},
mouseMap: {
trigger: { click: (event) => this.handleTriggerClick(event) },
},
},
},
ariaConfig: {
list: {
all: { role: "tablist" },
},
trigger: {
all: {
role: "tab",
controls: (el) =>
`${this.el.id}-content-${el.getAttribute("data-value")}`,
},
},
content: {
all: {
role: "tabpanel",
tabindex: "0",
},
},
},
};
}
setupAriaAttributes() {
// Set IDs and ARIA attributes for triggers
this.triggers.forEach((trigger) => {
const value = trigger.getAttribute("data-value");
if (!trigger.id) trigger.id = `${this.el.id}-trigger-${value}`;
});
// Set IDs and ARIA attributes for content panels
this.contents.forEach((content) => {
const value = content.getAttribute("data-value");
if (!content.id) content.id = `${this.el.id}-content-${value}`;
content.setAttribute("aria-labelledby", `${this.el.id}-trigger-${value}`);
});
}
handleTriggerClick(event) {
const trigger = event.currentTarget;
if (trigger.getAttribute("data-disabled") === "true") return;
this.selectTab(trigger.getAttribute("data-value"));
}
selectTab(value) {
// Find the trigger item
const triggerItem = this.collection.getItemByValue(value);
if (!triggerItem || this.collection.isValueSelected(value)) return;
// Select the tab
this.collection.select(triggerItem);
this.updateActiveTab();
// Focus the selected trigger
triggerItem.instance.focus();
// Emit event
this.pushEvent("tab-changed", { value: value, tab: value });
}
updateActiveTab() {
const selectedValue = this.collection.getValue();
// Update triggers
this.triggers.forEach((trigger) => {
const value = trigger.getAttribute("data-value");
const isActive = value === selectedValue;
trigger.setAttribute("data-state", isActive ? "active" : "inactive");
trigger.setAttribute("aria-selected", isActive.toString());
trigger.tabIndex = isActive ? 0 : -1;
});
// Update content panels
this.contents.forEach((content) => {
const value = content.getAttribute("data-value");
const isActive = value === selectedValue;
content.setAttribute("data-state", isActive ? "active" : "inactive");
content.hidden = !isActive;
});
}
navigateTab(direction) {
const currentItem = this.collection.getItemByValue(
this.collection.getValue(),
);
const nextItem = this.collection.getItem(direction, currentItem);
if (nextItem) this.selectTab(nextItem.value);
}
// Cleanup
destroy() {
this.collection = null;
super.destroy();
}
}
// Register the component
SaladUI.register("tabs", TabsComponent);
export default TabsComponent;
+201
View File
@@ -0,0 +1,201 @@
import Component from "../core/component";
import SaladUI from "../index";
import PositionedElement from "../core/positioned-element";
// Define constants at the module level outside the class
const DEFAULT_POSITION_CONFIG = {
placement: "top",
alignment: "center",
sideOffset: 8,
alignOffset: 0,
};
const DEFAULT_TIMING_CONFIG = {
openDelay: 150,
closeDelay: 100,
};
class TooltipComponent extends Component {
constructor(el, hookContext) {
super(el, { hookContext });
// Initialize core properties
this.trigger =
this.getPart("trigger") || this.el.querySelector(":first-child");
this.content = this.getPart("content");
// Set config from options with fallbacks to defaults
this.config.openDelay =
this.options.openDelay || DEFAULT_TIMING_CONFIG.openDelay;
this.config.closeDelay =
this.options.closeDelay || DEFAULT_TIMING_CONFIG.closeDelay;
// Track timer IDs for delayed open/close
this.openTimer = null;
this.closeTimer = null;
}
getComponentConfig() {
return {
stateMachine: {
closed: {
enter: "onClosedEnter",
transitions: {
open: "open",
},
},
open: {
enter: "onOpenEnter",
exit: "onOpenExit",
transitions: {
close: "closed",
},
},
},
events: {
closed: {
mouseMap: {
trigger: {
mouseenter: "delayOpen",
focus: "delayOpen",
},
},
},
open: {
mouseMap: {
trigger: {
mouseleave: "delayClose",
blur: "delayClose",
},
content: {
mouseenter: "clearTimers",
mouseleave: "delayClose",
},
},
},
},
hiddenConfig: {
closed: {
content: true,
},
open: {
content: false,
},
},
ariaConfig: {
trigger: {
all: {
describedby: () => this.getPartId("content"),
},
},
content: {
all: {
role: "tooltip",
},
},
},
};
}
// Generic methods for delayed state transitions
delayOpen() {
this.clearTimers();
this.openTimer = setTimeout(() => {
this.transition("open");
}, this.config.openDelay);
}
delayClose() {
this.clearTimers();
this.closeTimer = setTimeout(() => {
this.transition("close");
}, this.config.closeDelay);
}
clearTimers() {
if (this.openTimer) {
clearTimeout(this.openTimer);
this.openTimer = null;
}
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
}
// Initialize the positioned element
initializePositionedElement() {
if (this.positionedElement) return;
if (!this.trigger || !this.content) return;
// Get positioning configuration from content attributes with fallbacks to defaults
const positionConfig = {
placement:
this.content.getAttribute("data-side") ||
DEFAULT_POSITION_CONFIG.placement,
alignment:
this.content.getAttribute("data-align") ||
DEFAULT_POSITION_CONFIG.alignment,
sideOffset:
parseInt(this.content.getAttribute("data-side-offset"), 10) ||
DEFAULT_POSITION_CONFIG.sideOffset,
alignOffset:
parseInt(this.content.getAttribute("data-align-offset"), 10) ||
DEFAULT_POSITION_CONFIG.alignOffset,
flip: true,
usePortal: false,
trapFocus: false,
};
// Create positioned element
this.positionedElement = new PositionedElement(
this.content,
this.trigger,
positionConfig,
);
}
// State machine handlers
onOpenEnter() {
// Initialize positioned element if needed
this.initializePositionedElement();
// Activate positioned element
if (this.positionedElement) {
this.positionedElement.activate();
}
// Notify the server of the state change
this.pushEvent("opened");
}
onOpenExit() {
// Destroy the positioned element because there are too many tooltips, if we keep them all, it will costs more memory
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
}
onClosedEnter() {
// Notify the server of the state change
this.pushEvent("closed");
}
beforeDestroy() {
this.clearTimers();
// Clean up the positioned element if it exists
if (this.positionedElement) {
this.positionedElement.destroy();
this.positionedElement = null;
}
}
}
// Register the component
SaladUI.register("tooltip", TooltipComponent);
export default TooltipComponent;
+121
View File
@@ -0,0 +1,121 @@
// saladui/core/click-outside.js
/**
* ClickOutsideMonitor utility for SaladUI components
* Detects clicks outside of specified elements and triggers a callback
*/
class ClickOutsideMonitor {
/**
* Create a click outside monitor
*
* @param {HTMLElement|HTMLElement[]} elements - Element(s) to monitor clicks outside of
* @param {Function} callback - Function to call when click outside is detected
* @param {Object} options - Additional options
*/
constructor(elements, callback, options = {}) {
// Normalize elements to an array
this.elements = Array.isArray(elements) ? elements : [elements];
this.callback = callback;
this.options = {
// Whether to also monitor touchend events (for mobile)
trackTouch: true,
// Optional filter function to determine if a click should trigger the callback
filter: null,
...options,
};
this.active = false;
// Bind methods to maintain correct this context
this.handleClick = this.handleClick.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
}
/**
* Start monitoring clicks outside the element(s)
*/
start() {
if (this.active) return;
// Add document-level event listeners
document.addEventListener("click", this.handleClick);
if (this.options.trackTouch) {
document.addEventListener("touchend", this.handleTouchEnd);
}
this.active = true;
}
/**
* Stop monitoring clicks
*/
stop() {
if (!this.active) return;
// Remove document-level event listeners
document.removeEventListener("click", this.handleClick);
if (this.options.trackTouch) {
document.removeEventListener("touchend", this.handleTouchEnd);
}
this.active = false;
}
/**
* Handle click events
*/
handleClick(event) {
this.checkOutsideClick(event);
}
/**
* Handle touchend events
*/
handleTouchEnd(event) {
this.checkOutsideClick(event);
}
/**
* Check if click/touch was outside monitored elements
*/
checkOutsideClick(event) {
// Skip if not active or no callback
if (!this.active || !this.callback) return;
// Apply custom filter if provided
if (this.options.filter && !this.options.filter(event)) {
return;
}
// Get the event target
const target = event.target;
// Check if click was outside all monitored elements
const isOutside = !this.elements.some((element) => {
return element && (element === target || element.contains(target));
});
// If click was outside, call the callback
if (isOutside) {
this.callback(event);
}
}
/**
* Update the monitored elements
*/
updateElements(elements) {
this.elements = Array.isArray(elements) ? elements : [elements];
}
/**
* Clean up all references
*/
destroy() {
this.stop();
this.elements = null;
this.callback = null;
this.options = null;
}
}
export default ClickOutsideMonitor;
+337
View File
@@ -0,0 +1,337 @@
// saladui/core/collection-manager.js
/**
* Collection utility for SaladUI components
* Manages collections of items with focus, highlight, and selection states
*/
class Collection {
/**
* Create a collection manager
*
* @param {Object} options - Configuration options
* @param {string} options.type - Collection type: 'single' or 'multiple'
* @param {Array|*} options.defaultValue - Default value(s) if none provided
* @param {Array|*} options.value - Initial value(s) for the collection
* @param {function} options.getItemValue - Function to get value from an item instance
* @param {function} options.isItemDisabled - Function to check if an item is disabled
*/
constructor(options = {}) {
this.options = {
type: "single",
defaultValue: null,
value: null,
getItemValue: (item) => item.value,
isItemDisabled: (item) => item.disabled,
...options,
};
// Initialize collection
this.items = [];
this.focusedItem = null;
// Initialize values
this.values = [];
if (this.options.value !== null && this.options.value !== undefined) {
this.setValues(this.options.value);
} else if (
this.options.defaultValue !== null &&
this.options.defaultValue !== undefined
) {
this.setValues(this.options.defaultValue);
}
}
/**
* Reset the collection
*/
reset() {
this.items = [];
this.values = Array.isArray(this.options.defaultValue)
? [...this.options.defaultValue]
: this.options.defaultValue
? [this.options.defaultValue]
: [];
this.focusedItem = null;
}
/**
* Set collection values
*
* @param {Array|*} values - Value or array of values
*/
setValues(values) {
if (values === undefined || values === null) {
this.values = Array.isArray(this.options.defaultValue)
? [...this.options.defaultValue]
: this.options.defaultValue
? [this.options.defaultValue]
: [];
return;
}
if (this.options.type === "single") {
this.values = Array.isArray(values) ? [values[0]] : [values];
} else {
this.values = Array.isArray(values) ? [...values] : [values];
}
// Update selected state for all items
this.updateSelectedStates();
}
/**
* Get the selected value(s)
* For 'multiple' type collections, returns an array of all selected values
* For 'single' type collections, returns just the first selected value (or null if none)
*
* @param {boolean} asArray - Force return value to be an array even for single selection
* @returns {*|Array} Selected value(s)
*/
getValue(asArray = false) {
if (this.options.type === "multiple" || asArray) {
return [...this.values];
}
return this.values.length > 0 ? this.values[0] : null;
}
/**
* Add an item to the collection
*
* @param {Object} item - Item instance to add
* @param {*} value - Item value
* @returns {Object} The collection item wrapper
*/
add(item) {
const itemValue = this.options.getItemValue(item);
const isSelected = this.values.includes(itemValue);
const collectionItem = {
instance: item,
value: itemValue,
focused: false,
selected: isSelected,
};
this.items.push(collectionItem);
// Initialize item state
if (isSelected && typeof item.handleEvent === "function") {
item.handleEvent("select");
}
return collectionItem;
}
/**
* Remove an item from the collection
*
* @param {Object} itemInstance - Item instance to remove
*/
remove(itemInstance) {
const index = this.items.findIndex(
(item) => item.instance === itemInstance,
);
if (index >= 0) {
const [removedItem] = this.items.splice(index, 1);
if (this.focusedItem === removedItem) {
this.focusedItem = null;
}
// Remove from values if selected
if (removedItem.selected) {
this.values = this.values.filter(
(value) => value !== removedItem.value,
);
}
}
}
/**
* Clear all items from the collection
*/
clear() {
this.items = [];
this.focusedItem = null;
}
/**
* Get item by its instance
*
* @param {Object} itemInstance - Item instance to find
* @returns {Object} Collection item or null if not found
*/
getItemByInstance(itemInstance) {
return this.items.find((item) => item.instance === itemInstance) || null;
}
/**
* Get item by its value
*
* @param {*} value - Value to find
* @returns {Object} Collection item or null if not found
*/
getItemByValue(value) {
return this.items.find((item) => item.value === value) || null;
}
/**
* Get an item from the collection based on navigation direction
*
* @param {string} direction - Navigation direction: 'first', 'last', 'next', or 'prev'/'previous'
* @param {Object} referenceItem - Reference item for 'next' and 'prev' directions (optional)
* @param {boolean} loop - Whether to loop when reaching boundaries (optional, default: true)
* @returns {Object} The requested item or null if not found
*/
getItem(direction, referenceItem = null, loop = true) {
const enabledItems = this.items.filter(
(item) => !this.options.isItemDisabled(item.instance),
);
if (enabledItems.length === 0) return null;
switch (direction) {
case "first":
return enabledItems[0];
case "last":
return enabledItems[enabledItems.length - 1];
case "next":
if (!referenceItem) return this.getItem("first");
const nextIndex = enabledItems.indexOf(referenceItem) + 1;
if (nextIndex >= enabledItems.length) {
return loop ? enabledItems[0] : null;
}
return enabledItems[nextIndex];
case "prev":
case "previous":
if (!referenceItem) return this.getItem("last");
const currentIndex = enabledItems.indexOf(referenceItem);
if (currentIndex === -1) return enabledItems[enabledItems.length - 1];
const prevIndex = currentIndex - 1;
if (prevIndex < 0) {
return loop ? enabledItems[enabledItems.length - 1] : null;
}
return enabledItems[prevIndex];
default:
return null;
}
}
/**
* Focus an item
*
* @param {Object} item - Item to focus
* @returns {boolean} Whether the operation was successful
*/
focus(item) {
if (!item || this.options.isItemDisabled(item.instance)) return false;
// Clear previous focus
if (this.focusedItem) {
this.focusedItem.focused = false;
if (typeof this.focusedItem.instance.handleEvent === "function") {
this.focusedItem.instance.handleEvent("blur");
}
}
// Set new focus
this.focusedItem = item;
item.focused = true;
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("focus") !== false;
}
return true;
}
/**
* Select an item
*
* @param {Object} item - Item to select
* @returns {boolean} Whether the operation was successful
*/
select(item) {
if (!item || this.options.isItemDisabled(item.instance)) return false;
const isMultiple = this.options.type === "multiple";
// If it's already selected in single mode, do nothing
if (!isMultiple && item.selected && this.values.length === 1) {
return true;
}
// For single selection, clear all other selections
if (!isMultiple) {
this.items.forEach((existingItem) => {
if (existingItem !== item && existingItem.selected) {
existingItem.selected = false;
if (typeof existingItem.instance.handleEvent === "function") {
existingItem.instance.handleEvent("unselect");
}
}
});
this.values = [];
}
// Toggle selection for the item
if (item.selected) {
// Unselect the item
item.selected = false;
this.values = this.values.filter((value) => value !== item.value);
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("unselect") !== false;
}
} else {
// Select the item
item.selected = true;
this.values.push(item.value);
if (typeof item.instance.handleEvent === "function") {
return item.instance.handleEvent("select") !== false;
}
}
return true;
}
/**
* Update all item selected states based on values
*/
updateSelectedStates() {
this.items.forEach((item) => {
const shouldBeSelected = this.values.includes(item.value);
if (item.selected !== shouldBeSelected) {
item.selected = shouldBeSelected;
if (typeof item.instance.handleEvent === "function") {
item.instance.handleEvent(shouldBeSelected ? "select" : "unselect");
}
}
});
}
/**
* Check if a value is selected
*
* @param {*} value - Value to check
* @returns {boolean} Whether the value is selected
*/
isValueSelected(value) {
return this.values.includes(value);
}
each(callback) {
this.items.forEach((item) => callback(item.instance));
}
}
export default Collection;
+548
View File
@@ -0,0 +1,548 @@
// saladui/core/component.js
/**
* Base Component class for SaladUI framework
* Provides state management, event handling, and ARIA support
*/
import StateMachine from "./state-machine";
import { animateTransition, queryDOM } from "./utils";
class Component {
constructor(el, options) {
const { hookContext, initialState = "idle", ignoreItems = true } = options;
this.el = el;
this.hook = hookContext;
this.config = {
preventDefaultKeys: [],
};
this.initialState = initialState;
this.eventConfig = {};
this.componentConfig = {};
this.hiddenConfig = {};
this.ariaConfig = {};
// Initialize component
this.parseOptions();
this.disabled = !!this.options.disabled;
this.initEventMappings();
this.initConfig();
this.initStateMachine(this.componentConfig.stateMachine, this.initialState);
this.ariaManager = new AriaManager(this, this.ariaConfig);
// ignore item's part
this.allParts = this.queryParts();
if (ignoreItems) {
this.allParts = this.allParts.filter(
(element) =>
!element.dataset.part.startsWith("item") &&
!element.dataset.part.endsWith("-item"),
);
}
this.updateUI();
this.updatePartsVisibility();
// Map to store event handlers for each part element
this.partMouseEventHandlers = new Map();
this.keyEventHandlers = new Map();
}
parseOptions() {
try {
const optionsString = this.el.getAttribute("data-options");
this.options = optionsString ? JSON.parse(optionsString) : {};
this.initialState =
this.el.getAttribute("data-state") || this.initialState;
} catch (error) {
console.error("SaladUI: Error parsing component options:", error);
this.options = {};
}
}
queryParts() {
return queryDOM(this.el, (node) => {
if (!node.dataset?.part) return 0;
if (node.getAttribute("phx-hook") != null) return -1;
return 1;
}).concat([this.el]);
}
initEventMappings() {
this.onClientCommand = this.onClientCommand.bind(this);
try {
const mappingsString = this.el.getAttribute("data-event-mappings");
this.eventMappings = mappingsString ? JSON.parse(mappingsString) : {};
} catch (error) {
console.error("SaladUI: Error parsing event mappings:", error);
this.eventMappings = {};
}
}
/**
* Initialize component configuration
* This method should set up the componentConfig object with stateMachine, events, and ariaConfig
*/
initConfig() {
this.componentConfig = this.getComponentConfig();
// Add default configs if not provided
if (!this.componentConfig.stateMachine) {
this.componentConfig.stateMachine = {
idle: {
enter: () => {},
exit: () => {},
transitions: {},
},
};
} else {
this.componentConfig.stateMachine = this.bindStateHandlers(
this.componentConfig.stateMachine,
);
}
this.eventConfig = this.componentConfig.events || {};
this.hiddenConfig = this.componentConfig.hiddenConfig || {};
this.ariaConfig = this.componentConfig.ariaConfig || {};
}
/**
* Get component configuration
* Override in subclasses to provide component-specific configuration
* @returns {Object} Configuration object with stateMachine, events, and ariaConfig
*/
getComponentConfig() {
throw new Error("getComponentConfig() must be implemented in subclass");
}
initStateMachine(stateMachineConfig, initialState) {
this.stateMachine = new StateMachine(stateMachineConfig, initialState, {
onStateChanged: this.onStateChanged.bind(this),
});
}
// Handle client commands
onClientCommand(event) {
console.log(event);
const { command, params } = event.detail;
if (command) {
this.handleCommand(command, params);
}
}
onStateChanged(prevState, nextState, params) {
// Check if we should animate
const transitionName = `${prevState}_to_${nextState}`;
const animConfig = this.options.animations?.[transitionName];
this.updateUI();
if (!animConfig) {
// No animation, update visibility immediately
this.updatePartsVisibility(nextState);
return null; // No promise
}
// Get target element for animation
const targetElement = animConfig.target_part
? this.getPart(animConfig.target_part)
: this.el;
// Animate with the config
return animateTransition(animConfig, targetElement).then(() => {
this.updatePartsVisibility(nextState);
});
}
/**
* Process the state machine configuration to automatically bind string method references
* to instance methods for enter and exit handlers
*
* @param {Object} config - The original state machine configuration
* @returns {Object} - The processed configuration with bound enter/exit methods
*/
bindStateHandlers(stateMachineConfig) {
// Process each state
Object.keys(stateMachineConfig).forEach((stateName) => {
const stateConfig = stateMachineConfig[stateName];
["enter", "exit"].forEach((handlerName) => {
// Process handler if it's a string
if (typeof stateConfig[handlerName] === "string") {
const methodName = stateConfig[handlerName];
if (typeof this[methodName] === "function") {
stateConfig[handlerName] = this[methodName].bind(this);
} else {
console.warn(
`Method ${methodName} not found for ${handlerName} handler in state ${stateName}`,
);
}
}
});
});
return stateMachineConfig;
}
setupEvents() {
if (this.eventSetupCompleted) {
this.removeAllEvents();
}
this.el.addEventListener("salad_ui:command", this.onClientCommand);
this.el.addEventListener("click", this.handleActionClick.bind(this));
this.setupKeyEventHandlers();
this.setupMouseEventHandlers();
this.setupComponentEvents();
this.eventSetupCompleted = true;
}
/**
* Handle click events on action elements
* Transition with the action attribute value
*/
handleActionClick(event) {
const actionElement = event.target.closest("[data-action]");
if (!actionElement) return;
const action = actionElement.getAttribute("data-action");
this.transition(action, {
originalEvent: event,
target: actionElement,
});
}
setupComponentEvents() {
// Override in component subclasses
}
/**
* Set up event listeners for mouse events based on the current state
*/
setupKeyEventHandlers() {
Object.keys(this.eventConfig).forEach((stateName) => {
const stateEvents = this.eventConfig[stateName];
if (!stateEvents || !stateEvents.keyMap) return;
// Create a bound handler that will check the current state before executing
const boundHandler = (event) => {
if (stateName == "_all" || this.stateMachine.state === stateName) {
const key = event.key;
const action = stateEvents.keyMap[key];
if (action) {
this.executeHandler(action, event);
if (this.config.preventDefaultKeys.includes(key)) {
event.preventDefault();
}
}
}
};
// Get the target element for key events, if not specified, use the root element
const element = this.getPart(stateEvents.keyEventTarget) || this.el;
element.addEventListener("keydown", boundHandler);
this.keyEventHandlers.set(element, boundHandler);
});
}
/**
* Set up event listeners for mouse events based on the current state
*/
setupMouseEventHandlers() {
// Process all states for their mouse events
Object.keys(this.eventConfig).forEach((stateName) => {
const stateEvents = this.eventConfig[stateName];
if (!stateEvents || !stateEvents.mouseMap) return;
const mouseMap = stateEvents.mouseMap;
// For each part in the mouseMap
Object.keys(mouseMap).forEach((partName) => {
// Get all elements with this part name
const partElements = this.getAllParts(partName);
if (!partElements.length) return;
// For each event type on this part
Object.keys(mouseMap[partName]).forEach((eventType) => {
const handlerAction = mouseMap[partName][eventType];
// Create a bound handler that will check the current state before executing
const boundHandler = (event) => {
// Only execute the handler if we're in the correct state
const currentState = this.stateMachine.state;
if (currentState === stateName) {
this.executeHandler(handlerAction, event);
}
};
// For each element with this part
partElements.forEach((element) => {
// Add event listener directly to the part element
element.addEventListener(eventType, boundHandler);
// Store the handler reference for removal later
if (!this.partMouseEventHandlers.has(element)) {
this.partMouseEventHandlers.set(element, new Map());
}
const elementHandlers = this.partMouseEventHandlers.get(element);
if (!elementHandlers.has(eventType)) {
elementHandlers.set(eventType, []);
}
elementHandlers.get(eventType).push(boundHandler);
});
});
});
});
}
removeKeyEventHandlers() {
if (this.keyEventHandlers) {
// For each element that has event handlers
this.keyEventHandlers.forEach((handler, element) => {
element.removeEventListener("keydown", handler);
});
// Clear the map for future use
this.keyEventHandlers.clear();
}
}
/**
* Remove all active mouse event listeners
*/
removeMouseEventListeners() {
if (this.partMouseEventHandlers) {
// For each element that has event handlers
this.partMouseEventHandlers.forEach((eventHandlers, element) => {
// For each event type on this element
eventHandlers.forEach((handlers, eventType) => {
// Remove all handlers for this event type
handlers.forEach((handler) => {
element.removeEventListener(eventType, handler);
});
});
});
// Clear the map for future use
this.partMouseEventHandlers.clear();
}
}
/**
* Execute a handler from a mouseMap or keyMap
*/
executeHandler(handler, event, targetElement) {
if (typeof handler === "function") {
handler.call(this, event);
} else if (typeof handler === "string") {
if (typeof this[handler] === "function") {
this[handler](event);
} else {
// If it's not a method name, treat as transition
this.transition(handler, {
originalEvent: event,
target: targetElement,
});
}
}
}
/**
* Transition to a new state - delegates to the state machine
*/
transition(event, params = {}) {
return this.stateMachine.transition(event, params);
}
/**
* Update UI to reflect current state
* @param {Object} params - Optional parameters from state transition
*/
updateUI(params = {}) {
const currentState = this.stateMachine.state;
// Update data-state attributes on all parts and root element
this.allParts.forEach((el) => el.setAttribute("data-state", currentState));
this.el.setAttribute("data-state", currentState);
// Apply ARIA attributes
this.ariaManager.applyAriaAttributes(currentState);
}
/**
* Update part visibility based on current state configuration
*/
updatePartsVisibility() {
const currentState = this.stateMachine.state;
const stateVisibility = this.hiddenConfig[currentState];
if (!stateVisibility) return;
Object.entries(stateVisibility).forEach(([partName, hidden]) => {
const partElements = this.getAllParts(partName);
partElements.forEach((element) => {
if (element) {
element.hidden = hidden;
}
});
});
}
getPart(name) {
return this.allParts.find((part) => part.dataset.part === name);
}
getAllParts(name) {
return this.allParts.filter((part) => part.dataset.part === name);
}
getPartId(partName) {
const part = this.getPart(partName);
if (!part) return null;
if (!part.id) {
part.id = `${this.el.id}-${partName}`;
}
return part.id;
}
// Push event to server (for frameworks like Phoenix LiveView)
pushEvent(clientEvent, payload = {}, context) {
if (!this.hook || !this.hook.pushEventTo) return;
const eventHandler = this.eventMappings[clientEvent];
const el = context || this.el;
if (eventHandler) {
if (typeof eventHandler === "string") {
const fullPayload = {
...payload,
componentId: el.id,
component: el.getAttribute("data-component"),
};
this.hook.pushEventTo(this.el, eventHandler, fullPayload);
} else {
this.hook.liveSocket.execJS(this.el, JSON.stringify(eventHandler));
}
}
}
// Get current state from state machine
get state() {
return this.stateMachine.state;
}
// Get previous state from state machine
get previousState() {
return this.stateMachine.previousState;
}
removeAllEvents() {
this.el.removeEventListener("salad_ui:command", this.onClientCommand);
this.el.removeEventListener("click", this.handleActionClick);
this.removeKeyEventHandlers();
this.removeMouseEventListeners();
}
// Cleanup method to remove event listeners and references
destroy() {
// Lifecycle hook before destruction
this.beforeDestroy();
// Remove event listeners
this.removeAllEvents();
this.ariaManager = null;
// Allow garbage collection
this.stateMachine = null;
this.el = null;
this.hook = null;
this.options = null;
this.componentConfig = null;
}
// Lifecycle hooks
beforeDestroy() {}
// Alias for transition()
handleCommand(command, params = {}) {
return this.transition(command, params);
}
// Alias for transition()
trigger(event, params = {}) {
return this.transition(event, params);
}
}
/**
* AriaManager class for handling accessibility attributes
*/
class AriaManager {
constructor(component, ariaConfig) {
this.component = component;
this.ariaConfig = ariaConfig || {};
}
applyAriaAttributes(currentState) {
if (!this.ariaConfig) return;
Object.entries(this.ariaConfig).forEach(([partName, states]) => {
// Get all elements with this data-part, not just the first one
const parts = this.component.getAllParts(partName);
if (!parts || parts.length === 0) return;
// Apply attributes to all matching elements
parts.forEach((part, index) => {
// Set ID if not already defined
if (!part.id) {
part.id = `${this.component.el.id}-${partName}${parts.length > 1 ? `-${index}` : ""}`;
}
this.applyGlobalAriaAttributes(part, states);
this.applyStateSpecificAriaAttributes(part, states, currentState);
});
});
}
applyGlobalAriaAttributes(part, states) {
if (!states.all) return;
Object.entries(states.all).forEach(([attr, value]) => {
this.applyAriaAttribute(part, attr, value);
});
}
applyStateSpecificAriaAttributes(part, states, currentState) {
const stateConfig = states[currentState];
if (!stateConfig) return;
Object.entries(stateConfig).forEach(([attr, value]) => {
this.applyAriaAttribute(part, attr, value);
});
}
applyAriaAttribute(part, attr, value) {
const resolvedValue =
typeof value === "function" ? value.call(this.component, part) : value;
if (resolvedValue === null || resolvedValue === undefined) return;
if (attr === "role") {
part.setAttribute("role", resolvedValue);
} else {
part.setAttribute(`aria-${attr}`, resolvedValue);
}
}
}
export default Component;
+30
View File
@@ -0,0 +1,30 @@
// saladui/core/factory.js
class ComponentRegistry {
constructor() {
this.registry = new Map();
}
register(type, ComponentClass) {
this.registry.set(type, ComponentClass);
return this;
}
create(type, el, hookContext) {
const ComponentClass = this.registry.get(type);
if (!ComponentClass) {
console.error(`Component type '${type}' not registered`);
return null;
}
const instance = new ComponentClass(el, hookContext);
// Call setupEvents after the component is fully initialized
instance.setupEvents();
return instance;
}
}
const registry = new ComponentRegistry();
export { registry };
+154
View File
@@ -0,0 +1,154 @@
// saladui/core/focus-trap.js
/**
* FocusTrap utility for SaladUI components
* Manages focus behavior for modals, popovers, and similar components
*/
class FocusTrap {
/**
* Create a focus trap for a specific element
*
* @param {HTMLElement} element - The element to trap focus within
* @param {Object} options - Focus trap options
*/
constructor(element, options = {}) {
this.element = element;
this.options = {
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
...options,
};
this.previouslyFocused = null;
this.active = false;
// Bind methods that will be used as event handlers
this.handleKeyDown = this.handleKeyDown.bind(this);
}
/**
* Activate the focus trap
*/
activate() {
if (this.active) return;
// Store currently focused element to restore later
this.previouslyFocused = document.activeElement;
this.active = true;
// Set up event listener for keyboard navigation
this.element.addEventListener("keydown", this.handleKeyDown);
// Focus the first focusable element or the element itself
this.setInitialFocus();
}
/**
* Deactivate the focus trap and restore previous focus
*/
deactivate() {
if (!this.active) return;
// Remove event listeners
this.element.removeEventListener("keydown", this.handleKeyDown);
// Restore focus if possible
if (
this.previouslyFocused &&
this.previouslyFocused.focus &&
this.isElementInViewport(this.previouslyFocused)
) {
setTimeout(() => {
this.previouslyFocused.focus();
this.previouslyFocused = null;
}, 0);
}
this.active = false;
}
/**
* Set initial focus when trap is activated
*/
setInitialFocus() {
// Find all focusable elements
const focusableElements = this.getFocusableElements();
setTimeout(() => {
if (focusableElements.length > 0) {
// Look for an element with autofocus attribute first
const autoFocusEl = this.element.querySelector("[autofocus]");
const initialFocusEl = autoFocusEl || focusableElements[0];
initialFocusEl.focus();
} else {
// If no focusable elements, make the element itself focusable
this.element.setAttribute("tabindex", "-1");
this.element.focus();
}
}, 50); // Small delay to ensure DOM is ready
}
/**
* Handle keydown events for tab trapping and escape handling
*/
handleKeyDown(event) {
// Handle Tab key for focus trapping
if (event.key === "Tab") {
const focusableElements = this.getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
// Create focus loop
if (!event.shiftKey && activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
} else if (event.shiftKey && activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
}
}
/**
* Get all focusable elements within the trap
*/
getFocusableElements() {
return Array.from(
this.element.querySelectorAll(this.options.focusableSelector),
);
}
/**
* Check if an element is currently visible in the viewport
*/
isElementInViewport(element) {
if (!element || !document.body.contains(element)) {
return false;
}
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* Clean up all references when no longer needed
*/
destroy() {
this.deactivate();
this.element = null;
this.options = null;
this.previouslyFocused = null;
}
}
export default FocusTrap;
+55
View File
@@ -0,0 +1,55 @@
// saladui/core/hook.js
import { registry } from "./factory";
const SaladUIHook = {
mounted() {
this.initComponent();
this.setupServerEvents();
},
initComponent() {
const el = this.el;
const componentType = el.getAttribute("data-component");
if (!componentType) {
console.error(
"SaladUI: Component element is missing data-component attribute",
);
return;
}
// The registry.create method will handle creating the component and calling setupEvents
this.component = registry.create(componentType, el, this);
},
setupServerEvents() {
if (!this.component) return;
this.handleEvent("saladui:command", ({ command, params = {}, target }) => {
if (target && target !== this.el.id) return;
if (this.component) {
this.component.handleCommand(command, params);
}
});
},
updated() {
if (this.component) {
this.component.destroy();
this.component = null;
this.initComponent();
// this.component.parseOptions();
// this.component.setupEvents();
// this.component.updatePartsVisibility();
// this.component.updateUI();
}
},
destroyed() {
this.component?.destroy();
this.component = null;
},
};
export { SaladUIHook };
+186
View File
@@ -0,0 +1,186 @@
// saladui/core/portal.js
/**
* Portal utility for SaladUI components
* Manages moving elements to a different DOM context (usually body)
* to avoid z-index and overflow issues
*/
class Portal {
// Static storage for element metadata
static portalRegistry = new WeakMap();
/**
* Move an element to a portal container
*
* @param {HTMLElement} element - Element to move to the portal
* @param {HTMLElement} container - Container to move the element to (default: document.body)
* @returns {boolean} Success status
*/
static move(element, container = document.body) {
if (!element) return false;
// Store original information for restoration
const originalData = {
parent: element.parentElement,
styles: {
position: element.style.position,
top: element.style.top,
left: element.style.left,
zIndex: element.style.zIndex,
margin: element.style.margin,
transform: element.style.transform,
pointerEvents: element.style.pointerEvents,
},
inPortal: true,
};
// Store the metadata in our registry
this.portalRegistry.set(element, originalData);
// Move the element to the portal container
container.appendChild(element);
// Apply portal styles
element.style.position = "absolute";
element.style.zIndex = "9999";
return true;
}
/**
* Restore an element from portal to its original position
*
* @param {HTMLElement} element - Element to restore
* @returns {boolean} Success status
*/
static restore(element) {
if (!element) return false;
// Get the original data from our registry
const originalData = this.portalRegistry.get(element);
if (!originalData || !originalData.parent) {
return false;
}
try {
// Move back to original parent
originalData.parent.appendChild(element);
// Restore original styles
const styles = originalData.styles;
element.style.position = styles.position || "";
element.style.top = styles.top || "";
element.style.left = styles.left || "";
element.style.zIndex = styles.zIndex || "";
element.style.margin = styles.margin || "";
element.style.transform = styles.transform || "";
element.style.pointerEvents = styles.pointerEvents || "";
// Update portal state
originalData.inPortal = false;
return true;
} catch (error) {
console.warn("SaladUI Portal: Failed to restore element", error);
return false;
}
}
/**
* Check if an element is currently in a portal
*
* @param {HTMLElement} element - Element to check
* @returns {boolean} Whether the element is in a portal
*/
static isInPortal(element) {
if (!element) return false;
const data = this.portalRegistry.get(element);
return data?.inPortal === true;
}
/**
* Setup scroll event passthrough for a portal element
* Makes the portal element transparent to pointer events except for interactive elements
*
* @param {HTMLElement} element - Portal element to set up scroll passthrough for
*/
static setupScrollPassthrough(element) {
if (!element) return;
// Get original data from registry to ensure styles are properly tracked
const originalData = this.portalRegistry.get(element);
if (originalData) {
originalData.styles.pointerEvents = element.style.pointerEvents;
}
// Make the portal element transparent to pointer events
element.style.pointerEvents = "none";
Portal.updateScrollableContainer(element, "auto");
}
static updateScrollableContainer(parentElement, pointerEvent = "") {
// Check if the current element is scrollable
function isScrollable(element) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
const isScrollableY = element.scrollHeight > element.clientHeight;
const isScrollableX = element.scrollWidth > element.clientWidth;
return (
((overflowY === "auto" ||
overflowY === "scroll" ||
overflowY === "overlay") &&
isScrollableY) ||
((overflowX === "auto" ||
overflowX === "scroll" ||
overflowX === "overlay") &&
isScrollableX)
);
}
// Recursive function to traverse DOM tree
function traverse(element) {
// Check if current element is scrollable
if (isScrollable(element)) {
// Set pointer-events to auto
element.style.pointerEvents = pointerEvent;
// Stop traversing this branch
return;
}
// Process child elements if current element isn't scrollable
for (let i = 0; i < element.children.length; i++) {
traverse(element.children[i]);
}
}
// Start traversal from parent
traverse(parentElement);
// No return value as requested
}
/**
* Clean up scroll passthrough setup
*
* @param {HTMLElement} element - Element to clean up
*/
static cleanupScrollPassthrough(element) {
if (!element) return;
// Get the original data from registry if available
const originalData = this.portalRegistry.get(element);
const originalPointerEvents = originalData?.styles?.pointerEvents || "";
// Restore pointer-events on the element
element.style.pointerEvents = originalPointerEvents;
// Reset pointer-events on all children that might have been modified
Portal.updateScrollableContainer(element, "");
}
}
export default Portal;
+353
View File
@@ -0,0 +1,353 @@
// saladui/core/positioned-element.js - Updated for fixed positioning
/**
* PositionedElement - Main positioning class that integrates all positioning utilities
*/
import Positioner from "./positioner";
import FocusTrap from "./focus-trap";
import ClickOutsideMonitor from "./click-outside";
import Portal from "./portal";
import ScrollManager from "./scroll-manager";
class PositionedElement {
/**
* Create a positioned element with full functionality
*
* @param {HTMLElement} element - Element to position
* @param {HTMLElement} reference - Reference element to position against
* @param {Object} options - Positioning options
*/
constructor(element, reference, options = {}) {
this.element = element;
this.reference = reference;
this.options = {
// Positioning options
placement: "bottom",
alignment: "center",
sideOffset: 8,
alignOffset: 0,
flip: true,
// Portal options
usePortal: false,
portalContainer: document.body,
// Focus management
trapFocus: false,
focusableSelector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
// Event handlers
onOutsideClick: null,
scrollPassThrough: false,
...options,
};
// State
this.active = false;
// Initialize sub-modules
this.initializeModules();
}
/**
* Initialize all required modules
*/
initializeModules() {
// Focus trap for keyboard navigation
this.focusTrap = new FocusTrap(this.element, {
focusableSelector: this.options.focusableSelector,
});
// Click outside detection
this.clickOutsideMonitor = this.options.onOutsideClick
? new ClickOutsideMonitor(
[this.element, this.reference],
this.options.onOutsideClick,
)
: null;
// Scroll and resize handling
this.scrollManager = new ScrollManager(() => {
this.update();
});
// Bind methods for event handlers
this.handleWheel = this.handleWheel.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
}
/**
* Activate the positioned element
*/
activate() {
if (this.active) return this;
// Move to portal if enabled
if (this.options.usePortal) {
this.moveToPortal();
}
// Calculate and apply initial position
this.calculateAndApplyPosition();
// Activate sub-modules
if (this.options.trapFocus) {
this.focusTrap.activate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.start();
}
this.scrollManager.start(this.reference, this.element);
// Add wheel and touch event handlers if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.setupScrollPassthrough();
}
// set reference width and height ass css variable
this.element.style.setProperty(
"--salad-reference-width",
this.reference.offsetWidth + "px",
);
this.element.style.setProperty(
"--salad-reference-height",
this.reference.offsetHeight + "px",
);
this.active = true;
return this;
}
/**
* Deactivate the positioned element
*/
deactivate() {
if (!this.active) return this;
// Deactivate sub-modules
if (this.options.trapFocus) {
this.focusTrap.deactivate();
}
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.stop();
}
this.scrollManager.stop();
// Clean up scroll passthrough if in portal
if (Portal.isInPortal(this.element) && this.options.scrollPassThrough) {
this.cleanupScrollPassthrough();
}
// Restore from portal if needed
if (this.inPortal) {
this.restoreFromPortal();
}
this.active = false;
return this;
}
/**
* Update position
*/
update() {
if (this.active) {
this.calculateAndApplyPosition();
}
return this;
}
/**
* Move element to portal container
*/
moveToPortal() {
if (Portal.isInPortal(this.element)) return;
const container = this.options.portalContainer || document.body;
Portal.move(this.element, container);
}
/**
* Restore element from portal
*/
restoreFromPortal() {
if (!Portal.isInPortal(this.element)) return;
Portal.restore(this.element);
}
/**
* Calculate and apply position to the element
*/
calculateAndApplyPosition() {
const position = Positioner.calculate(
this.element,
this.reference,
this.options,
);
// Apply positioning - with fixed positioning, we no longer need to
// adjust for scroll position since fixed is relative to the viewport
Positioner.applyPosition(this.element, position.x, position.y);
// Update placement attribute
this.element.setAttribute("data-placement", position.placement);
return position;
}
/**
* Set up scroll event passthrough
*/
setupScrollPassthrough() {
Portal.setupScrollPassthrough(this.element, this.options.focusableSelector);
// Add wheel and touch event handlers
this.element.addEventListener("wheel", this.handleWheel, {
passive: false,
});
this.element.addEventListener("touchstart", this.handleTouchStart, {
passive: false,
});
this.element.addEventListener("touchmove", this.handleTouchMove, {
passive: false,
});
}
/**
* Clean up scroll passthrough
*/
cleanupScrollPassthrough() {
if (!this.element) return;
// Remove wheel and touch event handlers
this.element.removeEventListener("wheel", this.handleWheel);
this.element.removeEventListener("touchstart", this.handleTouchStart);
this.element.removeEventListener("touchmove", this.handleTouchMove);
// Clean up styles
Portal.cleanupScrollPassthrough(this.element);
}
/**
* Handle wheel events for scroll passthrough
*/
handleWheel(event) {
// Let the wheel event pass through
event.stopPropagation();
}
/**
* Handle touch start for scroll passthrough
*/
handleTouchStart(event) {
// Store initial touch position
if (event.touches.length === 1) {
this.touchStartY = event.touches[0].clientY;
}
}
/**
* Handle touch move for scroll passthrough
*/
handleTouchMove(event) {
if (!this.touchStartY) return;
// Determine scroll direction
const touchY = event.touches[0].clientY;
const deltaY = this.touchStartY - touchY;
this.touchStartY = touchY;
// Find element that should receive scroll
const elementsFromPoint = document.elementsFromPoint(
event.touches[0].clientX,
event.touches[0].clientY,
);
// Find first scrollable element that is not our portal
const scrollableElement = elementsFromPoint.find((el) => {
if (el === this.element || this.element.contains(el)) return false;
const style = window.getComputedStyle(el);
return (
style.overflowY === "auto" ||
style.overflowY === "scroll" ||
el === document.documentElement
);
});
if (scrollableElement) {
// Pass scroll to found element
scrollableElement.scrollTop += deltaY;
event.preventDefault();
}
}
/**
* Update the reference element
*/
updateReference(reference) {
this.reference = reference;
// Update click outside monitor
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.updateElements([this.element, this.reference]);
}
this.update();
return this;
}
/**
* Update options
*/
updateOptions(options = {}) {
this.options = { ...this.options, ...options };
// Update focus trap options if needed
if (this.focusTrap && options.focusableSelector) {
this.focusTrap.options = {
...this.focusTrap.options,
focusableSelector: options.focusableSelector,
};
}
this.update();
return this;
}
/**
* Clean up and destroy the positioned element
*/
destroy() {
this.deactivate();
// Destroy sub-modules
this.focusTrap.destroy();
if (this.clickOutsideMonitor) {
this.clickOutsideMonitor.destroy();
}
this.scrollManager.destroy();
if (Portal.isInPortal(this.element)) {
this.element.remove();
}
// Clear references
this.element = null;
this.reference = null;
this.options = null;
this.focusTrap = null;
this.clickOutsideMonitor = null;
this.scrollManager = null;
this.touchStartY = null;
}
}
export default PositionedElement;
+238
View File
@@ -0,0 +1,238 @@
// saladui/core/positioner.js - Updated to use fixed positioning
/**
* Core Positioning utility for SaladUI components
* Handles pure positioning calculations without side effects
*/
class Positioner {
/**
* Calculate position for an element relative to a reference element
*
* @param {HTMLElement} element - The element to position
* @param {HTMLElement} reference - The reference element to position against
* @param {Object} options - Positioning options
* @returns {Object} The computed position data
*/
static calculate(element, reference, options = {}) {
const {
placement = "bottom",
alignment = "center",
container = document.body,
flip = true,
alignOffset = 0,
sideOffset = 8,
} = options;
// Get element and reference rects for positioning
const referenceRect = reference.getBoundingClientRect();
const elementRect = {
width: element.offsetWidth,
height: element.offsetHeight,
};
// Find container bounds
let containerRect;
if (container === document.body) {
containerRect = {
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
left: 0,
width: window.innerWidth,
height: window.innerHeight,
};
} else {
containerRect = container.getBoundingClientRect();
}
// Calculate initial position
let { x, y } = this.getBasePosition(
placement,
alignment,
elementRect,
referenceRect,
alignOffset,
sideOffset,
);
// Apply flipping if needed
let actualPlacement = placement;
if (flip) {
const flippedPlacement = this.getFlippedPlacement(
placement,
{ x, y, width: elementRect.width, height: elementRect.height },
containerRect,
);
if (flippedPlacement !== placement) {
actualPlacement = flippedPlacement;
const flippedPosition = this.getBasePosition(
flippedPlacement,
alignment,
elementRect,
referenceRect,
alignOffset,
sideOffset,
);
x = flippedPosition.x;
y = flippedPosition.y;
}
}
return {
x,
y,
placement: actualPlacement,
};
}
/**
* Apply position to an element
* @param {HTMLElement} element - Element to position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
static applyPosition(element, x, y) {
element.style.position = "fixed";
// element.style.transform = `translate(${x}px, ${y}px)`;
element.style.top = y + "px";
element.style.left = x + "px";
element.style.margin = "0"; // Reset margins to avoid positioning issues
}
/**
* Calculate base position based on placement and alignment
*/
static getBasePosition(
placement,
alignment,
elementRect,
referenceRect,
alignOffset = 0,
sideOffset = 8,
) {
let x = 0;
let y = 0;
// Position based on placement
switch (placement) {
case "top":
y = referenceRect.top - elementRect.height - sideOffset;
break;
case "right":
x = referenceRect.right + sideOffset;
y = referenceRect.top;
break;
case "bottom":
y = referenceRect.bottom + sideOffset;
break;
case "left":
x = referenceRect.left - elementRect.width - sideOffset;
y = referenceRect.top;
break;
}
// Adjust based on alignment
switch (alignment) {
case "start":
if (placement === "top" || placement === "bottom") {
x = referenceRect.left + alignOffset;
} else {
y = referenceRect.top + alignOffset;
}
break;
case "center":
if (placement === "top" || placement === "bottom") {
x =
referenceRect.left +
referenceRect.width / 2 -
elementRect.width / 2 +
alignOffset;
} else {
y =
referenceRect.top +
referenceRect.height / 2 -
elementRect.height / 2 +
alignOffset;
}
break;
case "end":
if (placement === "top" || placement === "bottom") {
x = referenceRect.right - elementRect.width + alignOffset;
} else {
y = referenceRect.bottom - elementRect.height + alignOffset;
}
break;
}
return { x, y };
}
/**
* Determine if placement should be flipped due to lack of space
*/
static getFlippedPlacement(placement, elementCoords, containerRect) {
const { x, y, width, height } = elementCoords;
// Check if element would overflow container
const overflowTop = y < containerRect.top;
const overflowRight = x + width > containerRect.right;
const overflowBottom = y + height > containerRect.bottom;
const overflowLeft = x < containerRect.left;
// Determine if we need to flip
switch (placement) {
case "top":
if (overflowTop && !overflowBottom) {
return "bottom";
}
break;
case "right":
if (overflowRight && !overflowLeft) {
return "left";
}
break;
case "bottom":
if (overflowBottom && !overflowTop) {
return "top";
}
break;
case "left":
if (overflowLeft && !overflowRight) {
return "right";
}
break;
}
return placement;
}
/**
* Utility method to find all scrollable parent elements
*/
static findScrollableParents(element) {
const scrollableParents = [];
let currentElement = element;
while (currentElement && currentElement !== document.body) {
const style = window.getComputedStyle(currentElement);
if (
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowX === "auto" ||
style.overflowX === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll"
) {
scrollableParents.push(currentElement);
}
currentElement = currentElement.parentElement;
}
// Always include window for global scrolling
scrollableParents.push(window);
return scrollableParents;
}
}
export default Positioner;
+178
View File
@@ -0,0 +1,178 @@
// saladui/core/scroll-manager.js
/**
* ScrollManager utility for SaladUI components
* Manages scroll and resize event handlers with optimized performance
*/
class ScrollManager {
/**
* Create a scroll manager to handle scroll and resize events
*
* @param {Function} updateCallback - Function to call when scroll/resize events occur
* @param {Object} options - Additional options
*/
constructor(updateCallback, options = {}) {
this.updateCallback = updateCallback;
this.options = {
// Use requestAnimationFrame for throttling
useRAF: true,
...options,
};
this.scrollableParents = [];
this.active = false;
this.resizeObserver = null;
this.animationFrameId = null;
// Bind methods to maintain correct context
this.handleScroll = this.handleScroll.bind(this);
this.handleResize = this.handleResize.bind(this);
this.updatePosition = this.updatePosition.bind(this);
}
/**
* Start tracking scroll and resize events
*
* @param {HTMLElement} referenceElement - Element to track scrollable parents for
* @param {HTMLElement} targetElement - Optional element to observe with ResizeObserver
*/
start(referenceElement, targetElement = null) {
if (this.active) return;
// Find scrollable parent elements
if (referenceElement) {
this.scrollableParents = this.findScrollableParents(referenceElement);
// Add scroll listeners to all scrollable parents
this.scrollableParents.forEach((parent) => {
parent.addEventListener("scroll", this.handleScroll, { passive: true });
});
}
// Add resize listener
window.addEventListener("resize", this.handleResize, { passive: true });
// Set up ResizeObserver for element size changes if available
if (targetElement && typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(this.updatePosition);
this.resizeObserver.observe(targetElement);
if (referenceElement && referenceElement !== targetElement) {
this.resizeObserver.observe(referenceElement);
}
}
this.active = true;
}
/**
* Stop tracking scroll and resize events
*/
stop() {
if (!this.active) return;
// Remove scroll listeners
this.scrollableParents.forEach((parent) => {
parent.removeEventListener("scroll", this.handleScroll);
});
// Remove resize listener
window.removeEventListener("resize", this.handleResize);
// Disconnect ResizeObserver if present
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Cancel any pending animation frame
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.active = false;
this.scrollableParents = [];
}
/**
* Handle scroll events with throttling
*/
handleScroll() {
if (this.options.useRAF) {
this.throttledUpdate();
} else {
this.updatePosition();
}
}
/**
* Handle resize events with throttling
*/
handleResize() {
if (this.options.useRAF) {
this.throttledUpdate();
} else {
this.updatePosition();
}
}
/**
* Throttle updates using requestAnimationFrame
*/
throttledUpdate() {
if (this.animationFrameId === null) {
this.animationFrameId = requestAnimationFrame(() => {
this.updatePosition();
this.animationFrameId = null;
});
}
}
/**
* Call the update callback
*/
updatePosition() {
if (this.updateCallback) {
this.updateCallback();
}
}
/**
* Find all scrollable parent elements
*/
findScrollableParents(element) {
const scrollableParents = [];
let currentElement = element;
while (currentElement && currentElement !== document.body) {
const style = window.getComputedStyle(currentElement);
if (
style.overflow === "auto" ||
style.overflow === "scroll" ||
style.overflowX === "auto" ||
style.overflowX === "scroll" ||
style.overflowY === "auto" ||
style.overflowY === "scroll"
) {
scrollableParents.push(currentElement);
}
currentElement = currentElement.parentElement;
}
// Always include window for global scrolling
scrollableParents.push(window);
return scrollableParents;
}
/**
* Clean up all references
*/
destroy() {
this.stop();
this.updateCallback = null;
this.options = null;
}
}
export default ScrollManager;
+132
View File
@@ -0,0 +1,132 @@
// saladui/core/state-machine.js
/**
* StateMachine class for SaladUI framework
* Handles state transitions, event processing, and state-specific behavior
*/
class StateMachine {
/**
* Create a state machine
*
* @param {Object} stateConfig - Configuration object defining states and transitions
* @param {string} initialState - The initial state to start in
* @param {Object} options - Optional configuration options. Currently supports:
* - onStateChanged: A callback function to be called when the state changes
*/
constructor(stateConfig, initialState, options) {
this.stateConfig = stateConfig;
this.state = initialState || "idle";
this.previousState = null;
this.options = options || {};
}
/**
* Trigger a transition based on an event
*
* @param {string} event - The event triggering the transition
* @param {Object} params - Parameters to pass to the handlers
* @returns {boolean} Whether the transition was successful
*/
transition(event, params = {}) {
const currentStateConfig = this.stateConfig[this.state];
if (!currentStateConfig) return false;
const transition = currentStateConfig.transitions?.[event];
if (!transition) return false;
const nextState = this.determineNextState(transition, params);
if (!nextState) return false;
const prevState = this.state;
this.executeTransition(prevState, nextState, params);
return true;
}
/**
* Determine the next state based on the transition definition
*
* @param {string|Function|Object} transition - Transition definition
* @param {Object} params - Parameters to help determine the next state
* @returns {string|null} The next state or null if not determinable
*/
determineNextState(transition, params) {
if (typeof transition === "string") {
return transition;
} else if (typeof transition === "function") {
return transition(params);
}
return null;
}
/**
* Execute a transition between states, with optional animation
*
* @param {string} prevState - The state we're coming from
* @param {string} nextState - The state we're going to
* @param {Object} params - Parameters to pass to handlers
*/
executeTransition(prevState, nextState, params = {}) {
// Execute exit handlers
this.executeStateHandler(prevState, "exit", params);
// Update state
this.previousState = prevState;
this.state = nextState;
let callbackResult;
// Execute state change hook
if (typeof this.options.onStateChanged === "function") {
callbackResult = this.options.onStateChanged(
prevState,
nextState,
params,
);
}
if (callbackResult && typeof callbackResult.then === "function") {
// If it returns a promise, wait for completion before executing enter handler
callbackResult
.then(() => {
this.executeStateHandler(nextState, "enter", params);
})
.catch((error) => {
console.error("Animation promise rejected:", error);
// Still execute enter handler even if animation fails
this.executeStateHandler(nextState, "enter", params);
});
} else {
// If it doesn't return a promise, execute enter handler immediately
this.executeStateHandler(nextState, "enter", params);
}
}
/**
* Execute a state handler (enter or exit)
*
* @param {string} stateName - The state whose handler to execute
* @param {string} handlerType - 'enter' or 'exit'
* @param {Object} params - Parameters to pass to the handler
*/
executeStateHandler(stateName, handlerType, params) {
const stateConfig = this.stateConfig[stateName];
if (!stateConfig) return;
const handler = stateConfig[handlerType];
if (typeof handler === "function") {
handler(params);
}
}
/**
* Check if the state has changed since last transition
*
* @returns {boolean} Whether the state has changed
*/
hasStateChanged() {
return this.state !== this.previousState;
}
}
export default StateMachine;
+164
View File
@@ -0,0 +1,164 @@
// saladui/core/animation-utils.js
/**
* Animation utilities for SaladUI framework components
* Provides functions to handle animations and transitions
*/
/**
* Animation handler for state transitions
*
* @param {Object} animConfig - Animation configuration object
* @param {HTMLElement} targetElement - Element to animate
* @returns {Promise} A Promise that resolves when animation completes
*/
export function animateTransition(animConfig, targetElement) {
if (!animConfig || !targetElement) {
return Promise.resolve(); // Return resolved promise if no animation or target
}
const { animation, duration = 200 } = animConfig;
// Process animation classes
const animationClasses = (animation || ["", "", ""]).map((item) =>
typeof item === "string" ? item.split(/\s+/) : [],
);
// Execute animation with promise
return executeAnimation(targetElement, {
animation: animationClasses,
duration,
});
}
/**
* Execute animation sequence on target element
*
* @param {HTMLElement} targetElement - Element to animate
* @param {Object} animOptions - Animation options
* @returns {Promise} Promise that resolves when animation completes
*/
export function executeAnimation(targetElement, animOptions) {
console.log("Animating", targetElement, animOptions);
return new Promise((resolve) => {
const { animation, duration } = animOptions;
let [transitionRun, transitionStart, transitionEnd] = animation || [
[],
[],
[],
];
// First animation frame: apply start classes
addOrRemoveClasses(
targetElement,
transitionStart,
[].concat(transitionRun).concat(transitionEnd),
);
// Next frame: apply running classes
window.requestAnimationFrame(() => {
addOrRemoveClasses(targetElement, transitionRun, []);
// Next frame: apply end classes
window.requestAnimationFrame(() =>
addOrRemoveClasses(targetElement, transitionEnd, transitionStart),
);
});
// After duration, clean up classes and resolve promise
setTimeout(() => {
addOrRemoveClasses(
targetElement,
[],
[].concat(transitionRun).concat(transitionStart).concat(transitionEnd),
);
resolve();
}, duration);
});
}
/**
* Add and remove CSS classes from a target element
*
* @param {HTMLElement} targetElement - Element to modify classes on
* @param {Array} addClasses - Classes to add
* @param {Array} removeClasses - Classes to remove
*/
export function addOrRemoveClasses(
targetElement,
addClasses = [],
removeClasses = [],
) {
if (!targetElement) return;
if (addClasses.length > 0) {
targetElement.classList.add(...addClasses.filter(Boolean));
}
if (removeClasses.length > 0) {
targetElement.classList.remove(...removeClasses.filter(Boolean));
}
}
/**
* Filter result constants
* @enum {number}
*/
const FilterResult = {
IGNORE_AND_CONTINUE: 0, // Ignore node but traverse children
SELECT_AND_CONTINUE: 1, // Select node and traverse children
IGNORE_AND_SKIP: -1, // Ignore node and skip children
};
/**
* Custom DOM query selector with advanced filtering capabilities
*
* @param {Node} root - Starting DOM node
* @param {Function} filterFunction - Function returning a FilterResult value
* @param {Object} [options] - Optional settings
* @param {boolean} [options.breadthFirst=true] - Use breadth-first (true) or depth-first (false)
* @returns {Element[]} Matching elements
*/
export function queryDOM(
root,
filterFunction,
options = { breadthFirst: true },
) {
// Validate inputs
if (!(root instanceof Node)) throw new TypeError("Root must be a DOM node");
if (typeof filterFunction !== "function")
throw new TypeError("Filter must be a function");
const result = [];
const nodes = [...root.children];
const getNext =
options.breadthFirst !== false ? () => nodes.shift() : () => nodes.pop();
while (nodes.length > 0) {
const current = getNext();
// Skip non-element nodes
if (!(current instanceof Element)) continue;
// Process based on filter result
const filterResult = filterFunction(current);
if (filterResult === FilterResult.SELECT_AND_CONTINUE) {
result.push(current);
addChildren(current, nodes);
} else if (filterResult === FilterResult.IGNORE_AND_CONTINUE) {
addChildren(current, nodes);
}
// IGNORE_AND_SKIP: do nothing
}
return result;
}
/**
* Add element's children to the traversal collection
*/
function addChildren(element, collection) {
for (let i = 0; i < element.children.length; i++) {
collection.push(element.children[i]);
}
}
+16
View File
@@ -0,0 +1,16 @@
// saladui/index.js
import Component from "./core/component";
import { registry } from "./core/factory";
import { SaladUIHook } from "./core/hook";
function register(type, ComponentClass) {
registry.register(type, ComponentClass);
}
const SaladUI = {
Component,
register,
SaladUIHook,
};
export default SaladUI;
+46
View File
@@ -0,0 +1,46 @@
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "assets/js/app.js".
// Bring in the socket library
import {Socket} from "phoenix"
// And connect to the path in "lib/river_connect_web/endpoint.ex". We pass the
// token for authentication. Read below how it should be used.
let params = {user_id: window.userId || "guest-" + Math.floor(Math.random() * 1000)}
let socket = new Socket("/socket", {params: params})
// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
// which authenticates the session and assigns a `:current_user`.
// If the current user exists you can set a own_id in the window
// object in root.html.heex:
//
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
//
// Now you need to pass this token to JavaScript. You can do so
// inside the socket implementation in "lib/river_connect_web/channels/user_socket.ex":
//
// def connect(%{"token" => token}, socket, _connect_info) do
// # max_age: 1209600 is equivalent to two weeks in seconds
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
// {:ok, user_id} ->
// {:ok, assign(socket, :user, user_id)}
// {:error, reason} ->
// :error
// end
// end
//
// Finally, connect to the socket:
socket.connect()
// Now that you are connected, you can join channels with a topic.
// Let's assume you have a channel with a topic named `room` and the
// subtopic is its id - in this case 42:
//
// let channel = socket.channel("room:42", {})
// channel.join()
// .receive("ok", resp => { console.log("Joined successfully", resp) })
// .receive("error", resp => { console.log("Unable to join", resp) })
//
// export default socket to import it in other files
export default socket
+45
View File
@@ -0,0 +1,45 @@
{
"accent": {
"DEFAULT": "hsl(var(--accent))",
"foreground": "hsl(var(--accent-foreground))"
},
"background": "hsl(var(--background))",
"border": "hsl(var(--border))",
"card": {
"DEFAULT": "hsl(var(--card))",
"foreground": "hsl(var(--card-foreground))"
},
"destructive": {
"DEFAULT": "hsl(var(--destructive))",
"foreground": "hsl(var(--destructive-foreground))"
},
"foreground": "hsl(var(--foreground))",
"input": "hsl(var(--input))",
"muted": {
"DEFAULT": "hsl(var(--muted))",
"foreground": "hsl(var(--muted-foreground))"
},
"popover": {
"DEFAULT": "hsl(var(--popover))",
"foreground": "hsl(var(--popover-foreground))"
},
"primary": {
"DEFAULT": "hsl(var(--primary))",
"foreground": "hsl(var(--primary-foreground))"
},
"ring": "hsl(var(--ring))",
"secondary": {
"DEFAULT": "hsl(var(--secondary))",
"foreground": "hsl(var(--secondary-foreground))"
},
"sidebar": {
"DEFAULT": "hsl(var(--sidebar-background))",
"foreground": "hsl(var(--sidebar-foreground))",
"primary": "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
"accent": "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
"border": "hsl(var(--sidebar-border))",
"ring": "hsl(var(--sidebar-ring))"
}
}
+19
View File
@@ -0,0 +1,19 @@
import Config from 'tailwindcss'
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"../deps/salad_ui/lib/**/*.ex",
'./js/**/*.js',
'../lib/river_connect_web.ex',
'../lib/river_connect_web/**/*.*ex',
],
theme: {
extend: {
colors: require("./tailwind.colors.json"),},
},
plugins: [
require("@tailwindcss/typography"),
require("./vendor/tailwindcss-animate"),],
}
+32
View File
@@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}
+124
View File
File diff suppressed because one or more lines are too long
+1031
View File
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})
+188
View File
@@ -0,0 +1,188 @@
const plugin = require("tailwindcss/plugin")
function filterDefault(values) {
return Object.fromEntries(
Object.entries(values).filter(([key]) => key !== "DEFAULT"),
)
}
module.exports = plugin(
({ addUtilities, matchUtilities, theme }) => {
addUtilities({
"@keyframes enter": theme("keyframes.enter"),
"@keyframes exit": theme("keyframes.exit"),
".animate-in": {
animationName: "enter",
animationDuration: theme("animationDuration.DEFAULT"),
"--tw-enter-opacity": "initial",
"--tw-enter-scale": "initial",
"--tw-enter-rotate": "initial",
"--tw-enter-translate-x": "initial",
"--tw-enter-translate-y": "initial",
},
".animate-out": {
animationName: "exit",
animationDuration: theme("animationDuration.DEFAULT"),
"--tw-exit-opacity": "initial",
"--tw-exit-scale": "initial",
"--tw-exit-rotate": "initial",
"--tw-exit-translate-x": "initial",
"--tw-exit-translate-y": "initial",
},
})
matchUtilities(
{
"fade-in": (value) => ({ "--tw-enter-opacity": value }),
"fade-out": (value) => ({ "--tw-exit-opacity": value }),
},
{ values: theme("animationOpacity") },
)
matchUtilities(
{
"zoom-in": (value) => ({ "--tw-enter-scale": value }),
"zoom-out": (value) => ({ "--tw-exit-scale": value }),
},
{ values: theme("animationScale") },
)
matchUtilities(
{
"spin-in": (value) => ({ "--tw-enter-rotate": value }),
"spin-out": (value) => ({ "--tw-exit-rotate": value }),
},
{ values: theme("animationRotate") },
)
matchUtilities(
{
"slide-in-from-top": (value) => ({
"--tw-enter-translate-y": `-${value}`,
}),
"slide-in-from-bottom": (value) => ({
"--tw-enter-translate-y": value,
}),
"slide-in-from-left": (value) => ({
"--tw-enter-translate-x": `-${value}`,
}),
"slide-in-from-right": (value) => ({
"--tw-enter-translate-x": value,
}),
"slide-out-to-top": (value) => ({
"--tw-exit-translate-y": `-${value}`,
}),
"slide-out-to-bottom": (value) => ({
"--tw-exit-translate-y": value,
}),
"slide-out-to-left": (value) => ({
"--tw-exit-translate-x": `-${value}`,
}),
"slide-out-to-right": (value) => ({
"--tw-exit-translate-x": value,
}),
},
{ values: theme("animationTranslate") },
)
matchUtilities(
{ duration: (value) => ({ animationDuration: value }) },
{ values: filterDefault(theme("animationDuration")) },
)
matchUtilities(
{ delay: (value) => ({ animationDelay: value }) },
{ values: theme("animationDelay") },
)
matchUtilities(
{ ease: (value) => ({ animationTimingFunction: value }) },
{ values: filterDefault(theme("animationTimingFunction")) },
)
addUtilities({
".running": { animationPlayState: "running" },
".paused": { animationPlayState: "paused" },
})
matchUtilities(
{ "fill-mode": (value) => ({ animationFillMode: value }) },
{ values: theme("animationFillMode") },
)
matchUtilities(
{ direction: (value) => ({ animationDirection: value }) },
{ values: theme("animationDirection") },
)
matchUtilities(
{ repeat: (value) => ({ animationIterationCount: value }) },
{ values: theme("animationRepeat") },
)
},
{
theme: {
extend: {
animationDelay: ({ theme }) => ({
...theme("transitionDelay"),
}),
animationDuration: ({ theme }) => ({
0: "0ms",
...theme("transitionDuration"),
}),
animationTimingFunction: ({ theme }) => ({
...theme("transitionTimingFunction"),
}),
animationFillMode: {
none: "none",
forwards: "forwards",
backwards: "backwards",
both: "both",
},
animationDirection: {
normal: "normal",
reverse: "reverse",
alternate: "alternate",
"alternate-reverse": "alternate-reverse",
},
animationOpacity: ({ theme }) => ({
DEFAULT: 0,
...theme("opacity"),
}),
animationTranslate: ({ theme }) => ({
DEFAULT: "100%",
...theme("translate"),
}),
animationScale: ({ theme }) => ({
DEFAULT: 0,
...theme("scale"),
}),
animationRotate: ({ theme }) => ({
DEFAULT: "30deg",
...theme("rotate"),
}),
animationRepeat: {
0: "0",
1: "1",
infinite: "infinite",
},
keyframes: {
enter: {
from: {
opacity: "var(--tw-enter-opacity, 1)",
transform:
"translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))",
},
},
exit: {
to: {
opacity: "var(--tw-exit-opacity, 1)",
transform:
"translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))",
},
},
},
},
},
},
)
+138
View File
@@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));
+70
View File
@@ -0,0 +1,70 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :river_connect,
ecto_repos: [RiverConnect.Repo],
generators: [timestamp_type: :utc_datetime]
# Configure the endpoint
config :river_connect, RiverConnectWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: RiverConnectWeb.ErrorHTML, json: RiverConnectWeb.ErrorJSON],
layout: false
],
pubsub_server: RiverConnect.PubSub,
live_view: [signing_salt: "E5aSlroC"]
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :river_connect, RiverConnect.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
river_connect: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.1.12",
river_connect: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :river_connect, Oban,
repo: RiverConnect.Repo,
queues: [default: 10],
plugins: [Oban.Plugins.Pruner]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
+92
View File
@@ -0,0 +1,92 @@
import Config
# Configure your database
config :river_connect, RiverConnect.Repo,
username: "admin",
password: "21Nt26",
hostname: "localhost",
database: "river_connect_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :river_connect, RiverConnectWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4005],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "yzfuJmd+Bd93mCTaH1Yo5MNn1buUd4qrO5BoJKfgvSgbSAP3Pe0IMD86k0WB+8wi",
watchers: [
esbuild: {Esbuild, :install_and_run, [:river_connect, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:river_connect, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Reload browser tabs when matching files change.
config :river_connect, RiverConnectWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
# Gettext translations
~r"priv/gettext/.*\.po$"E,
# Router, Controllers, LiveViews and LiveComponents
~r"lib/river_connect_web/router\.ex$"E,
~r"lib/river_connect_web/(controllers|live|components)/.*\.(ex|heex)$"E
]
]
# Enable dev routes for dashboard and mailbox
config :river_connect, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
+31
View File
@@ -0,0 +1,31 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :river_connect, RiverConnectWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :river_connect, RiverConnectWeb.Endpoint,
force_ssl: [rewrite_on: [:x_forwarded_proto]],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.
+120
View File
@@ -0,0 +1,120 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/river_connect start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :river_connect, RiverConnectWeb.Endpoint, server: true
end
config :river_connect, RiverConnectWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4005"))]
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :river_connect, RiverConnect.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :river_connect, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :river_connect, RiverConnectWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :river_connect, RiverConnectWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :river_connect, RiverConnectWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :river_connect, RiverConnect.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end
+41
View File
@@ -0,0 +1,41 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :river_connect, RiverConnect.Repo,
username: "admin",
password: "21Nt26",
hostname: "localhost",
database: "river_connect_test",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :river_connect, RiverConnectWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4006],
secret_key_base: "nL8KkeOJhgu4Emju7nfXUwuWALkDLVPeg0koFDaCZaU8V8Ug12QKt9qUdRzak1Ll",
server: false
# In test we don't send emails
config :river_connect, RiverConnect.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true
+9
View File
@@ -0,0 +1,9 @@
defmodule RiverConnect do
@moduledoc """
RiverConnect keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end
+36
View File
@@ -0,0 +1,36 @@
defmodule RiverConnect.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
TwMerge.Cache,
RiverConnectWeb.Telemetry,
RiverConnect.Repo,
{DNSCluster, query: Application.get_env(:river_connect, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: RiverConnect.PubSub},
# Start a worker by calling: RiverConnect.Worker.start_link(arg)
# {RiverConnect.Worker, arg},
# Start to serve requests, typically the last entry
{Oban, Application.fetch_env!(:river_connect, Oban)},
RiverConnectWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: RiverConnect.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
RiverConnectWeb.Endpoint.config_change(changed, removed)
:ok
end
end
+104
View File
@@ -0,0 +1,104 @@
defmodule RiverConnect.Audio do
@moduledoc """
The Audio context.
"""
import Ecto.Query, warn: false
alias RiverConnect.Repo
alias RiverConnect.Audio.Message
@doc """
Returns the list of audio_messages.
## Examples
iex> list_messages()
[%Message{}, ...]
"""
def list_messages do
Repo.all(from m in Message, order_by: [desc: m.inserted_at])
end
@doc """
Gets a single message.
Raises `Ecto.NoResultsError` if the Message does not exist.
## Examples
iex> get_message!(123)
%Message{}
iex> get_message!(456)
** (Ecto.NoResultsError)
"""
def get_message!(id), do: Repo.get!(Message, id)
@doc """
Creates a message.
## Examples
iex> create_message(%{field: value})
{:ok, %Message{}}
iex> create_message(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_message(attrs \\ %{}) do
%Message{}
|> Message.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a message.
## Examples
iex> update_message(message, %{field: new_value})
{:ok, %Message{}}
iex> update_message(message, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_message(%Message{} = message, attrs) do
message
|> Message.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a message.
## Examples
iex> delete_message(message)
{:ok, %Message{}}
iex> delete_message(message)
{:error, %Ecto.Changeset{}}
"""
def delete_message(%Message{} = message) do
Repo.delete(message)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking message changes.
## Examples
iex> change_message(message)
%Ecto.Changeset{data: %Message{}}
"""
def change_message(%Message{} = message, attrs \\ %{}) do
Message.changeset(message, attrs)
end
end
+21
View File
@@ -0,0 +1,21 @@
defmodule RiverConnect.Audio.Message do
use Ecto.Schema
import Ecto.Changeset
@derive {Jason.Encoder, only: [:id, :user_id, :file_path, :duration_ms, :status, :inserted_at]}
schema "audio_messages" do
field :status, :string
field :user_id, :string
field :file_path, :string
field :duration_ms, :integer
timestamps(type: :utc_datetime)
end
@doc false
def changeset(message, attrs) do
message
|> cast(attrs, [:user_id, :file_path, :duration_ms, :status])
|> validate_required([:user_id, :file_path, :status])
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule RiverConnect.Mailer do
use Swoosh.Mailer, otp_app: :river_connect
end
+5
View File
@@ -0,0 +1,5 @@
defmodule RiverConnect.Repo do
use Ecto.Repo,
otp_app: :river_connect,
adapter: Ecto.Adapters.Postgres
end
@@ -0,0 +1,25 @@
defmodule RiverConnect.Workers.ProcessRecording do
use Oban.Worker, queue: :default
alias RiverConnect.Audio
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => id}}) do
case Audio.get_message!(id) do
%Audio.Message{} = message ->
# Simulate processing time (e.g. transcoding or upload)
Process.sleep(1000)
# Update status to "ready"
{:ok, _updated_message} = Audio.update_message(message, %{status: "ready"})
# Broadcast update
RiverConnectWeb.Endpoint.broadcast!("audio:lobby", "audio_message_ready", %{id: message.id})
:ok
_ ->
{:error, :not_found}
end
end
end
+114
View File
@@ -0,0 +1,114 @@
defmodule RiverConnectWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use RiverConnectWeb, :controller
use RiverConnectWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: RiverConnectWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: RiverConnectWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import RiverConnectWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias RiverConnectWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: RiverConnectWeb.Endpoint,
router: RiverConnectWeb.Router,
statics: RiverConnectWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
@@ -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

Some files were not shown because too many files have changed in this diff Show More