feat: keep empty cells consistent empty
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
carla 2026-02-26 13:37:35 +01:00
parent 9751525a0c
commit 4ac56958b4
18 changed files with 263 additions and 372 deletions

View file

@ -300,6 +300,81 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
@doc """
Renders a visually empty table cell with screen-reader-only text (WCAG).
Use when a table cell has no value so that:
- The cell appears empty (no dash, no "n/a").
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.empty_cell sr_text={gettext("No cycle")} />
<.empty_cell sr_text={gettext("No group assignment")} />
<.empty_cell sr_text={gettext("Not specified")} />
"""
attr :sr_text, :string,
required: true,
doc: "Text read by screen readers when the cell is visually empty"
def empty_cell(assigns) do
~H"""
<span class="sr-only">{@sr_text}</span>
"""
end
@doc """
Renders content when value is present, otherwise an accessible empty cell.
Use in table cells for optional fields: when `value` is blank, only the
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
{member.membership_fee_type.name}
</.maybe_value>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= for g <- member.groups do %>
<.badge variant="primary" style="outline">{g.name}</.badge>
<% end %>
</.maybe_value>
"""
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
attr :empty_sr_text, :string,
default: nil,
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
slot :inner_block, required: true
def maybe_value(assigns) do
empty_sr = assigns.empty_sr_text || gettext("Not specified")
assigns = assign(assigns, :empty_sr_text, empty_sr)
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
~H"""
<%= if @blank? do %>
<.empty_cell sr_text={@empty_sr_text} />
<% else %>
{render_slot(@inner_block)}
<% end %>
"""
end
defp value_blank?(nil), do: true
defp value_blank?(false), do: true
defp value_blank?([]), do: true
defp value_blank?(%Ash.NotLoaded{}), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
@doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2).

View file

@ -126,7 +126,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</p>
<p class="mt-2 text-sm">
{gettext(
"All datafield values will be permanently deleted when you delete this datfield."
"All datafield values will be permanently deleted when you delete this datafield."
)}
</p>
</div>

View file

@ -68,11 +68,9 @@ defmodule MvWeb.GroupLive.Index do
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</.maybe_value>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}

View file

@ -304,16 +304,14 @@ defmodule MvWeb.GroupLive.Show do
</.link>
</td>
<td>
<%= if member.email do %>
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</.maybe_value>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>

View file

@ -356,11 +356,9 @@
"""
}
>
<%= if member.membership_fee_type do %>
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}>
{member.membership_fee_type.name}
<% else %>
<span class="text-base-content/50">—</span>
<% end %>
</.maybe_value>
</:col>
<:col
:let={member}
@ -375,7 +373,7 @@
{badge.label}
</.badge>
<% else %>
<.badge variant="neutral">{gettext("No cycle")}</.badge>
<.empty_cell sr_text={gettext("No cycle")} />
<% end %>
</:col>
<:col
@ -394,18 +392,17 @@
"""
}
>
<%= for group <- (member.groups || []) do %>
<.badge
variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.badge>
<% end %>
<%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span>
<% end %>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= for group <- (member.groups || []) do %>
<.badge
variant="primary"
style="outline"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.badge>
<% end %>
</.maybe_value>
</:col>
<:action :let={member}>
<div class="sr-only">

View file

@ -559,7 +559,11 @@ defmodule MvWeb.MemberLive.Show do
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
{display_value(@value)}
<%= if value_blank?(@value) do %>
<.empty_cell sr_text={gettext("Not set")} />
<% else %>
{@value}
<% end %>
<% end %>
</dd>
</dl>
@ -593,9 +597,9 @@ defmodule MvWeb.MemberLive.Show do
# Helper Functions
# -----------------------------------------------------------------
defp display_value(nil), do: render_empty_value()
defp display_value(""), do: render_empty_value()
defp display_value(value), do: value
defp value_blank?(nil), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
@ -684,10 +688,10 @@ defmodule MvWeb.MemberLive.Show do
if String.trim(value) == "" do
render_empty_value()
else
assigns = %{email: value}
assigns = %{email: value, display: value}
~H"""
<.mailto_link email={@email} display={@email} />
<.mailto_link email={@email} display={@display} />
"""
end
end
@ -702,17 +706,10 @@ defmodule MvWeb.MemberLive.Show do
defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible placeholder for empty values
# Uses translated text for screen readers while maintaining visual consistency
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6).
# Returns safe HTML so it can be used from helpers without LiveView assigns.
defp render_empty_value do
assigns = %{text: gettext("Not set")}
~H"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
text = gettext("Not set")
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
end
end

View file

@ -101,7 +101,13 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<td>
<%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %>
{cell_content}
<% else %>
<.empty_cell sr_text={receipt_empty_sr_text(col_key)} />
<% end %>
</td>
<% end %>
</tr>
<% end %>
@ -1157,7 +1163,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end
defp format_receipt_cell(:amount, nil), do: ""
# Screen-reader text for empty receipt table cells (visually empty, A11y)
defp receipt_empty_sr_text(:status), do: gettext("Not set")
defp receipt_empty_sr_text(_), do: gettext("Not specified")
defp format_receipt_cell(:amount, nil), do: nil
defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do
@ -1175,7 +1185,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, nil), do: nil
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
@ -1183,7 +1193,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, nil), do: nil
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
@ -1192,7 +1202,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
do: nil
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
@ -1253,7 +1263,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status("empty"), do: nil
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)

View file

@ -329,7 +329,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col>
<:col :let={mft} label={gettext("Members")}>
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge>
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>

View file

@ -53,7 +53,7 @@
</:col>
<:col :let={role} label={gettext("Users")}>
<.badge variant="neutral">{get_user_count(role, @user_counts)}</.badge>
<span class="text-sm">{get_user_count(role, @user_counts)}</span>
</:col>
</.table>
</Layouts.app>

View file

@ -38,25 +38,25 @@
{user.role.name}
</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
<.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
<% else %>
<span class="text-base-content/70">{gettext("No member linked")}</span>
<% end %>
</.maybe_value>
</:col>
<:col :let={user} label={gettext("Password")}>
<%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
<.maybe_value
value={MvWeb.Helpers.UserHelpers.has_password?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Enabled")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</.maybe_value>
</:col>
<:col :let={user} label={gettext("OIDC")}>
<%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
<.maybe_value
value={MvWeb.Helpers.UserHelpers.has_oidc?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Linked")}</span>
<% else %>
<span class="text-base-content/70">—</span>
<% end %>
</.maybe_value>
</:col>
</.table>
</Layouts.app>