[Accessibility] Patch over a11y issues caused by "simple" ruby gems (#39325)

This commit is contained in:
diondiondion 2026-06-08 15:30:10 +02:00 committed by GitHub
parent 51a956b6bc
commit 5553448678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 3 deletions

View File

@ -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<HTMLElement>('.simple_form label.required abbr')
.forEach((element) => {
element.setAttribute('aria-hidden', 'true');
});
/**
* Associate form field hints with their inputs via aria-describedby
*/
document
.querySelectorAll<HTMLDivElement>('.simple_form .field_with_hint')
.forEach((field) => {
const inputs = field.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input[type='text'], input[type='checkbox'], textarea");
const hint = field.querySelector<HTMLDivElement>('.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<HTMLDivElement>(
'.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<HTMLLabelElement>('label');
const labelWithId =
groupWrapper.querySelector<HTMLLabelElement>('label[for]');
const groupHint = groupWrapper.querySelector<HTMLDivElement>('.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);

View File

@ -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,