mirror of
https://github.com/we-promise/sure.git
synced 2026-06-08 04:09:04 +00:00
feat(ds): DS::SegmentedControl — fix invisible dark selected pill (#2145)
* feat(ds): DS::SegmentedControl; fix invisible dark selected pill (#2137) Ships DS::SegmentedControl — a single-select pill group (filters, mode switches) — plus a Lookbook preview, tests, and an exemplar migration of the budget filter tabs. The audit flagged the dark selected pill as invisible: the bespoke controls paired a container-inset track (gray-800) with a gray-700 active pill, barely a step apart. The primitive uses the DS tab-token values (tab-bg-group -> a near-black alpha-black-700 track in dark), against which the gray-700 active pill clearly reads. Values are inlined with @variant theme-dark because @apply-ing the custom tab utilities drops their dark override. - app/components/DS/segmented_control.rb: slot-based with_segment(label, active:, href:, **opts); link or button; full_width: for equal footprint; selected style isolated in .segmented-control__segment--active so a controller can toggle it as one class. - .segmented-control recipe in components.css. - Migrate budgets/_budget_tabs; budget_filter_controller now toggles the single --active class instead of five raw utility classes. Verified in-browser: dark active pill reads (gray-700 on near-black track); filter toggle still works. Tests + rubocop clean. Deferred (follow-up): auth sign-in/up switch, transaction-type tabs, and other bespoke segmented controls — same primitive, one migration each. * fix(a11y): expose segmented-control selection + derive active from filter param - DS::SegmentedControl: set aria-current (links) / aria-pressed (buttons) from the segment's active state so screen readers announce the selection. - budget_filter_controller: mirror aria-pressed when it toggles the active class. - _budget_tabs: compute each segment's initial active: from params[:filter] so a ?filter=over_budget request server-renders the correct pill (no flash before Stimulus runs). Addresses CodeRabbit reviews on #2145. * fix(ds): close unterminated segmented-control CSS rule The --active rule swallowed the table-scroll block's comment opener and never closed, so the Tailwind build died with 'Missing closing } at @layer components' and both test jobs failed at boot. Restore the closing brace and the /* opener. Also add the budgets.show.filter.aria_label locale key the budget tabs view referenced only through its inline default. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f7be206c55
commit
0fe81cefe6
@@ -160,6 +160,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Segmented control (#2137) — track + equal-footprint segments. The selected
|
||||
state is a single `--active` class so an optional Stimulus controller can
|
||||
toggle it as one unit. Values mirror the DS tab tokens (tab-bg-group /
|
||||
tab-item-active / tab-item-hover) but are inlined with `@variant theme-dark`
|
||||
because `@apply`-ing a custom utility drops its dark override. The near-black
|
||||
dark track (alpha-black-700) is what makes the gray-700 selected pill read —
|
||||
the bespoke controls used a too-light `container-inset` track, hence the
|
||||
audit's "selected pill invisible on dark." Focus = neutral outline (matches
|
||||
the canonical focus ring; inlined until that token lands on main).
|
||||
*/
|
||||
.segmented-control {
|
||||
@apply inline-flex items-center gap-0.5 p-1 rounded-lg bg-gray-50;
|
||||
@variant theme-dark {
|
||||
@apply bg-alpha-black-700;
|
||||
}
|
||||
}
|
||||
|
||||
.segmented-control__segment {
|
||||
@apply inline-flex items-center justify-center px-2 py-1 rounded-md whitespace-nowrap;
|
||||
@apply text-sm font-medium text-secondary cursor-pointer transition-colors duration-200;
|
||||
@apply hover:bg-gray-200;
|
||||
@apply focus-visible:outline-2 focus-visible:outline-offset-2;
|
||||
@apply focus-visible:outline-alpha-black-400 theme-dark:focus-visible:outline-alpha-white-400;
|
||||
@variant theme-dark {
|
||||
@apply hover:bg-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
.segmented-control__segment--active {
|
||||
@apply bg-white text-primary shadow-sm hover:bg-white;
|
||||
@variant theme-dark {
|
||||
@apply bg-gray-700 hover:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Horizontally scrollable table wrapper (#2137). `overflow-x: auto` so wide
|
||||
tables scroll instead of clipping (the LLM-usage table was `overflow-hidden`
|
||||
|
||||
48
app/components/DS/segmented_control.rb
Normal file
48
app/components/DS/segmented_control.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class DS::SegmentedControl < DesignSystemComponent
|
||||
# A single-select pill group — filters, mode switches, compact view toggles.
|
||||
# NOT the full ARIA tab/panel widget; use DS::Tabs for tabs-with-panels.
|
||||
#
|
||||
# Each segment is a link (pass `href:`) or a button (default — pass `data:`
|
||||
# for a Stimulus-driven control). Mark the current one with `active: true`.
|
||||
# The selected style lives in `.segmented-control__segment--active`, so a
|
||||
# controller can toggle selection by flipping that one class.
|
||||
#
|
||||
# `full_width: true` stretches segments to equal width (the "equal-footprint"
|
||||
# the #2137 audit asked for); default is content width.
|
||||
renders_many :segments, ->(label, active: false, href: nil, **opts) do
|
||||
classes = class_names(
|
||||
"segmented-control__segment",
|
||||
("flex-1" if full_width),
|
||||
("segmented-control__segment--active" if active),
|
||||
opts.delete(:class)
|
||||
)
|
||||
|
||||
# Expose the selected state to assistive tech: link segments use
|
||||
# `aria-current`, button segments use `aria-pressed`. A Stimulus
|
||||
# controller that toggles `--active` should mirror these (see
|
||||
# budget_filter_controller#filterValueChanged).
|
||||
if href
|
||||
link_to(label, href, class: classes, "aria-current": (active ? "true" : nil), **opts)
|
||||
else
|
||||
content_tag(:button, label, type: "button", class: classes, "aria-pressed": active.to_s, **opts)
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :full_width, :aria_label
|
||||
|
||||
def initialize(full_width: false, aria_label: nil, **opts)
|
||||
@full_width = full_width
|
||||
@aria_label = aria_label
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
erb_template <<~ERB
|
||||
<%= content_tag :div,
|
||||
class: class_names("segmented-control", ("w-full" if full_width), @opts[:class]),
|
||||
role: "group",
|
||||
"aria-label": aria_label,
|
||||
**@opts.except(:class) do %>
|
||||
<% segments.each do |segment| %><%= segment %><% end %>
|
||||
<% end %>
|
||||
ERB
|
||||
end
|
||||
@@ -32,11 +32,13 @@ export default class extends Controller {
|
||||
|
||||
this.tabTargets.forEach((tab) => {
|
||||
const isActive = tab.dataset.budgetFilterFilterParam === filter;
|
||||
tab.classList.toggle("bg-white", isActive);
|
||||
tab.classList.toggle("theme-dark:bg-gray-700", isActive);
|
||||
tab.classList.toggle("text-primary", isActive);
|
||||
tab.classList.toggle("shadow-sm", isActive);
|
||||
tab.classList.toggle("text-secondary", !isActive);
|
||||
// Selected styling is encapsulated in the DS::SegmentedControl active
|
||||
// class; the base `.segmented-control__segment` already carries the
|
||||
// inactive (text-secondary) state.
|
||||
tab.classList.toggle("segmented-control__segment--active", isActive);
|
||||
// Keep assistive tech in sync with the visual selection (these are
|
||||
// button segments, so aria-pressed).
|
||||
tab.setAttribute("aria-pressed", String(isActive));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
<% current_filter = %w[all over_budget on_track].include?(params[:filter]) ? params[:filter] : "all" %>
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="max-w-full w-full lg:w-auto overflow-x-auto no-scrollbar">
|
||||
<div class="w-full inline-flex whitespace-nowrap bg-container-inset rounded-lg p-1 text-sm font-medium gap-0.5">
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="all"
|
||||
data-budget-filter-target="tab"
|
||||
class="w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200 bg-white theme-dark:bg-gray-700 text-primary shadow-sm">
|
||||
<%= t("budgets.show.filter.all") %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="over_budget"
|
||||
data-budget-filter-target="tab"
|
||||
class="w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200 shadow-sm">
|
||||
<%= t("budgets.show.filter.over_budget") %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="on_track"
|
||||
data-budget-filter-target="tab"
|
||||
class="w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200 shadow-sm">
|
||||
<%= t("budgets.show.filter.on_track") %>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-full w-full lg:w-auto overflow-x-auto no-scrollbar">
|
||||
<%= render DS::SegmentedControl.new(
|
||||
full_width: true,
|
||||
aria_label: t("budgets.show.filter.aria_label", default: "Filter budget categories"),
|
||||
class: "w-full lg:w-auto") do |sc| %>
|
||||
<% sc.with_segment t("budgets.show.filter.all"),
|
||||
active: current_filter == "all",
|
||||
data: { action: "click->budget-filter#setFilter", budget_filter_filter_param: "all", budget_filter_target: "tab" } %>
|
||||
<% sc.with_segment t("budgets.show.filter.over_budget"),
|
||||
active: current_filter == "over_budget",
|
||||
data: { action: "click->budget-filter#setFilter", budget_filter_filter_param: "over_budget", budget_filter_target: "tab" } %>
|
||||
<% sc.with_segment t("budgets.show.filter.on_track"),
|
||||
active: current_filter == "on_track",
|
||||
data: { action: "click->budget-filter#setFilter", budget_filter_filter_param: "on_track", budget_filter_target: "tab" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ en:
|
||||
title: Over Budget
|
||||
filter:
|
||||
all: All
|
||||
aria_label: Filter budget categories
|
||||
on_track: On Track
|
||||
over_budget: Over Budget
|
||||
tabs:
|
||||
|
||||
51
test/components/DS/segmented_control_test.rb
Normal file
51
test/components/DS/segmented_control_test.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
|
||||
class DS::SegmentedControlTest < ViewComponent::TestCase
|
||||
test "renders segments as buttons by default with the base + active classes" do
|
||||
render_inline(DS::SegmentedControl.new) do |sc|
|
||||
sc.with_segment("All", active: true)
|
||||
sc.with_segment("Over Budget")
|
||||
end
|
||||
|
||||
assert_selector "div.segmented-control[role=group]"
|
||||
assert_selector "button.segmented-control__segment", count: 2
|
||||
assert_selector "button.segmented-control__segment--active", text: "All", count: 1
|
||||
refute_selector "button.segmented-control__segment--active", text: "Over Budget"
|
||||
end
|
||||
|
||||
test "href renders a segment as a link" do
|
||||
render_inline(DS::SegmentedControl.new) do |sc|
|
||||
sc.with_segment("Sign in", href: "/login", active: true)
|
||||
sc.with_segment("Sign up", href: "/join")
|
||||
end
|
||||
|
||||
assert_selector "a.segmented-control__segment--active[href='/login']", text: "Sign in"
|
||||
assert_selector "a.segmented-control__segment[href='/join']", text: "Sign up"
|
||||
end
|
||||
|
||||
test "full_width stretches the track and each segment" do
|
||||
render_inline(DS::SegmentedControl.new(full_width: true)) do |sc|
|
||||
sc.with_segment("One", active: true)
|
||||
sc.with_segment("Two")
|
||||
end
|
||||
|
||||
assert_selector "div.segmented-control.w-full"
|
||||
assert_selector "button.segmented-control__segment.flex-1", count: 2
|
||||
end
|
||||
|
||||
test "aria_label and passthrough attrs land on the wrapper" do
|
||||
render_inline(DS::SegmentedControl.new(aria_label: "Filter", data: { controller: "x" })) do |sc|
|
||||
sc.with_segment("A", active: true)
|
||||
end
|
||||
|
||||
assert_selector "div.segmented-control[aria-label='Filter'][data-controller='x']"
|
||||
end
|
||||
|
||||
test "per-segment passthrough class and data merge onto the segment" do
|
||||
render_inline(DS::SegmentedControl.new) do |sc|
|
||||
sc.with_segment("A", active: true, class: "custom-x", data: { id: "a" })
|
||||
end
|
||||
|
||||
assert_selector "button.segmented-control__segment--active.custom-x[data-id='a']"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class SegmentedControlComponentPreview < ViewComponent::Preview
|
||||
# @display container_classes max-w-[480px]
|
||||
# @param full_width toggle
|
||||
def default(full_width: true)
|
||||
render DS::SegmentedControl.new(full_width: full_width, aria_label: "Budget filter") do |sc|
|
||||
sc.with_segment("All", active: true)
|
||||
sc.with_segment("Over Budget")
|
||||
sc.with_segment("On Track")
|
||||
end
|
||||
end
|
||||
|
||||
# Link segments (server-selected mode switch, e.g. the auth sign-in/up tabs).
|
||||
# @display container_classes max-w-[320px]
|
||||
def links
|
||||
render DS::SegmentedControl.new(full_width: true, aria_label: "Auth mode") do |sc|
|
||||
sc.with_segment("Sign in", href: "#", active: true)
|
||||
sc.with_segment("Sign up", href: "#")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user