From 5553448678a8a9f4ef970948b6fb7f013e9a4238 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 8 Jun 2026 15:30:10 +0200 Subject: [PATCH] [Accessibility] Patch over a11y issues caused by "simple" ruby gems (#39325) --- app/javascript/entrypoints/public.tsx | 84 ++++++++++++++++++++++ app/views/auth/registrations/new.html.haml | 6 +- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 4e5b35b973..5563b8324e 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -181,6 +181,8 @@ async function loaded() { truncateRuleHints(); + applyRailsA11yPatches(); + const reactComponents = document.querySelectorAll('[data-component]'); if (reactComponents.length > 0) { @@ -515,6 +517,88 @@ on('click', '.rules-list button', ({ target }) => { } }); +/** + * Patch accessibility issues caused by Ruby Gems that + * don't produce accessible markup (simple-forms & simple-navigation) + */ +function applyRailsA11yPatches() { + /** + * Mark current navigation item with aria-current + */ + const activeNavLink = document.querySelector( + '.simple-navigation-active-leaf a.selected', + ); + activeNavLink?.setAttribute('aria-current', 'page'); + + /** + * Hides the asterisk added to labels of required form fields + * from assistive tech. (Those fields already have the `required` attribute) + */ + document + .querySelectorAll('.simple_form label.required abbr') + .forEach((element) => { + element.setAttribute('aria-hidden', 'true'); + }); + + /** + * Associate form field hints with their inputs via aria-describedby + */ + document + .querySelectorAll('.simple_form .field_with_hint') + .forEach((field) => { + const inputs = field.querySelectorAll< + HTMLInputElement | HTMLTextAreaElement + >("input[type='text'], input[type='checkbox'], textarea"); + + const hint = field.querySelector('.hint'); + + // Bail out if there are more than one input as + // the association can't be safely made. + if (inputs.length !== 1 || !inputs[0] || !hint) { + return; + } + + const input = inputs[0]; + const inputId = input.getAttribute('id'); + const hintId = `${inputId}_hint`; + + input.setAttribute('aria-describedby', hintId); + hint.setAttribute('id', hintId); + }); + + /** + * Add fieldset-like group labels ("legends") to the date-of-birth selector + * and groups of radio buttons + */ + const groups = document.querySelectorAll( + '.simple_form .date_of_birth, .simple_form .input.with_label.radio_buttons', + ); + groups.forEach((groupWrapper) => { + // This is the element serving as the label of the group. + const groupLabel = groupWrapper.querySelector('label'); + const labelWithId = + groupWrapper.querySelector('label[for]'); + const groupHint = groupWrapper.querySelector('.hint'); + + // We need a unique ID to generate the aria associations. If `groupLabel` + // doesn't have one, we just take the first label with a `for` attribute + // that we can find, which is fine because we'll modify it before use. + const inputId = + groupLabel?.getAttribute('for') ?? labelWithId?.getAttribute('for'); + const labelId = `${inputId}_label`; + const hintId = `${inputId}_hint`; + + groupLabel?.setAttribute('id', labelId); + groupHint?.setAttribute('id', hintId); + + groupWrapper.setAttribute('role', 'group'); + groupWrapper.setAttribute('aria-labelledby', labelId); + if (groupHint) { + groupWrapper.setAttribute('aria-describedby', hintId); + } + }); +} + function main() { ready(loaded).catch((error: unknown) => { console.error(error); diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 6695160f58..15e1bed973 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -21,17 +21,17 @@ = f.simple_fields_for :account do |ff| = ff.input :username, append: "@#{site_hostname}", - input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT, placeholder: ' ' }, + input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }, required: true, wrapper: :with_label = f.input :email, hint: false, - input_html: { autocomplete: 'username', placeholder: ' ' }, + input_html: { autocomplete: 'username' }, required: true, wrapper: :with_label = f.input :password, hint: false, - input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last, placeholder: ' ' }, + input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, required: true, wrapper: :with_label = f.input :password_confirmation,