Skip to content

fix: make repeater row button names unique - #976

Merged
epeicher merged 6 commits into
WordPress:trunkfrom
Pranjal1423:fix/repeater-accessible-row-names
Oct 16, 2026
Merged

epeicher merged 6 commits into
WordPress:trunkfrom
Pranjal1423:fix/repeater-accessible-row-names

Conversation

@Pranjal1423

@Pranjal1423 Pranjal1423 commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #946

In <os-repeater>, every row's action buttons (reorder handles and remove button) were named using just the static row-label attribute (e.g. aria-label="Remove choice" and aria-label="Move choice up").

When tabbing through multiple rows with a screen reader, every remove button had the exact same accessible name, giving users no way to tell which row was being removed or moved without relying on visual position.

What does this change do?

  1. Positional labeling by default: Buttons now include the row's 1-based position and total count (e.g., Remove choice 2 of 3, Move choice 2 up, Move choice 2 down).
  2. Semantic row label support: If row-label-from="<field>" is passed or a row carries data-label="..." / aria-label="...", that label is used instead (e.g., Remove Drafting), falling back to positional numbering if the field is blank or new.
  3. Live updates: When row-label-from is set, typing into the input updates the button's accessible name in place without stealing focus.

How to test:

  1. Open the Components reference tab in OpenStation Preferences and find Repeater.
  2. Mount rows (e.g., document.querySelector('os-repeater').keys = ['a', 'b', 'c']).
  3. Inspect the #shadow-root of the rows in DevTools:
    • Verify remove buttons have unique labels (Remove choice 1 of 3, Remove choice 2 of 3, Remove choice 3 of 3).
    • Verify move buttons have unique labels (Move choice 1 up, Move choice 1 down, etc.).
  4. Set row-label-from="title" and provide child <input name="title" value="Draft" />:
    • Verify button updates to Remove Draft.
    • Change the text in the input and verify the button's accessible name updates live.
    • Clear the input and verify it falls back to Remove choice 1 of 3.
  5. Run the test suite:
    npx vitest run src/ui/components/os-repeater/os-repeater.test.ts
    

##After fix
Screenshot 2026-10-08 at 1 31 02 PM

Open WordPress Playground Preview

@epeicher
epeicher self-requested a review October 16, 2026 08:34
@epeicher

epeicher commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for making the repeater friendlier for screen reader users, @Pranjal1423! The positional names ("Remove choice 2 of 3") are a big improvement and they renumber correctly on reorder.

Two things on the field-label path. row-label-from and the row key go into querySelector unescaped (row lookup, field lookup), so a WordPress-style field name like items[0][title] (or anything starting with a digit) throws inside render() and the repeater stops rendering. CSS.escape() fixes it, and :scope > keeps a nested repeater's rows from matching:

const slot = this.querySelector( `:scope > [slot="row-${ CSS.escape( key ) }"]` );
const name = CSS.escape( rowLabelFrom );
const field = slot?.querySelector( `[name="${ name }"], [data-field="${ name }"], #${ name }` );

Also, once a label is found the position is dropped, so two rows with the same text get identical names again. Keeping the position, and routing the strings through sprintf( __() ) like os-notice does, would cover both:

import { __, sprintf } from '../../../i18n';

/* translators: 1: row label, 2: row position, 3: total rows. */
remove: sprintf( __( 'Remove %1$s (%2$d of %3$d)' ), label, pos, total ),

Last bit: the data-label / aria-label override on the row element is new public behaviour, so it'd be great to mention it in the row-<key> slot description in static help.

Pranjal1423 and others added 4 commits October 16, 2026 20:20
Rows and labelling fields are now found by comparing attribute values
instead of building a selector, so arbitrary keys and field names
(`items[0][title]`, `1st`) need no escaping and the hand-written
`CSS.escape` fallback goes away. Only direct children are matched, as
`:scope >` did.

Row names are read from the light DOM at render time, so a `data-label`
or `aria-label` written from code, a renamed field or an added row kept
the old name until something else re-rendered the repeater. A
MutationObserver on the rows now requests a render for those changes. A
field value set from code still needs an `input` event, which the help
text now says.

@epeicher epeicher left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Pranjal1423 for addressing the comments! I pushed a small commit on top of your changes and tested it; it works as expected. Changes LGTM!

@epeicher
epeicher enabled auto-merge (squash) October 16, 2026 15:29
@epeicher
epeicher merged commit 4d98d94 into WordPress:trunk Oct 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os-repeater: row controls have the same accessible name on every row

2 participants