mirror of
https://github.com/we-promise/sure.git
synced 2026-07-24 10:45:23 +00:00
* Fix keyboard navigation in DS::Select dropdowns. Open the listbox on Tab focus and arrow/space/enter from the trigger so account and category selectors in the transaction form are usable without a mouse, and reset option tabindex when the menu closes. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Tab skipping DS::Select triggers in transaction form. Keep focus on the trigger when Tab opens the menu, keep listbox options at tabindex -1 with inert on the closed menu, and move focus into options only via arrow keys so Tab no longer jumps to amount or date. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix search input losing focus during DS::Select filtering. Only repoint focus in syncTabindex when a listbox option was focused and became hidden; skip while the user is typing in the search field or when clearSearch runs on menu open. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Escape closing the parent dialog from an open DS::Select. Stop Escape propagation so only the listbox closes, and suppress handleButtonFocus briefly after close+refocus so keyboard modality does not reopen the menu via :focus-visible. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Tab leaving DS::Select after suppressReopenOnFocus change. Focus the trigger before closing on Tab and advance to the next dialog field manually so inert on the menu no longer drops focus to body. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
111 lines
5.0 KiB
Plaintext
111 lines
5.0 KiB
Plaintext
<%# locals: form:, method:, collection:, options: {} %>
|
|
|
|
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-select-menu-placement-value="<%= menu_placement %>" data-action="dropdown:select->form-dropdown#onSelect">
|
|
<div class="form-field <%= options[:container_class] %>">
|
|
<div class="form-field__body">
|
|
<%= form.label method, options[:label], class: "form-field__label", id: "#{method}_label" if options[:label].present? %>
|
|
<%= form.hidden_field method,
|
|
value: @selected_value,
|
|
data: {
|
|
"form-dropdown-target": "input",
|
|
"auto-submit-target": "auto",
|
|
**(options.dig(:html_options, :data) || {})
|
|
} %>
|
|
<%# `aria-expanded` reflects MENU open/closed state — managed by the
|
|
select controller's openMenu/close. Init as "false"; previously
|
|
this incorrectly mirrored whether a value was selected.
|
|
|
|
`aria-labelledby` points at BOTH the visible label and the
|
|
trigger button itself so AT users hear "<label> <selected
|
|
value>" — referencing only the label would override the
|
|
button's text node and suppress the current value. %>
|
|
<button type="button"
|
|
id="<%= method %>_trigger"
|
|
class="form-field__input w-full"
|
|
data-select-target="button"
|
|
data-action="click->select#toggle focus->select#handleButtonFocus"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="false"
|
|
tabindex="0"
|
|
<%= "aria-labelledby=\"#{method}_label #{method}_trigger\"".html_safe if options[:label].present? %>>
|
|
<%= selected_item&.dig(:label) || @placeholder %>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-border-lg bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
|
|
<% if searchable %>
|
|
<div class="flex items-center bg-container border border-secondary rounded-lg mb-1 focus-ring-within transition-shadow">
|
|
<%= render DS::SearchInput.new(
|
|
variant: :embedded,
|
|
placeholder: t("helpers.select.search_placeholder"),
|
|
data: {
|
|
list_filter_target: "input",
|
|
action: "input->list-filter#filter input->select#syncTabindex"
|
|
}
|
|
) %>
|
|
</div>
|
|
<% end %>
|
|
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto"
|
|
role="listbox" tabindex="-1">
|
|
<% items.each do |item| %>
|
|
<% is_selected = item[:value] == selected_value %>
|
|
<% obj = item[:object] %>
|
|
|
|
<%# Options use tabindex="-1" always — keyboard focus is managed
|
|
programmatically so listbox items never appear in the Tab
|
|
sequence and skip past the trigger to the next form field. %>
|
|
<div class="filterable-item text-primary text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
|
|
role="option"
|
|
tabindex="-1"
|
|
aria-selected="<%= is_selected %>"
|
|
data-select-target="option"
|
|
data-action="click->select#select"
|
|
data-value="<%= item[:value] %>"
|
|
data-filter-name="<%= item[:label] %>">
|
|
|
|
<span class="check-icon <%= "hidden" unless is_selected %>">
|
|
<%= helpers.icon("check") %>
|
|
</span>
|
|
|
|
<% case variant %>
|
|
<% when :simple %>
|
|
<%= item[:label] %>
|
|
|
|
<% when :logo %>
|
|
<% unless item[:value].nil? %>
|
|
<% if logo_for(item) %>
|
|
<%= image_tag logo_for(item),
|
|
class: "w-6 h-6 rounded-full border border-secondary",
|
|
loading: "lazy" %>
|
|
<% else %>
|
|
<%= render DS::FilledIcon.new(
|
|
variant: :text,
|
|
text: item[:label],
|
|
size: "sm",
|
|
rounded: true
|
|
) %>
|
|
<% end %>
|
|
<% end %>
|
|
<%= item[:label] %>
|
|
|
|
<% when :badge %>
|
|
<% hex_color = color_for(item) %>
|
|
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
|
|
style="
|
|
background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent);
|
|
border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent);
|
|
color: <%= hex_color %>;">
|
|
<% if icon_for(item) %>
|
|
<%= helpers.icon icon_for(item), size: "sm", color: "current" %>
|
|
<% else %>
|
|
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
|
|
<% end %>
|
|
<%= item[:label] %>
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|