Merge remote-tracking branch 'origin/main' into sidebar
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
ff625c91c5
113 changed files with 19602 additions and 2699 deletions
|
|
@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do
|
|||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
<.button disabled={true}>Disabled</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method)
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
|
|
@ -105,14 +107,37 @@ defmodule MvWeb.CoreComponents do
|
|||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||
|
||||
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]
|
||||
|
||||
# Prevent interaction when disabled
|
||||
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
|
||||
link_attrs =
|
||||
if assigns[:disabled] do
|
||||
rest
|
||||
|> Map.drop([:href, :navigate, :patch])
|
||||
|> Map.merge(%{tabindex: "-1", "aria-disabled": "true"})
|
||||
else
|
||||
rest
|
||||
end
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:link_class, link_class)
|
||||
|> assign(:link_attrs, link_attrs)
|
||||
|
||||
~H"""
|
||||
<.link class={["btn", @class]} {@rest}>
|
||||
<.link class={@link_class} {@link_attrs}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={["btn", @class]} {@rest}>
|
||||
<button class={["btn", @class]} disabled={@disabled} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
|
|
@ -153,7 +178,7 @@ defmodule MvWeb.CoreComponents do
|
|||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
class="btn btn-ghost"
|
||||
class="btn"
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid="dropdown-button"
|
||||
|
|
@ -236,6 +261,30 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a section in with a border similar to cards.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<p>input</p>
|
||||
</form_section>
|
||||
"""
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def form_section(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
|
|
@ -284,7 +333,8 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :error_class, :string, 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
|
||||
include:
|
||||
~w(accept autocomplete aria-required 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
|
||||
|
|
@ -304,6 +354,24 @@ defmodule MvWeb.CoreComponents do
|
|||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
# For checkboxes, we don't use HTML required attribute (means "must be checked")
|
||||
# Instead, we use aria-required for screen readers (WCAG 2.1, Success Criterion 3.3.2)
|
||||
# Extract required from rest and remove it, but keep aria-required if provided
|
||||
rest = assigns.rest || %{}
|
||||
is_required = Map.get(rest, :required, false)
|
||||
aria_required = Map.get(rest, :aria_required, if(is_required, do: "true", else: nil))
|
||||
|
||||
# Remove required from rest (we don't want HTML required on checkbox)
|
||||
rest_without_required = Map.delete(rest, :required)
|
||||
# Ensure aria-required is set if field is required
|
||||
rest_final =
|
||||
if aria_required,
|
||||
do: Map.put(rest_without_required, :aria_required, aria_required),
|
||||
else: rest_without_required
|
||||
|
||||
assigns = assign(assigns, :rest, rest_final)
|
||||
assigns = assign(assigns, :is_required, is_required)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
|
|
@ -318,9 +386,9 @@ defmodule MvWeb.CoreComponents do
|
|||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}<span
|
||||
:if={@rest[:required]}
|
||||
:if={@is_required}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
data-tip={gettext("This field is required")}
|
||||
>*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -434,7 +502,7 @@ defmodule MvWeb.CoreComponents do
|
|||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
<h1 class="text-xl font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
|
|
@ -474,6 +542,7 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
|
||||
end
|
||||
|
||||
|
|
@ -490,7 +559,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
|
|
@ -514,7 +583,34 @@ defmodule MvWeb.CoreComponents do
|
|||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
}
|
||||
class={["max-w-xs truncate", (col[:col_click] || @row_click) && "hover:cursor-pointer"]}
|
||||
class={
|
||||
col_class = Map.get(col, :class)
|
||||
has_click = col[:col_click] || @row_click
|
||||
classes = ["max-w-xs"]
|
||||
|
||||
classes =
|
||||
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
|
||||
["truncate" | classes]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if has_click do
|
||||
["hover:cursor-pointer" | classes]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class do
|
||||
[col_class | classes]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
Enum.join(classes, " ")
|
||||
}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ defmodule MvWeb.Layouts do
|
|||
default: nil,
|
||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||
|
||||
attr :club_name, :string,
|
||||
default: nil,
|
||||
doc: "optional club name to pass to navbar"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
|
|
|
|||
|
|
@ -2,47 +2,82 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
@moduledoc """
|
||||
Navbar that is used in the rootlayout shown on every page
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
use MvWeb, :verified_routes
|
||||
use MvWeb, :html
|
||||
|
||||
attr :current_user, :map,
|
||||
required: true,
|
||||
doc: "The current user - navbar is only shown when user is present"
|
||||
|
||||
attr :club_name, :string,
|
||||
default: nil,
|
||||
doc: "Optional club name - if not provided, will be loaded from database"
|
||||
|
||||
def navbar(assigns) do
|
||||
club_name = assigns[:club_name] || get_club_name()
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
~H"""
|
||||
<header class="shadow-sm navbar bg-base-100">
|
||||
<div class="flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
|
||||
aria-label={gettext("Toggle navigation menu")}
|
||||
aria-expanded="false"
|
||||
aria-controls="main-sidebar"
|
||||
id="sidebar-toggle"
|
||||
class="mr-2 btn btn-square btn-ghost focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class="my-1.5 inline-block size-4"
|
||||
aria-hidden="true"
|
||||
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
|
||||
<ul class="menu menu-horizontal bg-base-200">
|
||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
|
||||
<li><.link navigate="/users">{gettext("Users")}</.link></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>{gettext("Contributions")}</summary>
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||
<li>
|
||||
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate="/membership_fee_settings">
|
||||
{gettext("Membership Fee Settings")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" action="/set_locale" class="mr-4">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<label class="sr-only" for="locale-select">{gettext("Select language")}</label>
|
||||
<select
|
||||
id="locale-select"
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z">
|
||||
</path>
|
||||
<path d="M9 4v16"></path>
|
||||
<path d="M14 10l2 2l-2 2"></path>
|
||||
</svg>
|
||||
<span class="sr-only">{gettext("Toggle navigation menu")}</span>
|
||||
</button>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Daisy UI Theme Toggle for dark and light mode-->
|
||||
<label class="flex cursor-pointer gap-2" aria-label={gettext("Toggle dark mode")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle toggle-sm theme-controller"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper function to get club name from settings
|
||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
||||
defp get_club_name do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue