mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
* fix(design-system): DS::Button a11y audit Closes #1738. Four concrete fixes surfaced by the savings-goals audit + #1737 universal checklist: 1. Focus ring (WCAG 2.4.7). `base.css` had `focus-visible:outline-gray-900` which is **1.07:1** against the primary button's gray-900 background — invisible. Widen to `outline-2 outline-offset-2`, place outline outside the button via offset, and add a dark-mode `outline-white` so the ring is always visible against the page chrome regardless of the button surface. 2. Touch target (WCAG 2.5.5). Icon-only buttons at the default `:md` size were `w-9 h-9` = 36×36, below the 44×44 enhanced target. Bump `md.icon_container_classes` to `w-11 h-11` and `lg.icon_container_classes` to `w-12 h-12` to keep the size scale intact. `sm` stays at 32×32 (already passes WCAG 2.5.8 AA's 24×24 minimum; intentional compact-density variant). 3. Default button type. `content_tag(:button, ...)` inherits the HTML default `type="submit"`, so a DS::Button rendered inside a form steals Enter-key submission from the first text input (reproducible in the form stepper). Default to `type="button"` in the non-`href` branch; existing form submitters pass `type: "submit"` explicitly and continue to work. The `button_to` (href) branch keeps the submit default because button_to wraps its own form. 4. Icon-only accessible name. Icon-only buttons render no text node, so AT users hear "button" with no name. Derive a humanized aria-label from the icon key (e.g. `icon: "more-horizontal"` → `aria-label="More horizontal"`); explicit `aria: { label: }` on the caller still wins. Soft fallback — callers should still pass meaningful labels for richer copy. Plus: replace the stale `fg-white` icon class on the destructive variant with `text-inverse` (the `fg-*` namespace was deprecated in #1626 so `fg-white` resolved to nothing; the icon was using its helper-default color rather than the white the design intended). Out of scope: - Menu avatar trigger (custom 36×36 button bypassing DS::Button) — belongs to #1743 DS::Menu audit. - DS::FilledIcon `lg` size container (decorative, not interactive) — belongs to #1742. * fix(design-system): force type=submit on StyledFormBuilder#submit The DS::Button default-type-button change in the previous commit broke every `form.submit "Log in"` callsite because `StyledFormBuilder#submit` (app/helpers/styled_form_builder.rb) renders a DS::Button under the hood with no explicit `type:`. After the default flip, those submit buttons rendered as `type="button"`, so submitting forms (login, password reset, every form using `form.submit`) silently no-ops. CI surfaced this via ~30 system tests failing in the `sign_in` helper, which couldn't get past the login page. Pin `type: "submit"` on the DS::Button rendered by `StyledFormBuilder#submit`. The 22 view-level `f.submit` / `render DS::Button.new(type: :submit, ...)` callers already pass type explicitly and are unaffected. * fix(review): href-branch type-button bug + focus-ring tokens + profile Save submit CodeRabbit P1+P2 review on #1840: 1. button.rb: `merged_opts.delete(:href)` always returned nil because Buttonish#initialize strips :href from opts into @href, so the `if href.blank?` guard was ALWAYS true. Every DS::Button rendered via button_to (the href branch) got `type="button"` on the inner button, breaking submission of those button_to-generated forms (e.g. imports/_ready.html.erb publish button, imports/_failure.html.erb try-again button). Drop the local `href = merged_opts.delete(:href)` so the guard now reads the @href reader, leaving the href branch's HTML default intact. 2. settings/profiles/show.html.erb: the Save button is rendered with `render DS::Button.new(...)` inside `styled_form_with` (not via form.submit), so the StyledFormBuilder#submit type-pin from624e9794doesn't cover it. Pass `type: :submit` explicitly so the profile form submits again under the default-type-button policy. 3. base.css: replace raw `outline-gray-900` / `outline-white` with the established alpha-ring focus pattern (focus-visible:ring-alpha-black-300 + theme-dark:ring-alpha-white-300) already used by app/components/settings/provider_card.html.erb and sure-design-system/components.css. Keeps a11y focus ring while using DS tokens. * fix(review): add type: :submit to DS::Button submitters inside forms CI test_system on #1840 surfaced 6 failures (confirm-dialog close, property create/edit, transaction filter apply) caused by the same gap thatdb563f3dstarted addressing: the default-type-button policy on DS::Button means every \`render DS::Button.new(...)\` inside a \`<form>\` (or \`styled_form_with\`) that relies on the HTML default to submit is now an inert \`type="button"\`. Audited every \`render DS::Button.new(\` callsite repo-wide for the combination (no \`type:\`, no \`href:\`, inside a form context) and pinned \`type: :submit\` explicitly on the 12 forms that need it: - layouts/shared/_confirm_dialog.html.erb: Confirm button inside the global \`<form method=\"dialog\">\` — fixes test_should_allow_revoking_API_key_with_confirmation. - properties/{new,edit,balances}.html.erb: Save/Next submitter inside \`styled_form_with\` — fixes test_can_create_property_account, test_can_persist_property_subtype. - transactions/searches/_menu.html.erb: Apply inside the filter form — fixes test_can_filter_uncategorized_transactions, test_all_filters_work_and_empty_state_shows_if_no_match, test_can_open_filters_and_apply_one_or_more. - transactions/bulk_updates/new.html.erb: Save in bulk-edit drawer. - account_sharings/show.html.erb: Save in account-sharing form. - category/deletions/new.html.erb, tag/deletions/new.html.erb: destructive + safe submit buttons in deletion dialog forms. - family_merchants/merge.html.erb: Submit in merge form. - subscriptions/upgrade.html.erb: contribute_and_support_sure submit. - rules/_category_rule_cta.html.erb: Dismiss inside the rule_prompts_disabled form. Cancel/close DS::Button instances inside these same forms intentionally keep the \`type=button\` default since they drive JS-only actions (\`DS--dialog#close\`, \`DS--menu#close\`). * fix(review): add type: :submit to 4 remaining form-context DS::Button callers Second sweep for the same default-type-button regression that24c517ebfixed for 12 callsites. The latest CI run on this branch narrowed the failures from 6 to 2 (the property wizard's Address step still failed because that view was not in the first sweep). Audited via a wider 4000-char form-context window: - app/views/properties/address.html.erb: Save inside styled_form_with — fixes the remaining test_can_create_property_account + test_can_persist_property_subtype by letting Step 3 of the property wizard complete. - app/views/onboardings/goals.html.erb: Submit inside form_with so the onboarding goals step submits. - app/views/account_sharings/show.html.erb (owner-side form): Save button for the family-share permissions form (the non-owner Save was already fixed in24c517eb). - app/views/transactions/_attachments.html.erb: Upload inside styled_form_with — kept the JS-driven hook (attachment_upload_target) but explicit type:submit covers the no-JS fallback. * fix(review): pin type=submit on the Save currencies button Codex P1 (third pass) caught one more in-form DS::Button I missed in the earlier sweeps: \`app/views/settings/preferences/show.html.erb:185\` renders the Save currencies submit deep inside a long \`styled_form_with\` block. The form-context scan I used had a finite look-back window which missed it because the matching \`styled_form_with\` opener sits ~80 lines / 4k+ characters above the button. Switched to a whole-file scan to confirm no further callsite remains.
160 lines
5.3 KiB
Ruby
160 lines
5.3 KiB
Ruby
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
|
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
|
|
|
text_field_helpers.each do |selector|
|
|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{selector}(method, options = {})
|
|
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
|
|
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
|
|
|
|
build_field(method, form_options, html_options) do |merged_options|
|
|
super(method, merged_options)
|
|
end
|
|
end
|
|
RUBY_EVAL
|
|
end
|
|
|
|
def radio_button(method, tag_value, options = {})
|
|
merged_options = { class: "form-field__radio" }.merge(options)
|
|
super(method, tag_value, merged_options)
|
|
end
|
|
|
|
def select(method, choices, options = {}, html_options = {})
|
|
field_options = normalize_options(options, html_options)
|
|
|
|
build_field(method, field_options, html_options) do |merged_html_options|
|
|
super(method, choices, options, merged_html_options)
|
|
end
|
|
end
|
|
|
|
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
|
selected_value =
|
|
if options.key?(:selected)
|
|
options[:selected]
|
|
elsif @object.respond_to?(method)
|
|
@object.public_send(method)
|
|
end
|
|
placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label")
|
|
|
|
@template.render(
|
|
DS::Select.new(
|
|
form: self,
|
|
method: method,
|
|
items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } },
|
|
selected: selected_value,
|
|
placeholder: placeholder,
|
|
searchable: options.fetch(:searchable, false),
|
|
menu_placement: options[:menu_placement],
|
|
variant: options.fetch(:variant, :simple),
|
|
include_blank: options[:include_blank],
|
|
label: options[:label],
|
|
container_class: options[:container_class],
|
|
label_tooltip: options[:label_tooltip],
|
|
html_options: html_options
|
|
)
|
|
)
|
|
end
|
|
|
|
def money_field(amount_method, options = {})
|
|
@template.render partial: "shared/money_field", locals: {
|
|
form: self,
|
|
amount_method:,
|
|
currency_method: options[:currency_method] || :currency,
|
|
**options
|
|
}
|
|
end
|
|
|
|
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
field_id = field_id(method)
|
|
field_name = field_name(method)
|
|
checked = object ? object.send(method) : options[:checked]
|
|
|
|
@template.render(
|
|
DS::Toggle.new(
|
|
id: field_id,
|
|
name: field_name,
|
|
checked: checked,
|
|
disabled: options[:disabled],
|
|
checked_value: checked_value,
|
|
unchecked_value: unchecked_value,
|
|
**options
|
|
)
|
|
)
|
|
end
|
|
|
|
def submit(value = nil, options = {})
|
|
value, options = nil, value if value.is_a?(Hash)
|
|
value ||= submit_default_value
|
|
|
|
@template.render(
|
|
DS::Button.new(
|
|
text: value,
|
|
type: "submit",
|
|
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
|
|
full_width: true
|
|
)
|
|
)
|
|
end
|
|
|
|
private
|
|
def build_field(method, options = {}, html_options = {}, &block)
|
|
if options[:inline] || options[:label] == false
|
|
return yield({ class: "form-field__input" }.merge(html_options))
|
|
end
|
|
|
|
label_element = build_label(method, options)
|
|
field_element = yield({ class: "form-field__input" }.merge(html_options))
|
|
|
|
container_classes = [ "form-field", options[:container_class] ].compact
|
|
|
|
@template.tag.div class: container_classes do
|
|
if options[:label_tooltip]
|
|
@template.tag.div(class: "form-field__header") do
|
|
label_element +
|
|
@template.tag.div(class: "form-field__actions") do
|
|
build_tooltip(options[:label_tooltip])
|
|
end
|
|
end +
|
|
@template.tag.div(class: "form-field__body") do
|
|
field_element
|
|
end
|
|
else
|
|
@template.tag.div(class: "form-field__body") do
|
|
label_element + field_element
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def normalize_options(options, html_options)
|
|
options.merge(required: options[:required] || html_options[:required])
|
|
end
|
|
|
|
def build_label(method, options)
|
|
return "".html_safe unless options[:label]
|
|
|
|
label_text = options[:label]
|
|
|
|
if options[:required]
|
|
label_text = @template.safe_join([
|
|
label_text == true ? method.to_s.humanize : label_text,
|
|
@template.tag.span("*", class: "text-red-500 ml-0.5")
|
|
])
|
|
end
|
|
|
|
return label(method, class: "form-field__label") if label_text == true
|
|
label(method, label_text, class: "form-field__label")
|
|
end
|
|
|
|
def build_tooltip(tooltip_text)
|
|
return nil unless tooltip_text
|
|
|
|
@template.tag.div(data: { controller: "tooltip" }) do
|
|
@template.safe_join([
|
|
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
|
|
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
|
|
])
|
|
end
|
|
end
|
|
end
|