<%= wrapper do %>
<% if icon %>
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>
diff --git a/app/components/DS/menu_item.rb b/app/components/DS/menu_item.rb
index 5aa3959b3..7e55968c2 100644
--- a/app/components/DS/menu_item.rb
+++ b/app/components/DS/menu_item.rb
@@ -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",
diff --git a/app/components/DS/popover.html.erb b/app/components/DS/popover.html.erb
new file mode 100644
index 000000000..2b7e45d15
--- /dev/null
+++ b/app/components/DS/popover.html.erb
@@ -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. %>
+
+ <% end %>
+
+
+ <%= 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 %>
+
+<% end %>
diff --git a/app/components/DS/popover.rb b/app/components/DS/popover.rb
new file mode 100644
index 000000000..8361e9519
--- /dev/null
+++ b/app/components/DS/popover.rb
@@ -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
diff --git a/app/components/DS/popover_controller.js b/app/components/DS/popover_controller.js
new file mode 100644
index 000000000..2040a04e2
--- /dev/null
+++ b/app/components/DS/popover_controller.js
@@ -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: "",
+ });
+ }
+ });
+ }
+}
diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb
index 4d97b2d4c..8639ab62a 100644
--- a/app/components/UI/account/activity_feed.html.erb
+++ b/app/components/UI/account/activity_feed.html.erb
@@ -67,8 +67,8 @@