feat(design-system): split DS::Menu into strict action-list + new DS::Popover (#1850)

* feat(design-system): split DS::Menu into strict action-list + new DS::Popover for mixed content

Closes #1743.

DS::Menu used to absorb both action-list dropdowns (row context menus,
"more actions") AND mixed-content panels (user-account dropdown,
filter forms, picker pop-ups). The two shapes carry incompatible a11y
contracts:

- **Action list**: `role="menu"` container, `role="menuitem"` children,
  Up/Down arrow nav per WAI-ARIA APG.
- **Mixed content**: NO menu role — `role="menu"` restricts AT users
  to menuitem-only navigation and breaks any panel with forms,
  headings, or generic groupings.

This PR splits the component:

## DS::Menu (tightened)

Strict action-list primitive. Variants reduced to `:icon` and
`:button` (no `:avatar`). `custom_content` slot removed. Bakes in:

- `role="menu"` on the panel, `aria-haspopup="menu"` +
  `aria-expanded` + `aria-controls` on the trigger.
- `role="menuitem"` + `tabindex="-1"` on every DS::MenuItem; the
  controller installs roving tabindex (first item gets `tabindex="0"`
  when the menu opens) and handles ArrowUp/Down/Home/End +
  Escape + Enter/Space activation.
- `role="separator"` on the divider variant.
- Stable per-instance `menu-<8-char hex>` id so the trigger's
  `aria-controls` resolves correctly.

`DS::Menu.new(variant: :avatar, ...)` now raises ArgumentError
pointing at DS::Popover.

## DS::Popover (new)

Positioned panel for **mixed**, **non-action-list** content: account
menus, picker forms, filter forms, embedded controls. Slots: `button`,
`header`, `custom_content`. Variants: `:icon`, `:button`, `:avatar`.
NO `role="menu"` — the panel announces as a generic dialog-popup
(`aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`).
Mirrors DS::Menu's floating-ui positioning + Escape/outside-click
lifecycle in its own Stimulus controller (`DS--popover`). Avatar
variant ships a focus ring + bumped touch target (44×44 via `w-11
h-11` per #1738).

## Migrated callsites (7 → DS::Popover)

- `app/views/users/_user_menu.html.erb` — avatar trigger + profile
  header + nav links (items kept as DS::MenuItem inside
  `custom_content` for visual parity)
- `app/views/categories/_menu.html.erb` — turbo-framed category picker
- `app/views/budgets/_budget_header.html.erb` — budget picker
- `app/views/reports/index.html.erb` — period picker
- `app/views/holdings/_cost_basis_cell.html.erb` — cost-basis edit form
- `app/views/transactions/searches/_form.html.erb` — filter form
- `app/components/UI/account/activity_feed.html.erb:70` — status
  checkboxes (the row-level "new" menu on line 9 stays as DS::Menu)

The other 33 DS::Menu callsites stay as-is — pure action lists.

Locale: `ds.popover.avatar_default_label` + `users.user_menu.aria_label`
keys added (en only; other locales handled in a separate i18n pass).

* fix(test): update sidebar user-menu selector for Menu→Popover migration

The user-menu now renders as `DS::Popover` (variant: :avatar) instead
of `DS::Menu` after the menu split, so its trigger carries
`data-DS--popover-target="button"` rather than the old
`data-DS--menu-target`. Update the sidebar-driven settings test helper
to match — every system test that drives Settings via the sidebar
gates on this selector.

* fix(review): DS::Popover/Menu trigger a11y + caller-attr preservation

- popover.rb / menu.rb: button slot now merges (not overwrites) caller-
  provided data and aria hashes, sets aria-haspopup/expanded/controls on
  the :button variant, defaults type="button" on block-rendered buttons.
- menu.rb / menu.html.erb: drop renders_one :header (strict-menu API
  shouldn't expose an arbitrary-markup escape hatch); preview updated.
- menu_controller.js: handle Enter/Space activation on focused menuitem
  so keyboard navigation matches the ARIA menu pattern.
- cost_basis_cell / transactions/searches/_menu: retarget cancel button
  data-action from DS--menu#close to DS--popover#close (host controller
  changed in the migration).

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* fix(review): MenuItem roving: false for DS::Popover usage

Codex P1 on #1850: \`DS::MenuItem\` hard-codes \`tabindex=\"-1\"\` and
\`role=\"menuitem\"\` for both link and button variants — correct
inside \`DS::Menu\` (which provides arrow-key roving and announces
\`role=\"menu\"\`), but breaks every \`DS::MenuItem\` rendered inside
\`DS::Popover\` (\`app/views/users/_user_menu.html.erb\`). Popover has
no roving handler, so Tab skips every item — Settings, Changelog,
Feedback, Contact, Log out become keyboard-unreachable.

Add a \`roving:\` keyword (default \`true\`) to \`DS::MenuItem\` that
gates both \`tabindex=\"-1\"\` and \`role=\"menuitem\"\`. \`DS::Menu\`
callers keep the default (roving menu semantics intact). Pass
\`roving: false\` from \`_user_menu.html.erb\` so user-menu items land
in the normal Tab order. Existing \`menu.with_item(...)\` callers in
the design system still default to \`true\`, so no behavior change for
\`DS::Menu\` consumers.

* fix(review): make menuitem_attrs authoritative on roving

CodeRabbit Major on #1850: \`merged_opts\` was splatted AFTER
\`menuitem_attrs\` in \`DS::MenuItem#wrapper\`, so a stray
\`role: :button\` or \`tabindex: 0\` from a \`menu.with_item(..., role: …)\`
caller could silently downgrade the \`DS::Menu\` ARIA contract that
\`menuitem_attrs\` enforces.

Strip \`:role\` and \`:tabindex\` from \`merged_opts\` whenever
\`roving\` is enabled, then splat \`menuitem_attrs\` last. When
\`roving: false\` (popover usage in \`_user_menu.html.erb\`) callers
keep full control — Tab order and explicit ARIA stay tunable by the
caller.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Guillem Arias Fauste
2026-05-20 18:30:25 +02:00
committed by GitHub
parent 355648c4a6
commit 12785754c8
20 changed files with 391 additions and 106 deletions

View File

@@ -1,26 +1,16 @@
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", aria: { haspopup: "menu", expanded: "false", controls: menu_id }, data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<button data-DS--menu-target="button">
<div class="w-9 h-9 cursor-pointer">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div id="<%= menu_id %>" data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50" role="menu">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<% items.each do |item| %>
<%= item %>
<% end %>
<%= custom_content %>
<% end %>
<% end %>
</div>

View File

@@ -1,32 +1,40 @@
# frozen_string_literal: true
# `DS::Menu` is a strict action-list primitive. Children are `DS::MenuItem`
# (link / button / divider) only; the container announces as `role="menu"`,
# items as `role="menuitem"`, dividers as `role="separator"`. Arrow Up/Down
# and Home/End move focus across items (roving tabindex). Use **only** for
# flat clickable-action lists.
#
# Need a panel that hosts forms, pickers, headings, or user-account
# content? Use `DS::Popover` — `role="menu"` restricts AT users to
# menuitem-only navigation and breaks anything that isn't an action.
class DS::Menu < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width
attr_reader :variant, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width, :menu_id
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
options_with_target = button_options.deep_dup
options_with_target[:data] = (options_with_target[:data] || {}).merge(DS__menu_target: "button")
options_with_target[:aria] = (options_with_target[:aria] || {}).merge(
haspopup: "menu",
expanded: "false",
controls: menu_id
)
if block
options_with_target[:type] ||= "button"
content_tag(:button, **options_with_target, &block)
else
DS::Button.new(**options_with_target)
end
end
renders_one :header, ->(&block) do
content_tag(:div, class: "border-b border-tertiary", &block)
end
renders_one :custom_content
renders_many :items, DS::MenuItem
VARIANTS = %i[icon button avatar].freeze
VARIANTS = %i[icon button].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
def initialize(variant: "icon", placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@placement = placement
@offset = offset
@icon_vertical = icon_vertical
@@ -34,7 +42,8 @@ class DS::Menu < DesignSystemComponent
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
@menu_id = "menu-#{SecureRandom.hex(4)}"
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
raise ArgumentError, "Invalid variant: #{@variant}. DS::Menu is for action lists only; use DS::Popover for mixed content (forms, pickers, account dropdowns)." unless VARIANTS.include?(@variant)
end
end

View File

@@ -8,7 +8,10 @@ import {
import { Controller } from "@hotwired/stimulus";
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
* Strict action-list menu. Container is `role="menu"`, items are
* `role="menuitem"`. Arrow Up/Down moves focus between items, Home/End
* jumps to first/last, Escape closes the menu and returns focus to the
* trigger. Use DS::Popover for mixed-content panels (forms, pickers).
*/
export default class extends Controller {
static targets = ["button", "content"];
@@ -59,31 +62,71 @@ export default class extends Controller {
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
return;
}
if (!this.show) return;
const items = this.#menuItems();
if (items.length === 0) return;
const currentIndex = items.indexOf(event.target);
// Activate the focused item on Enter / Space (ARIA menu pattern).
// Without this, link-based menuitems can't be activated by keyboard
// once focus has moved off the native default.
if (event.key === "Enter" || event.key === " ") {
if (currentIndex < 0) return;
event.preventDefault();
items[currentIndex].click();
return;
}
let nextIndex = null;
switch (event.key) {
case "ArrowDown":
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
break;
case "ArrowUp":
nextIndex = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
items.forEach((item, i) => item.setAttribute("tabindex", i === nextIndex ? "0" : "-1"));
items[nextIndex].focus();
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
this.update();
this.focusFirstElement();
this.#focusFirstMenuItem();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus({ preventScroll: true });
}
#menuItems() {
return Array.from(this.contentTarget.querySelectorAll('[role="menuitem"]'));
}
#focusFirstMenuItem() {
const items = this.#menuItems();
if (items.length === 0) return;
items.forEach((item, i) => item.setAttribute("tabindex", i === 0 ? "0" : "-1"));
items[0].focus({ preventScroll: true });
}
startAutoUpdate() {

View File

@@ -1,7 +1,9 @@
<% if variant == :divider %>
<%= render "shared/ruler", classes: "my-1" %>
<div role="separator">
<%= render "shared/ruler", classes: "my-1" %>
</div>
<% else %>
<div class="px-1">
<div class="px-1" role="none">
<%= wrapper do %>
<% if icon %>
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>

View File

@@ -1,9 +1,14 @@
class DS::MenuItem < DesignSystemComponent
VARIANTS = %i[link button divider].freeze
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :roving, :opts
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)
# `roving: true` (default) emits `tabindex="-1"` and `role="menuitem"` — correct
# for `DS::Menu`, which provides arrow-key roving and announces `role="menu"`.
# `roving: false` omits both so items stay in the normal Tab order — required
# inside `DS::Popover`, which has no roving handler and is not a `role="menu"`
# container.
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, roving: true, **opts)
@variant = variant.to_sym
@text = text
@icon = icon
@@ -12,15 +17,22 @@ class DS::MenuItem < DesignSystemComponent
@destructive = destructive
@confirm = confirm
@frame = frame
@roving = roving
@opts = opts
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end
def wrapper(&block)
# When roving is on, `menuitem_attrs` is part of the `DS::Menu` ARIA contract
# and must win — strip any caller overrides of `role`/`tabindex` from
# `merged_opts` before splatting, so a stray `role: :button` or
# `tabindex: 0` can't downgrade keyboard/AT semantics.
html_opts = roving ? merged_opts.except(:role, :tabindex) : merged_opts
if variant == :button
button_to href, method: method, class: container_classes, **merged_opts, &block
button_to href, method: method, class: container_classes, **html_opts, **menuitem_attrs, &block
elsif variant == :link
link_to href, class: container_classes, **merged_opts, &block
link_to href, class: container_classes, **html_opts, **menuitem_attrs, &block
else
nil
end
@@ -38,6 +50,10 @@ class DS::MenuItem < DesignSystemComponent
end
private
def menuitem_attrs
roving ? { role: "menuitem", tabindex: "-1" } : {}
end
def container_classes
[
"flex items-center gap-2 p-2 rounded-md w-full",

View File

@@ -0,0 +1,32 @@
<%= tag.div data: { controller: "DS--popover", DS__popover_placement_value: placement, DS__popover_offset_value: offset, DS__popover_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon, aria_label: trigger_aria_label, aria: { haspopup: "dialog", expanded: "false", controls: panel_id }, data: { DS__popover_target: "button" }) %>
<% elsif variant == :button %>
<%= button %>
<% elsif variant == :avatar %>
<%# Avatar trigger needs an explicit accessible name — the inner
avatar image is decorative. Caller must pass `aria_label:` or
the fallback `ds.popover.avatar_default_label` is used. %>
<button type="button"
data-DS--popover-target="button"
class="inline-flex items-center justify-center w-11 h-11 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 theme-dark:focus-visible:outline-white"
aria-label="<%= trigger_aria_label %>"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="<%= panel_id %>">
<div class="w-9 h-9">
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
</div>
</button>
<% end %>
<div id="<%= panel_id %>" data-DS--popover-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
<%= custom_content %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
# `DS::Popover` is a positioned panel for **mixed, non-action-list** content:
# user-account menus, picker forms, filter forms, embedded controls. The
# panel hosts arbitrary markup and **does not** announce as a `role="menu"`
# — that role restricts AT users to menuitem-only navigation, which breaks
# any panel containing form inputs, headings, or generic groupings.
#
# Use `DS::Menu` instead when the panel is a flat list of clickable actions.
class DS::Popover < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon, :no_padding, :testid, :mobile_fullwidth, :max_width, :panel_id
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.deep_dup
options_with_target[:data] = (options_with_target[:data] || {}).merge(DS__popover_target: "button")
options_with_target[:aria] = {
haspopup: "dialog",
expanded: "false",
controls: panel_id
}.merge(options_with_target[:aria] || {})
if block
options_with_target[:type] ||= "button"
content_tag(:button, **options_with_target, &block)
else
DS::Button.new(**options_with_target)
end
end
renders_one :header, ->(&block) do
content_tag(:div, class: "border-b border-tertiary", &block)
end
renders_one :custom_content
VARIANTS = %i[icon button avatar].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon: "more-horizontal", no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil, aria_label: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@placement = placement
@offset = offset
@icon = icon
@no_padding = no_padding
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
@aria_label = aria_label
@panel_id = "popover-#{SecureRandom.hex(4)}"
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end
# Accessible name for the trigger button. The `:avatar` variant has no
# visible text, so the caller MUST pass `aria_label:`. `:icon` and
# `:button` variants fall back to DS::Button's icon-derived label and
# the slot's own text respectively.
def trigger_aria_label
@aria_label || (variant == :avatar ? I18n.t("ds.popover.avatar_default_label", default: "Open menu") : nil)
end
end

View File

@@ -0,0 +1,140 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
/**
* Positioned panel for mixed content (forms, pickers, account menus).
* Mirrors DS--menu's positioning + open/close lifecycle but skips the
* `role="menu"` / arrow-key navigation that's specific to action lists.
* Wiring `aria-expanded` on the trigger so AT users hear "expanded" /
* "collapsed" as the panel opens / closes.
*/
export default class extends Controller {
static targets = ["button", "content"];
static values = {
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
mobileFullwidth: { type: Boolean, default: true },
};
connect() {
this.show = this.showValue;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
this.startAutoUpdate();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
this.close();
}
addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
}
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
}
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
this.update();
this.focusFirstElement();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus({ preventScroll: true });
}
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.buttonTarget,
this.contentTarget,
this.boundUpdate,
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
if (!this.buttonTarget || !this.contentTarget) return;
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
computePosition(this.buttonTarget, this.contentTarget, {
placement: useMobileFullwidth ? "bottom" : this.placementValue,
middleware: [offset(this.offsetValue), flip({ padding: 5 }), shift({ padding: 5 })],
strategy: "fixed",
}).then(({ x, y }) => {
if (useMobileFullwidth) {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: "0px",
width: "100vw",
top: `${y}px`,
});
} else {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
width: "",
});
}
});
}
}

View File

@@ -67,8 +67,8 @@
</div>
</div>
<%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %>
<% menu.with_button(
<%= render DS::Popover.new(variant: "button", no_padding: true) do |popover| %>
<% popover.with_button(
id: "activity-status-filter-button",
type: "button",
text: t("accounts.show.activity.filter"),
@@ -76,7 +76,7 @@
icon: "list-filter"
) %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<div class="p-3 space-y-3 min-w-[160px]">
<p class="text-xs font-medium text-secondary uppercase"><%= t("accounts.show.activity.status") %></p>
<div class="flex items-center gap-3">

View File

@@ -27,13 +27,13 @@
<% end %>
</div>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<%= render DS::Popover.new(variant: "button") do |popover| %>
<% popover.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<span class="text-primary font-medium text-lg lg:text-base"><%= @budget.name %></span>
<%= icon("chevron-down") %>
<% end %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<%= render "budgets/picker", family: Current.family, year: budget.start_date.year %>
<% end %>
<% end %>

View File

@@ -1,7 +1,7 @@
<%# locals: (transaction:, in_split_group: false) %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(class: "block w-full overflow-hidden") do %>
<%= render DS::Popover.new(variant: "button") do |popover| %>
<% popover.with_button(class: "block w-full overflow-hidden") do %>
<div class="hidden min-w-0 w-full lg:flex">
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</div>
@@ -10,7 +10,7 @@
</div>
<% end %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, grouped: in_split_group), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>

View File

@@ -17,8 +17,8 @@
</div>
<% else %>
<%# Unlocked OR editable context (drawer) - show clickable menu %>
<%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %>
<% menu.with_button(class: "hover:text-primary cursor-pointer group") do %>
<%= render DS::Popover.new(variant: :button, placement: "bottom-end") do |popover| %>
<% popover.with_button(class: "hover:text-primary cursor-pointer group") do %>
<% if holding.avg_cost %>
<div class="flex items-center gap-1">
<%= tag.span format_money(holding.avg_cost), class: "privacy-sensitive" %>
@@ -34,7 +34,7 @@
</div>
<% end %>
<% end %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<div class="p-4 min-w-[280px]"
data-controller="cost-basis-form"
data-cost-basis-form-qty-value="<%= holding.qty %>">
@@ -96,7 +96,7 @@
<div class="flex justify-end gap-2 pt-2">
<button type="button"
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover"
data-action="click->DS--menu#close">
data-action="click->DS--popover#close">
<%= t(".cancel") %>
</button>
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>

View File

@@ -115,13 +115,13 @@
</div>
<% if [:monthly, :quarterly, :ytd].include?(@period_type) %>
<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<%= render DS::Popover.new(variant: "button") do |popover| %>
<% popover.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
<span class="text-primary font-medium text-lg lg:text-base"><%= @nav[:label] %></span>
<%= icon("chevron-down") %>
<% end %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<%= render "reports/period_picker", period_type: @period_type, start_date: @start_date %>
<% end %>
<% end %>

View File

@@ -17,8 +17,8 @@
</div>
</div>
<%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %>
<% menu.with_button(
<%= render DS::Popover.new(variant: "button", no_padding: true) do |popover| %>
<% popover.with_button(
id: "transaction-filters-button",
type: "button",
text: t(".filter"),
@@ -26,7 +26,7 @@
icon: "list-filter"
) %>
<% menu.with_custom_content do %>
<% popover.with_custom_content do %>
<%= render "transactions/searches/menu", form: form %>
<% end %>
<% end %>

View File

@@ -37,7 +37,7 @@
</div>
<div>
<%= render DS::Button.new(text: t(".cancel"), type: "button", variant: "ghost", data: { action: "DS--menu#close" }) %>
<%= render DS::Button.new(text: t(".cancel"), type: "button", variant: "ghost", data: { action: "DS--popover#close" }) %>
<%= render DS::Button.new(text: t(".apply"), type: :submit) %>
</div>
</div>

View File

@@ -3,19 +3,20 @@
<% intro_mode = local_assigns.fetch(:intro_mode, false) %>
<div data-testid="user-menu">
<%= render DS::Menu.new(
variant: "avatar",
<%# `DS::Popover` (not `DS::Menu`) because the panel includes a
decorative profile header alongside the action items — `role="menu"`
would restrict AT users to menuitem-only navigation and hide the
user-info block. %>
<%= render DS::Popover.new(
variant: intro_mode ? "icon" : "avatar",
avatar_url: user.profile_image&.variant(:small)&.url,
initials: user.initials,
placement: placement,
offset: offset
) do |menu| %>
<% if intro_mode %>
<% menu.with_button do %>
<%= render DS::Button.new(variant: "icon", icon: "settings", data: { DS__menu_target: "button" }) %>
<% end %>
<% end %>
<%= menu.with_header do %>
offset: offset,
icon: "settings",
aria_label: t(".aria_label", default: "Open account menu")
) do |popover| %>
<%= popover.with_header do %>
<div class="px-4 py-3 flex items-center gap-3">
<div class="w-9 h-9 shrink-0">
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, lazy: true %>
@@ -43,21 +44,27 @@
<% end %>
<% end %>
<% menu.with_item(
variant: "link",
text: t(".settings"),
icon: "settings",
href: intro_mode ? settings_profile_path : accounts_path(return_to: request.fullpath)
) %>
<% menu.with_item(variant: "link", text: t(".changelog"), icon: "box", href: changelog_path) %>
<% popover.with_custom_content do %>
<%# `roving: false` keeps these items in the normal Tab order. The parent
`DS::Popover` has no arrow-key roving handler and is not a `role="menu"`
container, so the default `tabindex="-1"` would skip every item. %>
<%= render DS::MenuItem.new(
variant: "link",
roving: false,
text: t(".settings"),
icon: "settings",
href: intro_mode ? settings_profile_path : accounts_path(return_to: request.fullpath)
) %>
<%= render DS::MenuItem.new(variant: "link", roving: false, text: t(".changelog"), icon: "box", href: changelog_path) %>
<% if self_hosted? && !intro_mode %>
<% menu.with_item(variant: "link", text: t(".feedback"), icon: "megaphone", href: feedback_path) %>
<% if self_hosted? && !intro_mode %>
<%= render DS::MenuItem.new(variant: "link", roving: false, text: t(".feedback"), icon: "megaphone", href: feedback_path) %>
<% end %>
<%= render DS::MenuItem.new(variant: "link", roving: false, text: t(".contact"), icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK", target: "_blank", rel: "noopener noreferrer") %>
<%= render DS::MenuItem.new(variant: "divider") %>
<%= render DS::MenuItem.new(variant: "button", roving: false, text: t(".log_out"), icon: "log-out", href: session_path(Current.session), method: :delete) %>
<% end %>
<% menu.with_item(variant: "link", text: t(".contact"), icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK", target: "_blank", rel: "noopener noreferrer") %>
<% menu.with_item(variant: "divider") %>
<% menu.with_item(variant: "button", text: t(".log_out"), icon: "log-out", href: session_path(Current.session), method: :delete) %>
<% end %>
</div>

View File

@@ -89,6 +89,8 @@ en:
default_label: Preview
dialog:
close: Close
popover:
avatar_default_label: Open menu
tooltip:
trigger_label: More info
link:

View File

@@ -21,6 +21,7 @@ en:
member: Member
super_admin: Super admin
user_menu:
aria_label: Open account menu
version: Version
settings: Settings
changelog: Changelog

View File

@@ -12,33 +12,14 @@ class MenuComponentPreview < ViewComponent::Preview
end
end
def avatar
render DS::Menu.new(variant: "avatar") do |menu|
menu_contents(menu)
end
end
private
def menu_contents(menu)
menu.with_header do
content_tag(:div, class: "p-3") do
content_tag(:h3, "Menu header", class: "font-medium text-primary")
end
end
menu.with_item(variant: "link", text: "Link", href: "#", icon: "plus")
menu.with_item(variant: "button", text: "Action", href: "#", method: :post, icon: "circle")
menu.with_item(variant: "button", text: "Action destructive", href: "#", method: :delete, icon: "circle")
menu.with_item(variant: "divider")
menu.with_custom_content do
content_tag(:div, class: "p-4") do
safe_join([
content_tag(:h3, "Custom content header", class: "font-medium text-primary"),
content_tag(:p, "Some custom content", class: "text-sm text-secondary")
])
end
end
menu.with_item(variant: "link", text: "Another link", href: "#", icon: "external-link")
end
end

View File

@@ -104,7 +104,7 @@ class SettingsTest < ApplicationSystemTestCase
def open_settings_from_sidebar
user_menu = find("div[data-testid=user-menu]", match: :first, visible: :visible)
within user_menu do
find("[data-DS--menu-target='button']", match: :first).click
find("[data-DS--popover-target='button']", match: :first).click
click_link "Settings", match: :first
end
end