diff --git a/catalog/CHANGELOG.md b/catalog/CHANGELOG.md
index 2cbb430deb3..328b593df2a 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 no longer disappears while you type in "Find metadata" on stacks with truncated facet lists, and it is withheld when the query matches nothing rather than offering to sort an empty list ([#5262](https://github.com/quiltdata/quilt/pull/5262))
- [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))
diff --git a/catalog/app/containers/Search/Layout/PackageFilters.tsx b/catalog/app/containers/Search/Layout/PackageFilters.tsx
index 17d16e8c0f6..627d68655fc 100644
--- a/catalog/app/containers/Search/Layout/PackageFilters.tsx
+++ b/catalog/app/containers/Search/Layout/PackageFilters.tsx
@@ -237,9 +237,10 @@ export function AvailablePackagesMetaFilters({
},
Disabled: () => null,
})(filtering)}
- {/* `offered` rather than a count taken here: withholding it turns on how many
+ {/* `offered` rather than a count taken here: the threshold turns on how many
fields exist, and `facets.available` is already narrowed by the filter box
- on the client-filter path. */}
+ on the filtering paths. The model also withholds it when nothing is
+ displayed, so this is the whole rule. */}
{ordering.offered && (
diff --git a/catalog/app/containers/Search/model.spec.ts b/catalog/app/containers/Search/model.spec.ts
index 30e93f888e7..a3ba42cdb53 100644
--- a/catalog/app/containers/Search/model.spec.ts
+++ b/catalog/app/containers/Search/model.spec.ts
@@ -495,4 +495,36 @@ describe('containers/Search/model', () => {
)
})
})
+
+ describe('orderingOffered', () => {
+ const T = model.FACET_ORDERING_THRESHOLD
+
+ it('withholds the control below the threshold', () => {
+ expect(model.orderingOffered(T - 1, T - 1)).toBe(false)
+ })
+
+ it('offers the control at the threshold', () => {
+ expect(model.orderingOffered(T, T)).toBe(true)
+ })
+
+ it('keeps the control while the reader narrows the list', () => {
+ // The threshold reads the pre-filter total, which does not move while the
+ // filter box is typed in, so narrowing cannot retract the control.
+ expect(model.orderingOffered(T, T)).toBe(true)
+ expect(model.orderingOffered(T, 3)).toBe(true)
+ expect(model.orderingOffered(T, 1)).toBe(true)
+ })
+
+ it('withholds the control while nothing is displayed', () => {
+ // A live "Sort by" above "No metadata found" offers to sort nothing.
+ expect(model.orderingOffered(T, 0)).toBe(false)
+ })
+
+ it('carries no state between calls', () => {
+ // The answer must not depend on call order or on which mount asked.
+ expect(model.orderingOffered(T, T)).toBe(true)
+ expect(model.orderingOffered(1, 1)).toBe(false)
+ expect(model.orderingOffered(T, T)).toBe(true)
+ })
+ })
})
diff --git a/catalog/app/containers/Search/model.ts b/catalog/app/containers/Search/model.ts
index e945eea87d4..191a3a45add 100644
--- a/catalog/app/containers/Search/model.ts
+++ b/catalog/app/containers/Search/model.ts
@@ -1093,11 +1093,12 @@ export const AvailableFiltersState = tagged.create(
ordering: {
value: FacetOrdering
set: (value: FacetOrdering) => void
- // Whether to offer the control at all. Decided here because it turns on how
- // many fields *exist*, not how many currently match the filter box --
- // `facets.available` is the post-filter list on the client-filter path, so
- // gating on it would unmount the control mid-search, exactly while a reader
- // is hunting for a field.
+ // Whether to offer the control at all. Decided here because the threshold
+ // turns on how many fields *exist*, not how many currently match the filter
+ // box — `facets.available` is the post-filter list on the filtering paths,
+ // so gating the threshold on it would unmount the control mid-search,
+ // exactly while a reader is hunting for a field. It is additionally
+ // withheld when nothing is displayed at all: see `orderingOffered`.
offered: boolean
}
fetching: boolean
@@ -1316,7 +1317,12 @@ function AvailablePackagesMetaFiltersServerFilterQuery({
return React.createElement(AvailablePackagesMetaFiltersGroup, {
state,
children,
- totalAvailable: available.length,
+ // `initial`, not `available`: `available` is this query's own result and
+ // shrinks with every keystroke. `initial` is the list as it stood before the
+ // reader started typing, so it is stable across the search — and it is a
+ // genuine lower bound on the total, because the list reaching this path is
+ // truncated, so the server holds at least this many.
+ totalAvailable: initial.length,
})
}
@@ -1364,6 +1370,24 @@ function AvailablePackagesMetaFiltersClientFilter({
})
}
+/**
+ * Whether to offer the ordering switcher.
+ *
+ * Two rules, and they read different counts on purpose:
+ *
+ * - the **threshold** turns on `totalAvailable`, the size of the list before the
+ * reader narrows it. Every caller passes a count that does not move while the
+ * filter box is typed in, so narrowing cannot retract the control mid-word.
+ * - the control is **withheld** while `displayed` is zero, because a live "Sort
+ * by" above "No metadata found" offers to sort nothing.
+ *
+ * Pure, and a plain function rather than a hook: the answer must not depend on
+ * call order or on which mount asked.
+ */
+export function orderingOffered(totalAvailable: number, displayed: number): boolean {
+ return displayed > 0 && totalAvailable >= FACET_ORDERING_THRESHOLD
+}
+
// Every `Ready` path funnels through here before the tree reaches the panel, so
// this is the one place the ordering can own both the sort and the split.
function AvailablePackagesMetaFiltersGroup({
@@ -1372,8 +1396,15 @@ function AvailablePackagesMetaFiltersGroup({
totalAvailable,
}: RenderProps & {
state: AvailableFiltersStateInstance
- // The count *before* any filtering, which the caller still has. `state.facets
- // .available` is already narrowed on the client-filter path.
+ // How many fields the list held before the reader started narrowing it with
+ // the filter box — which only the caller still has, because `state.facets
+ // .available` is already narrowed on every filtering path.
+ //
+ // "Before any filtering" is *not* what this is, and the difference matters:
+ // facet filters the reader has already applied are excluded, and on the
+ // truncated paths the server returned only part of the list. It is a lower
+ // bound on the total that does not move while the filter box is typed in,
+ // which is exactly what the threshold needs and no more than that.
totalAvailable: number
}) {
// From the URL, not local state, so a shared link reproduces the panel the
@@ -1392,7 +1423,7 @@ function AvailablePackagesMetaFiltersGroup({
[available, ordering],
)
- const offered = totalAvailable >= FACET_ORDERING_THRESHOLD
+ const offered = orderingOffered(totalAvailable, available?.length ?? 0)
const orderingState = React.useMemo(
() => ({ value: ordering, set: setOrdering, offered }),