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" %>