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