Files
sure/app/components/DS/menu_item.rb
Guillem Arias Fauste 1742f4ef1e feat(ds): elevate dropdown overlays and stabilize selection check gutter (#2161)
* 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.

* 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.

* 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.
2026-06-04 11:55:57 +02:00

105 lines
3.5 KiB
Ruby

class DS::MenuItem < DesignSystemComponent
VARIANTS = %i[link button divider].freeze
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :roving, :selected, :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, selected: nil, **opts)
@variant = variant.to_sym
@text = text
@icon = icon
@href = href
@method = method.to_sym
@destructive = destructive
@confirm = confirm
@frame = frame
@roving = roving
@selected = selected
@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
# Merge the contract's ARIA (e.g. aria-checked for selectable rows) with any
# caller-supplied aria so neither clobbers the other; the contract wins per-key.
attrs = menuitem_attrs
if attrs[:aria] && html_opts[:aria]
html_opts = html_opts.merge(aria: html_opts[:aria].merge(attrs[:aria]))
attrs = attrs.except(:aria)
end
if variant == :button
button_to href, method: method, class: container_classes, **html_opts, **attrs, &block
elsif variant == :link
link_to href, class: container_classes, **html_opts, **attrs, &block
else
nil
end
end
def text_classes
[
"text-sm",
destructive? ? "text-destructive" : "text-primary"
].join(" ")
end
def destructive?
method == :delete || destructive
end
# A selectable item participates in a single-select list (e.g. a period or
# category picker). When set (true/false) the item reserves a fixed-width
# leading check gutter so row text stays aligned whether or not it is the
# current selection. Leave nil for plain action items (no gutter).
def selectable?
!selected.nil?
end
private
def menuitem_attrs
return {} unless roving
# Selectable rows are a single-select group: announce selection state to AT
# via menuitemradio + aria-checked, not only the visual check gutter.
if selectable?
{ role: "menuitemradio", tabindex: "-1", aria: { checked: selected ? "true" : "false" } }
else
{ role: "menuitem", tabindex: "-1" }
end
end
def container_classes
[
"flex items-center gap-2 p-2 rounded-md w-full",
destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover"
].join(" ")
end
def merged_opts
merged_opts = opts.dup || {}
data = merged_opts.delete(:data) || {}
if confirm.present?
confirm_value = confirm.respond_to?(:to_data_attribute) ? confirm.to_data_attribute : confirm
data = data.merge(turbo_confirm: confirm_value)
end
if frame.present?
data = data.merge(turbo_frame: frame)
end
merged_opts.merge(data: data)
end
end