Files
sure/app/views/pages/dashboard.html.erb
Guillem Arias Fauste 96079188a2 feat(dashboard): unify per-widget period selectors into one picker (#2162)
* feat(ds): elevate dropdown overlays and stabilize selection check gutter

Menus and popovers floated at the same elevation as inline cards
(shadow-border-xs), so dropdowns blended into the content beneath them.
Bump DS::Menu and DS::Popover panels to shadow-border-lg.

DS::MenuItem rendered its leading icon only when present, so a selection
check shifted the row's text out of alignment with the unselected rows.
Add a `selected:` param that reserves a fixed-width check gutter (check
when selected, empty otherwise) so row text stays aligned. Apply the same
reserved gutter to the bespoke category dropdown row, and add a
`selectable` menu preview.

* feat(dashboard): unify per-widget period selectors into one picker

The dashboard rendered three identical period <select>s (cashflow,
outflows, net worth), each writing the same global User#default_period
and full-reloading the page via turbo_frame "_top" — so changing one
changed all. Replace them with a single shared UI::PeriodPicker
(DS::Menu of period links) in a toolbar, and wrap the sections grid in a
"dashboard_sections" Turbo frame so a period change swaps only the
dashboard (no full-page reload). Reuse the same picker on the account
chart, removing its duplicate select.

* refactor(dashboard): use DS::MenuItem selected: gutter in period picker

Now that DS::MenuItem reserves a check gutter, the period picker passes
selected: instead of a conditional leading check icon, so the current
period's row stays aligned with the rest.

* fix(ds): expose menu selection via menuitemradio + aria-checked

Selectable DS::MenuItem rows conveyed selection only visually. Render them
as role="menuitemradio" with aria-checked so assistive tech gets the
selection state of single-select lists, merging the menu ARIA contract with
any caller-supplied aria. Addresses CodeRabbit review feedback.

* refactor(dashboard): drop redundant aria-current from period picker

DS::MenuItem now exposes selection via menuitemradio + aria-checked, so the
period picker no longer needs its own aria-current. Update the component
test to assert the new ARIA.

* fix(ds): include selectable roles in menu roving-focus query

DS::MenuItem selectable rows render as role=menuitemradio, but the menu
controller built its roving-focus list from [role=menuitem] only, leaving
single-select menus with no keyboard focus/arrow handling. Query the
menuitemradio/menuitemcheckbox roles too. Addresses Codex review feedback.

* fix(dashboard): keep non-picker links out of frame + fix custom-month picker

- turbo_frame_tag "dashboard_sections" now targets _top so ordinary links
  inside the sections (e.g. Balance Sheet account links) navigate the page
  instead of failing to resolve inside the frame.
- Period.current_month_for / last_month_for carry their semantic key for
  custom-month families, so the picker shows the right label and checks the
  right option instead of falling back to 30D.

Addresses Codex review feedback.

* fix(dashboard): announce selected period in picker trigger's accessible name

The static "Select time period" aria-label overrode the visible selected
label as the trigger's accessible name, so assistive tech kept announcing
the same name regardless of selection. Interpolate the selected short
label into the aria-label and pin it with a component test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:11:43 +02:00

108 lines
4.8 KiB
Plaintext

<% content_for :page_header do %>
<div class="space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">
<%= t("pages.dashboard.welcome", name: Current.user.first_name) %>
</h1>
<p class="text-sm lg:text-base text-secondary">
<%= t("pages.dashboard.subtitle") %>
</p>
</div>
<div class="flex items-center gap-2">
<%= render DS::Link.new(
icon: "plus",
text: t("pages.dashboard.new"),
href: new_account_path,
frame: :modal,
class: "hidden lg:inline-flex"
) %>
<%= render DS::Link.new(
variant: "icon-inverse",
icon: "plus",
href: new_account_path,
frame: :modal,
class: "rounded-full lg:hidden"
) %>
</div>
</div>
<% end %>
<%= turbo_frame_tag "dashboard_sections", target: "_top" do %>
<% if accessible_accounts.any? %>
<div class="flex items-center justify-end mb-4">
<%= render UI::PeriodPicker.new(selected: @period, url: root_path, frame: "dashboard_sections") %>
</div>
<% end %>
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections">
<% if accessible_accounts.any? %>
<% @dashboard_sections.each do |section| %>
<% next unless section[:visible] %>
<section
class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
data-dashboard-sortable-target="section"
data-section-key="<%= section[:key] %>"
data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>"
data-dashboard-section-section-key-value="<%= section[:key] %>"
data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>"
draggable="true"
tabindex="0"
role="listitem"
aria-grabbed="false"
aria-label="<%= t(section[:title]) %> section. Press Enter or Space to grab for reordering, then use arrow keys to move."
data-action="
dragstart->dashboard-sortable#dragStart
dragend->dashboard-sortable#dragEnd
touchstart->dashboard-sortable#touchStart
touchmove->dashboard-sortable#touchMove
touchend->dashboard-sortable#touchEnd
touchcancel->dashboard-sortable#touchEnd
keydown->dashboard-sortable#handleKeyDown">
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<button
type="button"
class="text-secondary hover:text-primary transition-colors p-0.5"
data-action="click->dashboard-section#toggle keydown->dashboard-section#handleToggleKeydown"
data-dashboard-section-target="button"
aria-label="<%= t("pages.dashboard.toggle_section") %>"
aria-expanded="<%= !Current.user.dashboard_section_collapsed?(section[:key]) %>">
<%= icon("chevron-down", size: "sm", class: "transition-transform", data: { dashboard_section_target: "chevron" }) %>
</button>
<h2 class="text-base font-medium">
<%= t(section[:title]) %>
</h2>
</div>
<div class="flex items-center gap-1">
<% if section[:key] == "cashflow_sankey" && section[:locals][:sankey_data][:links].present? %>
<button
type="button"
class="text-secondary hover:text-primary transition-colors opacity-100 lg:opacity-0 lg:group-hover:opacity-100 flex items-center justify-center w-5 h-5 ml-auto lg:ml-0"
data-action="click->cashflow-expand#open"
aria-label="<%= t("global.expand") %>">
<%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %>
</button>
<% end %>
<button
type="button"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 hidden lg:block"
aria-label="<%= t("pages.dashboard.drag_to_reorder") %>">
<%= icon("grip-vertical", size: "sm") %>
</button>
</div>
</div>
<div class="py-4" data-dashboard-section-target="content">
<%= render partial: section[:partial], locals: section[:locals] %>
</div>
</section>
<% end %>
<% else %>
<section>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
</section>
<% end %>
</div>
<% end %>