From 0fe81cefe693be0ecb16538bbcf054d23d6d05ac Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sat, 6 Jun 2026 16:54:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(ds):=20DS::SegmentedControl=20=E2=80=94=20?= =?UTF-8?q?fix=20invisible=20dark=20selected=20pill=20(#2145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Juan José Mata --- .../sure-design-system/components.css | 36 +++++++++++++ app/components/DS/segmented_control.rb | 48 +++++++++++++++++ .../controllers/budget_filter_controller.js | 12 +++-- app/views/budgets/_budget_tabs.html.erb | 48 +++++++---------- config/locales/views/budgets/en.yml | 1 + test/components/DS/segmented_control_test.rb | 51 +++++++++++++++++++ .../segmented_control_component_preview.rb | 20 ++++++++ 7 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 app/components/DS/segmented_control.rb create mode 100644 test/components/DS/segmented_control_test.rb create mode 100644 test/components/previews/segmented_control_component_preview.rb diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css index 0d9103300..f529486fc 100644 --- a/app/assets/tailwind/sure-design-system/components.css +++ b/app/assets/tailwind/sure-design-system/components.css @@ -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` diff --git a/app/components/DS/segmented_control.rb b/app/components/DS/segmented_control.rb new file mode 100644 index 000000000..38c000e75 --- /dev/null +++ b/app/components/DS/segmented_control.rb @@ -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 diff --git a/app/javascript/controllers/budget_filter_controller.js b/app/javascript/controllers/budget_filter_controller.js index 2187a0f65..0d73e4f43 100644 --- a/app/javascript/controllers/budget_filter_controller.js +++ b/app/javascript/controllers/budget_filter_controller.js @@ -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)); }); } diff --git a/app/views/budgets/_budget_tabs.html.erb b/app/views/budgets/_budget_tabs.html.erb index cdb82ba31..71dd65458 100644 --- a/app/views/budgets/_budget_tabs.html.erb +++ b/app/views/budgets/_budget_tabs.html.erb @@ -1,31 +1,19 @@ +<% current_filter = %w[all over_budget on_track].include?(params[:filter]) ? params[:filter] : "all" %>
-
-
- - - - - - - -
-
-
\ No newline at end of file +
+ <%= 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 %> +
+ diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index 04f745c61..072fc4773 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -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: diff --git a/test/components/DS/segmented_control_test.rb b/test/components/DS/segmented_control_test.rb new file mode 100644 index 000000000..efb6e193d --- /dev/null +++ b/test/components/DS/segmented_control_test.rb @@ -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 diff --git a/test/components/previews/segmented_control_component_preview.rb b/test/components/previews/segmented_control_component_preview.rb new file mode 100644 index 000000000..5bc11f330 --- /dev/null +++ b/test/components/previews/segmented_control_component_preview.rb @@ -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