Files
sure/app/views/pages/dashboard/_outflows_donut.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

119 lines
6.0 KiB
Plaintext

<%# locals: (outflows_data:, period:) %>
<div id="outflows-donut-section" class="@container">
<div class="px-4">
<div class="flex flex-col @2xl:flex-row gap-8 items-center">
<!-- Donut Chart -->
<div class="w-full @2xl:w-1/3 max-w-[300px] px-4">
<div class="h-[300px] relative"
data-controller="donut-chart"
data-donut-chart-segments-value="<%= outflows_data[:categories].to_json %>"
data-donut-chart-segment-opacity-value="0.9"
data-donut-chart-extended-hover-value="true"
data-donut-chart-hover-extension-value="3"
data-donut-chart-enable-click-value="true"
data-donut-chart-start-date-value="<%= period.date_range.first %>"
data-donut-chart-end-date-value="<%= period.date_range.last %>">
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<div class="text-secondary text-sm mb-2">
<span><%= t("pages.dashboard.outflows_donut.total_outflows") %></span>
</div>
<div data-donut-chart-target="amount" class="text-3xl font-medium text-primary privacy-sensitive whitespace-nowrap">
<%= format_money Money.new(outflows_data[:total], outflows_data[:currency]) %>
</div>
</div>
<% outflows_data[:categories].each do |category| %>
<div id="segment_<%= category[:id] %>" class="hidden">
<div class="flex flex-col gap-2 items-center">
<p class="text-sm text-secondary"><%= category[:name] %></p>
<p data-donut-chart-target="amount" class="text-3xl font-medium text-primary privacy-sensitive whitespace-nowrap">
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ",") %>
</p>
<p class="text-sm text-secondary privacy-sensitive"><%= category[:percentage] %>%</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Category List -->
<div class="w-full @2xl:w-2/3 bg-container-inset rounded-xl p-1 space-y-1 overflow-x-auto">
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
<div class="w-16 @2xl:w-40">
<p><%= t("pages.dashboard.outflows_donut.categories") %><span class="text-subdued mx-1">&middot;</span><%= outflows_data[:categories].count %></p>
</div>
<div class="ml-auto text-right flex items-center gap-6">
<div class="w-16 @2xl:w-40">
<p><%= t("pages.dashboard.outflows_donut.value") %></p>
</div>
<div class="w-16">
<p><%= t("pages.dashboard.outflows_donut.weight") %></p>
</div>
</div>
</div>
<div class="py-3 shadow-border-xs rounded-lg bg-container font-medium text-sm max-w-full">
<% outflows_data[:categories].each_with_index do |category, idx| %>
<%
category_content = capture do
%>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="h-7 w-7 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
style="
background-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
border-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
color: <%= category[:color] %>;">
<% if category[:icon] %>
<%= icon(category[:icon], color: "current", size: "sm") %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
hex_color: category[:color],
text: category[:name],
size: "sm",
rounded: true
) %>
<% end %>
</div>
<span class="text-sm font-medium text-primary truncate"><%= category[:name] %></span>
</div>
<div class="flex items-center gap-4 flex-shrink-0 text-right">
<span class="text-sm font-medium text-primary whitespace-nowrap privacy-sensitive"><%= format_money Money.new(category[:amount], category[:currency]) %></span>
<span class="text-sm text-secondary whitespace-nowrap w-10 @2xl:w-15 privacy-sensitive"><%= category[:percentage] %>%</span>
</div>
<% end %>
<% if category[:clickable] != false %>
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
data: {
turbo_frame: "_top",
turbo_prefetch: false,
category_id: category[:id],
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
} do %>
<%= category_content %>
<% end %>
<% else %>
<div class="flex items-center justify-between mx-3 p-3 rounded-lg group gap-3"
data-category-id="<%= category[:id] %>"
data-action="mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment">
<%= category_content %>
</div>
<% end %>
<% if idx < outflows_data[:categories].size - 1 %>
<%= render "shared/ruler", classes: "mx-3 @2xl:mx-4" %>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
</div>