fix(ds): unify tab/chip controls on DS::SegmentedControl (#8) (#2284)

* fix(ds): goals status filter -> DS::SegmentedControl

The goals status chips were a hand-rolled segmented control: active state
toggled via ad-hoc bg-container/shadow-border-xs/text-* classes, NO hover on
inactive chips, and a light-only focus ring (ring-alpha-black-100, invisible in
dark mode). Migrate to DS::SegmentedControl button segments + toggle the
canonical --active class in goals_filter_controller#syncChipState. Gains the
dark-safe hover (hover:bg-gray-200 / theme-dark:hover:bg-gray-800) and the
canonical focus ring. First control in the tab-consistency pass (#8).

* fix(ds): provider filter chips -> DS::SegmentedControl

Same hand-rolled segmented control as the goals chips: active toggled via
ad-hoc bg-container/shadow/text classes, no inactive hover, light-only
ring-alpha-black-100 focus ring. Migrate to DS::SegmentedControl + toggle the
canonical --active class in providers_filter_controller#syncChipState.

* fix(ds): transaction-type tabs -> DS::SegmentedControl

Expense/Income/Transfer tabs were hand-rolled with a hover==active affordance
bug: inactive hover raised bg to bg-container, identical to the active state, so
hover and selected were indistinguishable. Migrate to DS::SegmentedControl (link
segments + icons); the controller toggles the canonical --active class instead
of swapping ad-hoc ACTIVE/INACTIVE class lists. The client-side nature switch
(expense<->income updates the form's hidden nature field without navigating) is
preserved -- verified live: switching to Income flips the active segment AND
sets the nature field to inflow.

* fix(ds): reports period tabs -> DS::SegmentedControl

The Monthly/Quarterly/YTD/Last-6-Months/Custom period selector was five
DS::Link ghost/secondary buttons -- a different tab idiom than the rest of the
app. Migrate to DS::SegmentedControl link segments (server-rendered active via
aria-current; pure navigation, no controller). Now matches the goals / provider /
transaction-type controls + the AI provider picker. Also autocorrected a
pre-existing single-quote in the next-period aria-label.
This commit is contained in:
Guillem Arias Fauste
2026-06-11 15:47:00 +02:00
committed by GitHub
parent 2defccf366
commit d29591cff9
7 changed files with 67 additions and 101 deletions

View File

@@ -155,10 +155,7 @@ export default class extends Controller {
this.chipTargets.forEach((chip) => {
const active = chip.dataset.status === this.statusValue;
chip.setAttribute("aria-pressed", active);
chip.classList.toggle("bg-container", active);
chip.classList.toggle("shadow-border-xs", active);
chip.classList.toggle("text-primary", active);
chip.classList.toggle("text-secondary", !active);
chip.classList.toggle("segmented-control__segment--active", active);
});
}
}

View File

@@ -58,10 +58,7 @@ export default class extends Controller {
if (!this.hasChipTarget) return;
this.chipTargets.forEach((chip) => {
const active = chip.dataset.kind === this.kindValue;
chip.classList.toggle("bg-container", active);
chip.classList.toggle("shadow-border-xs", active);
chip.classList.toggle("text-primary", active);
chip.classList.toggle("text-secondary", !active);
chip.classList.toggle("segmented-control__segment--active", active);
chip.setAttribute("aria-pressed", active ? "true" : "false");
});
}

View File

@@ -1,21 +1,26 @@
import { Controller } from "@hotwired/stimulus"
const ACTIVE_CLASSES = ["bg-container", "text-primary", "shadow-sm"]
const INACTIVE_CLASSES = ["hover:bg-container", "text-subdued", "hover:text-primary", "hover:shadow-sm"]
import { Controller } from "@hotwired/stimulus";
// Expense / income tabs render as a DS::SegmentedControl. Switching is
// client-side here: it updates the form's hidden nature field and flips the
// active segment without navigating (the href is a progressive-enhancement
// fallback). Transfer is a plain link to the transfer form.
export default class extends Controller {
static targets = ["tab", "natureField"]
static targets = ["tab", "natureField"];
selectTab(event) {
event.preventDefault()
event.preventDefault();
const selectedTab = event.currentTarget
this.natureFieldTarget.value = selectedTab.dataset.nature
const selectedTab = event.currentTarget;
this.natureFieldTarget.value = selectedTab.dataset.nature;
this.tabTargets.forEach(tab => {
const isActive = tab === selectedTab
tab.classList.remove(...(isActive ? INACTIVE_CLASSES : ACTIVE_CLASSES))
tab.classList.add(...(isActive ? ACTIVE_CLASSES : INACTIVE_CLASSES))
})
this.tabTargets.forEach((tab) => {
const isActive = tab === selectedTab;
tab.classList.toggle("segmented-control__segment--active", isActive);
if (isActive) {
tab.setAttribute("aria-current", "true");
} else {
tab.removeAttribute("aria-current");
}
});
}
}

View File

@@ -125,19 +125,18 @@
horizontally when the column is too narrow for all six chips,
rather than wrapping "On track" mid-label or forcing the row
wider than the viewport. %>
<div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-xl overflow-x-auto max-w-full">
<%= render DS::SegmentedControl.new(aria_label: t(".chips_aria", default: "Filter goals by status"), class: "overflow-x-auto max-w-full") do |sc| %>
<% %w[all on_track behind no_target_date paused completed].each do |status| %>
<% active = status == "all" %>
<button type="button"
data-goals-filter-target="chip"
data-action="click->goals-filter#selectChip"
data-status="<%= status %>"
aria-pressed="<%= active %>"
class="shrink-0 whitespace-nowrap px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
<%= t(".chips.#{status}") %>
</button>
<% sc.with_segment(t(".chips.#{status}"),
active: status == "all",
class: "shrink-0 whitespace-nowrap",
data: {
"goals-filter-target": "chip",
status: status,
action: "click->goals-filter#selectChip"
}) %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -30,38 +30,14 @@
<%# Period Navigation Tabs %>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 overflow-x-auto no-scrollbar">
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
href: reports_path(period_type: :monthly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.quarterly"),
variant: @period_type == :quarterly ? "secondary" : "ghost",
href: reports_path(period_type: :quarterly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.ytd"),
variant: @period_type == :ytd ? "secondary" : "ghost",
href: reports_path(period_type: :ytd),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.last_6_months"),
variant: @period_type == :last_6_months ? "secondary" : "ghost",
href: reports_path(period_type: :last_6_months),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.custom"),
variant: @period_type == :custom ? "secondary" : "ghost",
href: reports_path(period_type: :custom),
size: :sm
) %>
</div>
<%= render DS::SegmentedControl.new(aria_label: t("reports.index.period_aria", default: "Report period"), class: "overflow-x-auto no-scrollbar") do |sc| %>
<% %i[monthly quarterly ytd last_6_months custom].each do |ptype| %>
<% sc.with_segment(t("reports.index.periods.#{ptype}"),
active: @period_type == ptype,
href: reports_path(period_type: ptype),
class: "whitespace-nowrap") %>
<% end %>
<% end %>
</div>
<%# Custom Date Range Picker (only shown when custom is selected) %>

View File

@@ -8,17 +8,15 @@
action: "input->providers-filter#filter"
}
) %>
<div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-xl">
<%= render DS::SegmentedControl.new(aria_label: t("settings.providers.search_filters.aria_label")) do |sc| %>
<% %w[all bank crypto investment].each do |kind| %>
<% active = kind == "all" %>
<button type="button"
data-providers-filter-target="chip"
data-action="click->providers-filter#selectChip"
data-kind="<%= kind %>"
aria-pressed="<%= active %>"
class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
<%= t("settings.providers.search_filters.chips.#{kind}") %>
</button>
<% sc.with_segment(t("settings.providers.search_filters.chips.#{kind}"),
active: kind == "all",
data: {
"providers-filter-target": "chip",
kind: kind,
action: "click->providers-filter#selectChip"
}) %>
<% end %>
</div>
<% end %>
</div>

View File

@@ -1,30 +1,24 @@
<%# locals: (active_tab:, account_id: nil) %>
<fieldset class="bg-surface-inset rounded-lg p-1 flex flex-wrap gap-2">
<%= link_to new_transaction_path(nature: "outflow", account_id: account_id),
data: { turbo_frame: :modal, action: "click->transaction-type-tabs#selectTab", nature: "outflow", "transaction-type-tabs-target": "tab" },
class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'expense' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %>
<%= icon "minus-circle" %>
<%= tag.span class: "truncate" do %>
<%= t("shared.transaction_tabs.expense") %>
<% end %>
<% end %>
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("shared.transaction_tabs.aria", default: "Transaction type")) do |sc| %>
<% sc.with_segment(
safe_join([ icon("minus-circle"), tag.span(t("shared.transaction_tabs.expense"), class: "truncate") ]),
active: active_tab == "expense",
href: new_transaction_path(nature: "outflow", account_id: account_id),
class: "min-w-0 gap-2",
data: { turbo_frame: :modal, action: "click->transaction-type-tabs#selectTab", nature: "outflow", "transaction-type-tabs-target": "tab" }) %>
<%= link_to new_transaction_path(nature: "inflow", account_id: account_id),
data: { turbo_frame: :modal, action: "click->transaction-type-tabs#selectTab", nature: "inflow", "transaction-type-tabs-target": "tab" },
class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'income' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %>
<%= icon "plus-circle" %>
<%= tag.span class: "truncate" do %>
<%= t("shared.transaction_tabs.income") %>
<% end %>
<% end %>
<% sc.with_segment(
safe_join([ icon("plus-circle"), tag.span(t("shared.transaction_tabs.income"), class: "truncate") ]),
active: active_tab == "income",
href: new_transaction_path(nature: "inflow", account_id: account_id),
class: "min-w-0 gap-2",
data: { turbo_frame: :modal, action: "click->transaction-type-tabs#selectTab", nature: "inflow", "transaction-type-tabs-target": "tab" }) %>
<%= link_to new_transfer_path(from_account_id: account_id),
data: { turbo_frame: :modal },
class: "flex-1 min-w-0 flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'transfer' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}" do %>
<%= icon "arrow-right-left" %>
<%= tag.span class: "truncate" do %>
<%= t("shared.transaction_tabs.transfer") %>
<% end %>
<% end %>
</fieldset>
<% sc.with_segment(
safe_join([ icon("arrow-right-left"), tag.span(t("shared.transaction_tabs.transfer"), class: "truncate") ]),
active: active_tab == "transfer",
href: new_transfer_path(from_account_id: account_id),
class: "min-w-0 gap-2",
data: { turbo_frame: :modal }) %>
<% end %>