mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 12:55:20 +00:00
* feat(ds): one canonical focus ring across primitives (#2136) Replaces the grab-bag of per-primitive focus indicators (neutral ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4) with a single recipe — the #1737 accessibility follow-up. - New --color-focus-ring token: blue-600 (light) / blue-500 (dark), >=4:1 against both surfaces. - Canonical .focus-ring / .focus-ring-within in components.css: a 2px outline + 2px offset on :focus-visible only. Outline (not a box-shadow ring) so the offset gap is transparent on any surface with no layout shift; :focus-visible so it never shows for mouse/touch. - Applied to every focusable DS primitive: Button (had none), Link, Disclosure summary, Tabs nav, MenuItem (replaces the browser-default box), SearchInput, Tooltip trigger, Popover trigger, Select panel (focus-within), Toggle (peer-driven outline-focus-ring). .form-field adopts it via :focus-within, replacing the ~1:1 ring-4. - Dialog close button is a DS::Button icon variant, so it inherits the focus-visible-only ring and keeps no resting border (fixes "stuck ring"). Verified in-browser, light+dark: focus-visible ring on button, input, and full-width menu row — consistent blue 2px+offset, legible on both surfaces. Remaining follow-up: >=44px touch targets (disclosure trigger, composer send); bespoke notification / account-new close buttons that still carry a permanent border. * fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome - Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send + the coming-soon icons are already DS::Button icon/md (w-11 h-11). - Notification close buttons (sync_toast, notice): drop the resting border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container + shadow-xs chip so the corner control stays visible over the page, and brighten the muted glyph on hover (text-subdued -> hover:text-primary). * refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline) Per design feedback: the blue 2px outline + 2px offset read as a loud, detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring to a soft box-shadow ring that hugs the control (follows border-radius, no gap), in the theme-aware neutral focus-ring token (alpha-black/white-400). Transparent outline kept as a forced-colors fallback; toggle peer-driver switched from outline-* to ring-* to match. Still one token, :focus-visible only. Strength is tunable (currently subtle ~1.5:1). * fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow The neutral box-shadow ring lived in the components layer, so any utility-layer shadow-* (or .form-field's focus-within:shadow-none) on the same element overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw the same subtle neutral ring with a hugging `outline` (outline-offset: 0) instead — a separate property with no box-shadow conflict, and it doubles as the forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes. * fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5) The hover-revealed close button had ~2px padding around a 20px icon (~24px total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to ~32px. Addresses CodeRabbit review on #2140. * fix(ds): keep form-field's resting halo; stop the outline color flash Two testing findings: - .form-field reverts to its original always-on soft ring (focus-within ring-4 at low alpha, theme-aware) instead of adopting the keyboard-only outline. It's a resting decoration, not a focus indicator, and the lower-opacity halo was the better look. The canonical block's comment documents the deliberate opt-out. - .focus-ring/.focus-ring-within now carry a base transparent 2px outline so consumers with transition-all (form-field had it) animate transparent -> token on focus instead of passing through currentColor, which flashed as a black border appearing and then fading out. * feat(ds): focus-ring token clears WCAG 3:1 non-text contrast alpha-black-400 (20%) measured ~1.6:1 against white — visible but below the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via tokens:build. * fix(ds): ring the hand-rolled privacy toggle too The header pair showed two different focus treatments: panel-right (a DS::Button) got the new token ring while the hand-rolled privacy toggle next to it fell back to the browser-default ring — the sweep covered DS primitives but not bespoke buttons. Both privacy toggles (mobile + desktop) now carry .focus-ring. Also documents the transition interplay on the focused-state rule: consumers with transition-colors fade the ring in over 150ms because Tailwind v4's color transition list includes outline-color. Verified settled value at the intended 50% alpha via Playwright. * fix(ds): ring the sidebar and settings nav links The reshoot caught both nav species falling back to the browser's blue default ring — main sidebar items and settings nav items are bespoke link_to markup the primitive sweep missed, and they're the primary keyboard path in the app. Both adopt .focus-ring (main nav adds rounded-lg so the outline follows a shape). * fix(ds): retire the legacy base-layer button ring for the canonical outline The @layer base button rule still painted a ring-2 ring-offset-2 box-shadow on :focus-visible. Box-shadow and outline are independent properties, so .focus-ring (an outline) could never clear it and every button-tag primitive double-painted both indicators on keyboard focus. Apply the canonical recipe to the base button rule itself: every <button> now gets the transparent resting outline + focus-ring token on :focus-visible by default. Two bespoke buttons suppressed the outline with focus:outline-none and relied on the base ring for their keyboard indicator (category dropdown rows, the sign-up password toggle). Drop the suppression so they pick up the canonical outline — :focus-visible keeps it keyboard-only, which is what the suppression was protecting against anyway. * fix(ds): segmented control adopts the canonical focus recipe The segment rule inlined its own focus-visible outline (offset 2, alpha-400 colors) with a comment noting it was temporary until the canonical token landed — this branch is that token. Drop the inlined utilities: button segments get the outline from the base button rule, and link segments now carry .focus-ring.
110 lines
5.0 KiB
Plaintext
110 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"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="false"
|
|
<%= "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] %>
|
|
|
|
<%# Roving tabindex: selected option is in tab order (`0`); others
|
|
are reachable only via ArrowUp/Down (`-1`). WAI-ARIA APG
|
|
listbox keyboard pattern. %>
|
|
<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="<%= is_selected ? "0" : "-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>
|