diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md
index 99878fca7f5..2cbb430deb3 100644
--- a/catalog/CHANGELOG.md
+++ b/catalog/CHANGELOG.md
@@ -21,6 +21,7 @@ complete sentence without it.
## Changes
+- [Fixed] Search sidebar: the facet "Sort by" control announces what it is — its label used to land on a hidden input, leaving assistive tech to read the control as its current ordering and nothing more ([#5261](https://github.com/quiltdata/quilt/pull/5261))
- [Fixed] Queries: the query selector announces its label to assistive tech, and no longer claims "Custom" is loaded while its helper text reports the query failed to load ([#5260](https://github.com/quiltdata/quilt/pull/5260))
- [Changed] The `data-products` demo fixture data no longer ships in the bundles a browser downloads on the volumes landing; it loads only when the preview is on ([#5259](https://github.com/quiltdata/quilt/pull/5259))
- [Fixed] Quilt+ URI parsing retains the informational `catalog` field, reads a raw `+` in an unencoded path as a `+` rather than a space, and shares its compatibility corpus with quilt3 ([#5255](https://github.com/quiltdata/quilt/pull/5255))
diff --git a/catalog/app/containers/Search/Layout/PackageFilters.spec.tsx b/catalog/app/containers/Search/Layout/PackageFilters.spec.tsx
new file mode 100644
index 00000000000..0e951aea976
--- /dev/null
+++ b/catalog/app/containers/Search/Layout/PackageFilters.spec.tsx
@@ -0,0 +1,47 @@
+import * as React from 'react'
+import { render, cleanup, fireEvent, screen } from '@testing-library/react'
+import { describe, it, expect, vi, afterEach } from 'vitest'
+
+import * as SearchUIModel from '../model'
+
+import { AvailablePackagesMetaFilters } from './PackageFilters'
+
+vi.mock('constants/config', () => ({ default: {} }))
+
+function renderFilters() {
+ return render(
+ ,
+ )
+}
+
+describe('containers/Search/Layout/PackageFilters', () => {
+ afterEach(cleanup)
+
+ describe('the accessible name', () => {
+ // The exact name, not a substring: the caption alone matches /Sort by/ too,
+ // so a loosened assertion passes on wiring that drops the ordering.
+ it('carries the label and the selected ordering', () => {
+ renderFilters()
+ expect(screen.getByRole('button', { name: 'Sort by: Name A → Z' })).toBeTruthy()
+ })
+
+ it('names the popup listbox too', () => {
+ renderFilters()
+ fireEvent.mouseDown(screen.getByRole('button'))
+ expect(screen.getByRole('listbox', { name: 'Sort by:' })).toBeTruthy()
+ })
+ })
+})
diff --git a/catalog/app/containers/Search/Layout/PackageFilters.tsx b/catalog/app/containers/Search/Layout/PackageFilters.tsx
index d7f884c93d6..17d16e8c0f6 100644
--- a/catalog/app/containers/Search/Layout/PackageFilters.tsx
+++ b/catalog/app/containers/Search/Layout/PackageFilters.tsx
@@ -8,6 +8,7 @@ import * as FiltersUI from 'components/Filters'
import Skeleton from 'components/Skeleton'
import * as JSONPointer from 'utils/JSONPointer'
import * as NamedRoutes from 'utils/NamedRoutes'
+import useId from 'utils/useId'
import FilterWidget from '../FilterWidget'
import { PACKAGE_FILTER_LABELS } from '../i18n'
@@ -189,7 +190,9 @@ interface AvailablePackagesMetaFiltersProps {
fetching: boolean
}
-function AvailablePackagesMetaFilters({
+// Exported for testing: the ordering control's accessible name lives in how
+// props land on MUI's Select internals, which only a render can assert.
+export function AvailablePackagesMetaFilters({
className,
filtering,
facets,
@@ -197,6 +200,8 @@ function AvailablePackagesMetaFilters({
fetching,
}: AvailablePackagesMetaFiltersProps) {
const classes = useAvailablePackagesMetaFiltersStyles()
+ const orderLabelId = useId()
+ const orderButtonId = useId()
const [expanded, setExpanded] = React.useState(false)
const toggleExpanded = React.useCallback(() => setExpanded((x) => !x), [])
@@ -237,7 +242,7 @@ function AvailablePackagesMetaFilters({
on the client-filter path. */}
{ordering.offered && (
-
+
Sort by:
@@ -246,7 +251,12 @@ function AvailablePackagesMetaFilters({
disabled={fetching}
extents={FACET_ORDERING_VALUES}
getOptionLabel={(value) => FACET_ORDERING_LABELS[value]}
- inputProps={{ 'aria-labelledby': 'meta-order-label' }}
+ // `labelId` reaches the focusable display div and names the popup
+ // listbox; `id` must come with it, since MUI joins the two into
+ // `aria-labelledby="labelId buttonId"` and `labelId` alone would
+ // override the div's contents, dropping the ordering from the name.
+ labelId={orderLabelId}
+ id={orderButtonId}
onChange={(value) =>
ordering.set(SearchUIModel.parseFacetOrdering(value, ordering.value))
}