Merge branch 'main' into feat/421_accessibility
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
73382c2c3f
49 changed files with 3415 additions and 1950 deletions
|
|
@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
|
|||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="z-50 toast toast-top toast-end"
|
||||
class="pointer-events-auto"
|
||||
{@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",
|
||||
@kind == :success && "bg-green-500 text-white",
|
||||
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
|
||||
@kind == :success && "alert-success",
|
||||
@kind == :warning && "alert-warning"
|
||||
]}>
|
||||
<.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" />
|
||||
|
|
@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do
|
|||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Variants (Design Guidelines §5.2)
|
||||
- primary (main CTA)
|
||||
- secondary (supporting)
|
||||
- neutral (cancel/back)
|
||||
- ghost (low emphasis; table/toolbars)
|
||||
- outline (alternative CTA)
|
||||
- danger (destructive)
|
||||
- link (inline; rare)
|
||||
- icon (icon-only)
|
||||
|
||||
## Sizes
|
||||
- sm, md (default), lg
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
<.button navigate={~p"/"} variant="secondary">Home</.button>
|
||||
<.button variant="ghost" size="sm">Edit</.button>
|
||||
<.button disabled={true}>Disabled</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method data-testid)
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
attr :rest, :global, include: ~w(href navigate patch method data-testid form)
|
||||
|
||||
attr :variant, :string,
|
||||
values: ~w(primary secondary neutral ghost outline danger link icon),
|
||||
default: "primary"
|
||||
|
||||
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
rest = assigns.rest
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||
variant = assigns[:variant] || "primary"
|
||||
size = assigns[:size] || "md"
|
||||
|
||||
variant_classes = %{
|
||||
"primary" => "btn-primary",
|
||||
"secondary" => "btn-secondary",
|
||||
"neutral" => "btn-neutral",
|
||||
"ghost" => "btn-ghost",
|
||||
"outline" => "btn-outline",
|
||||
"danger" => "btn-error",
|
||||
"link" => "btn-link",
|
||||
"icon" => "btn-ghost btn-square"
|
||||
}
|
||||
|
||||
size_classes = %{
|
||||
"sm" => "btn-sm",
|
||||
"md" => "",
|
||||
"lg" => "btn-lg"
|
||||
}
|
||||
|
||||
base_class = Map.fetch!(variant_classes, variant)
|
||||
size_class = size_classes[size]
|
||||
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||
|
||||
assigns = assign(assigns, :btn_class, btn_class)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
# For links, we can't use disabled attribute, so we use btn-disabled class
|
||||
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
|
||||
link_class =
|
||||
if assigns[:disabled],
|
||||
do: ["btn", assigns.class, "btn-disabled"],
|
||||
else: ["btn", assigns.class]
|
||||
do: ["btn", btn_class, "btn-disabled"],
|
||||
else: ["btn", btn_class]
|
||||
|
||||
# Prevent interaction when disabled
|
||||
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
rest
|
||||
|
|
@ -138,7 +176,7 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @class]} disabled={@disabled} {@rest}>
|
||||
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
|
|
@ -240,6 +278,42 @@ defmodule MvWeb.CoreComponents do
|
|||
defp badge_style_class("outline"), do: "badge-outline"
|
||||
defp badge_style_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||
or status badges that need explanation (Design Guidelines §8.2).
|
||||
|
||||
## Examples
|
||||
|
||||
<.tooltip content={gettext("Edit")}>
|
||||
<.button variant="icon" size="sm"><.icon name="hero-pencil" /></.button>
|
||||
</.tooltip>
|
||||
|
||||
<.tooltip content={@full_name} position="top">
|
||||
<span class="truncate max-w-32">{@full_name}</span>
|
||||
</.tooltip>
|
||||
"""
|
||||
attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
|
||||
|
||||
attr :position, :string,
|
||||
values: ~w(top bottom left right),
|
||||
default: "bottom"
|
||||
|
||||
attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tooltip(assigns) do
|
||||
position_class = "tooltip tooltip-#{assigns.position}"
|
||||
wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
|
||||
|
||||
assigns = assign(assigns, :wrap_class, wrap_class)
|
||||
|
||||
~H"""
|
||||
<div class={@wrap_class} data-tip={@content}>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a dropdown menu.
|
||||
|
||||
|
|
@ -532,7 +606,7 @@ defmodule MvWeb.CoreComponents do
|
|||
{@rest}
|
||||
/>{@label}<span
|
||||
:if={@is_required}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field is required")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -551,7 +625,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -580,7 +654,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -609,7 +683,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
class="text-error tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
|
|
@ -654,17 +728,24 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
|
||||
Use the `:leading` slot for the Back button (left side, consistent with data fields).
|
||||
Use the `:actions` slot for primary actions (e.g. Save) on the right.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :leading, doc: "Content on the left (e.g. Back button)"
|
||||
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", @class]}>
|
||||
<div>
|
||||
<header class={["flex items-center gap-6 pb-4", @class]}>
|
||||
<div :if={@leading != []} class="shrink-0">
|
||||
{render_slot(@leading)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-xl font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
|
|
@ -672,7 +753,9 @@ defmodule MvWeb.CoreComponents do
|
|||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
|
||||
<div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
|
||||
{render_slot(@actions)}
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
|
@ -680,18 +763,51 @@ defmodule MvWeb.CoreComponents do
|
|||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||
|
||||
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
|
||||
For interactive elements inside other columns (e.g. checkboxes, buttons), use
|
||||
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
|
||||
<.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
|
||||
<:col :let={m} label="Name">{m.name}</: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 :selected_row_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
|
||||
|
||||
attr :row_selected?, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
|
||||
|
||||
attr :row_tooltip, :string,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
|
||||
|
||||
attr :row_value_id, :any,
|
||||
default: nil,
|
||||
doc:
|
||||
"optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
|
@ -703,6 +819,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||
|
||||
attr :sticky_header, :boolean,
|
||||
default: false,
|
||||
doc:
|
||||
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
|
|
@ -720,6 +841,12 @@ defmodule MvWeb.CoreComponents do
|
|||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
# Function to get the row's value id for selected_row_id comparison (no extra DB reads)
|
||||
row_value_id_fn =
|
||||
assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
|
||||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<table class="table table-zebra">
|
||||
|
|
@ -727,12 +854,12 @@ defmodule MvWeb.CoreComponents do
|
|||
<tr>
|
||||
<th
|
||||
:for={col <- @col}
|
||||
class={Map.get(col, :class)}
|
||||
class={table_th_class(col, @sticky_header)}
|
||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||
>
|
||||
{col[:label]}
|
||||
</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
|
|
@ -742,15 +869,21 @@ defmodule MvWeb.CoreComponents do
|
|||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
|
||||
<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)}>
|
||||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||
title={@row_click && @row_tooltip}
|
||||
>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -784,6 +917,9 @@ defmodule MvWeb.CoreComponents do
|
|||
Enum.join(classes, " ")
|
||||
}
|
||||
>
|
||||
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
|
||||
<span class="sr-only">{@row_tooltip}</span>
|
||||
<% end %>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -817,6 +953,43 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
|
||||
defp table_row_selected?(assigns, row) do
|
||||
item = assigns.row_item.(row)
|
||||
|
||||
if assigns[:row_selected?] do
|
||||
assigns.row_selected?.(item)
|
||||
else
|
||||
assigns[:selected_row_id] != nil and
|
||||
assigns.row_value_id_fn.(row) == assigns.selected_row_id
|
||||
end
|
||||
end
|
||||
|
||||
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||
# and stronger selected outline when selected (WCAG: not color-only).
|
||||
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||
defp table_row_tr_class(row_click, selected?) do
|
||||
has_row_click? = not is_nil(row_click)
|
||||
base = []
|
||||
|
||||
base =
|
||||
if has_row_click? and not selected?,
|
||||
do:
|
||||
base ++
|
||||
[
|
||||
"hover:ring-2",
|
||||
"hover:ring-inset",
|
||||
"hover:ring-base-content/10",
|
||||
"focus-within:ring-2",
|
||||
"focus-within:ring-inset",
|
||||
"focus-within:ring-base-content/10"
|
||||
],
|
||||
else: base
|
||||
|
||||
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||
Enum.join(base, " ")
|
||||
end
|
||||
|
||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||
col_sort = Map.get(col, :sort_field)
|
||||
|
||||
|
|
@ -827,6 +1000,18 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
||||
defp table_th_class(col, sticky_header) do
|
||||
base = Map.get(col, :class)
|
||||
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
|
||||
[base, sticky] |> Enum.filter(& &1) |> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp table_th_sticky_class(true),
|
||||
do: "lg:sticky lg:top-0 bg-base-100 z-10"
|
||||
|
||||
defp table_th_sticky_class(_), do: nil
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue