Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 3 additions & 2 deletions catalog/app/containers/Search/Layout/PackageFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<div className={classes.order}>
<span className={classes.orderLabel} id={orderLabelId}>
Expand Down
32 changes: 32 additions & 0 deletions catalog/app/containers/Search/model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
49 changes: 40 additions & 9 deletions catalog/app/containers/Search/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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({
Expand All @@ -1372,8 +1396,15 @@ function AvailablePackagesMetaFiltersGroup({
totalAvailable,
}: RenderProps<AvailableFiltersStateInstance> & {
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
Expand All @@ -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 }),
Expand Down
Loading