Skip to content

[6.x] Fix listing search label and empty table header cells - #15406

Open
bpmore wants to merge 2 commits into
statamic:6.xfrom
bpmore:fix/listing-search-label-and-headers
Open

[6.x] Fix listing search label and empty table header cells#15406
bpmore wants to merge 2 commits into
statamic:6.xfrom
bpmore:fix/listing-search-label-and-headers

Conversation

@bpmore

@bpmore bpmore commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Two small accessibility fixes in ui/Listing/, found during a WCAG 2.1 AA audit of the CP. Filing as a PR rather than issues since both are a few lines.

1. The search field says "Search entries" on listings that hold no entries

Search.vue hardcoded the label:

<label for="listings-search" class="sr-only">{{ __('Search entries') }}</label>

On /cp/assets/browse/assets and /cp/users a screen reader user is told they are searching entries while looking at files or people. WCAG 2.1 SC 2.4.6 (AA).

The same line hardcodes id="listings-search", which is also bound to the input. Two listings rendered on one page (the asset selector opened over a listing, for example) produce duplicate ids and a label pointing at whichever one wins.

Changed to:

  • an optional label prop, defaulting to the listing-agnostic 'Search' — accurate everywhere, and no worse than today on any screen
  • useId() for the input id, matching what Switch.vue and friends already do

Passed label="Search assets" at the two asset call sites. The generic default covers the rest; individual listings can opt in as their nouns are known.

Note: 'Search entries' is no longer referenced. Left the translation key in place rather than touching lang files in an a11y PR — say the word if you'd like it removed.

2. Empty <th> cells

The select/reorder column and the actions column rendered header cells with no content, so screen readers announce an empty column header when moving across a row. axe-core empty-table-header fired on 9 screens.

Added sr-only names: Select / Reorder for the first column when no toggle-all is rendered, and Actions for the last.

Tests

Added resources/js/tests/components/ListingSearch.test.js with 5 cases:

  • the label defaults to Search
  • the label can be set per listing
  • the label is associated with the input
  • two search fields in one listing do not share an id
  • the actions header cell has an accessible name

4 of the 5 fail against the unpatched components; the association test passes either way and stays as a regression guard.

Full unit suite: 568 passed, 9 failed. Those 9 are pre-existing LivePreview failures on a clean 6.x checkout — identical without this branch. Net effect: +5 tests, no change to existing results.

Notes

AI tools were used to draft this; the changes, the tests, and the before/after runs were reviewed by hand. Happy to split this into two PRs if you'd rather review them separately.

The listing search field was hardcoded to "Search entries" and to a fixed
id, so the asset browser and users listing told screen reader users they
were searching entries, and two listings on one page produced duplicate
ids. The select and actions header cells rendered empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YZV7JD2cyMHSeSu2fUXCK

@jackmcdade jackmcdade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The label prop needs to follow the same pattern as every other label in the CP: translate at the call site, render the already-translated string in the component. Don't only wrap the call sites — Search.vue already runs __(label), so you'd double-translate.

<div class="flex items-center gap-2 sm:gap-3 py-3 relative overflow-clip st-overflow-clip-margin">
<div class="flex flex-1 items-center gap-2 sm:gap-3">
<ListingSearch />
<ListingSearch label="Search assets" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Needs the translation helper, same as every other label prop:

<ListingSearch :label="__('Search assets')" />

<div class="flex items-center gap-2 sm:gap-3 mb-4">
<div class="flex flex-1 items-center gap-2 sm:gap-3">
<Search ref="search" />
<Search ref="search" label="Search assets" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here:

<Search ref="search" :label="__('Search assets')" />

<template>
<div class="flex-1 max-w-sm" :class="{ 'max-w-60!': activeFilterBadgeCount > 2 }">
<label for="listings-search" class="sr-only">{{ __('Search entries') }}</label>
<label :for="id" class="sr-only">{{ label ? __(label) : __('Search') }}</label>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once the call sites pass an already-translated string, don't wrap label again:

{{ label || __('Search') }}

Follows the CP convention: call sites pass an already-translated string
and the component renders it as given. Search.vue was running __() on the
prop, so once the call sites used __() the string would be translated twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YZV7JD2cyMHSeSu2fUXCK
@bpmore

bpmore commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — you're right on all three, and the second one was a real bug rather than a style point.

Search.vue ran __() on the prop, so the moment a call site did the same the string would have been translated twice. It only looked fine because I'd passed raw strings from the two call sites, which is exactly the pattern you're telling me not to use. Pushed in b2fbafc:

  • Search.vue now renders {{ label || __('Search') }} — the fallback is the component's own string so it still gets translated, the supplied one does not
  • both call sites pass :label="__('Search assets')"
  • prop docblock updated to say the value is already translated

I checked the convention before changing it rather than taking it on faith: no component under components/ui calls __() on a label prop, and every :label= call site in the CP translates at the point of use. The only raw label="…" strings are in pages/Playground.vue, which is a demo surface.

Tests: the 5 in ListingSearch.test.js still pass. Full unit suite 568 passed / 9 failed, the 9 being the pre-existing LivePreview failures that also fail on a clean 6.x.

One thing I tried and abandoned, in case you'd want it done properly: a regression test that seeds setTranslations() and asserts a supplied label is not re-translated. I couldn't get it to fail when I deliberately reintroduced __(label) — the translator module instance the test imports isn't the one the component resolves under Vite — so it passed for the wrong reason. I dropped it rather than ship a test that can't catch the regression, and left a comment in the test file explaining the convention instead. Happy to take another run at it if you know the right seam.

Pushed as a normal commit on top rather than a rebase, so your line comments stay anchored.

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.

2 participants