Files
sure/app/components/DS/segmented_control.rb
Guillem Arias Fauste 0fe81cefe6 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>
2026-06-06 16:54:15 +02:00

49 lines
1.9 KiB
Ruby

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