mirror of
https://github.com/we-promise/sure.git
synced 2026-05-28 06:54:56 +00:00
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:
committed by
GitHub
parent
355648c4a6
commit
12785754c8
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
32
app/components/DS/popover.html.erb
Normal file
32
app/components/DS/popover.html.erb
Normal 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 %>
|
||||
62
app/components/DS/popover.rb
Normal file
62
app/components/DS/popover.rb
Normal 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
|
||||
140
app/components/DS/popover_controller.js
Normal file
140
app/components/DS/popover_controller.js
Normal 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: "",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,6 +89,8 @@ en:
|
||||
default_label: Preview
|
||||
dialog:
|
||||
close: Close
|
||||
popover:
|
||||
avatar_default_label: Open menu
|
||||
tooltip:
|
||||
trigger_label: More info
|
||||
link:
|
||||
|
||||
@@ -21,6 +21,7 @@ en:
|
||||
member: Member
|
||||
super_admin: Super admin
|
||||
user_menu:
|
||||
aria_label: Open account menu
|
||||
version: Version
|
||||
settings: Settings
|
||||
changelog: Changelog
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user