Pattern
List page
Managing a set of records: the header with its actions, then the search, filter pills, Sort, and count line, then the rows.
Copy it from /
Header, toolbar, and rows
ListPageHeader, then whatever the page shows between the header and the list (the Animals tabs), then ListToolbar, then the rows. Three pills of their own, then Tags and Care grouped under More, Sort, and the count line. Search or pick to see the count follow, and press Bulk edit to see it pressed; on the page the selection bar’s Cancel leaves the mode. The code is the Animals page’s own: the header from its layout, the toolbar and five of its filters with the builders they call.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group - Screenshot
- docs/
agents/ ui-patterns/ list-page-1440.png
Animals
A list of all the animals in the system.
3 animals match
- MochiAvailable
Siamese·Female
- JuniperAvailable
Labrador Retriever·Female
- PepperAvailable
Domestic Shorthair·Female
// animal-list.layout.tsx: the header, above the tabs.
<ListPageHeader
title="Animals"
caption="A list of all the animals in the system."
actions={[
// The mode is left from the bar's Cancel, which also clears the
// selection, so here Bulk edit only starts it and shows it is on.
// It reads the same in both states, so nothing in the row moves
// when the mode starts.
...(canBulkEdit
? [
{
label: 'Bulk edit',
icon: 'select' as const,
onPress: bulkEditMode.start,
isPressed: bulkEditMode.isSelecting,
},
]
: []),
...(canAddAnimals
? [
{
label: 'Bulk add',
icon: 'bulk' as const,
to: `${siteUrl}/animals/bulk-add`,
},
]
: []),
]}
primary={
canAddAnimals
? {
label: 'Add animal',
icon: 'add',
to: `${siteUrl}/animals/active/new/edit`,
}
: undefined
}
/>
// animal-list-filters.tsx: the toolbar, under the tabs.
const NOUN = { one: 'animal', other: 'animals' }
const { sort, onSortChange, location, updateParams, isNavigating } = state
return (
<ListToolbar
// The search the URL holds now, not the navigation under way, so the
// toolbar can tell when a search has landed.
searchValue={
new URLSearchParams(location.search).get(SearchKeys.SearchText) ?? ''
}
onSearchChange={(text, { replace }) =>
updateParams({ [SearchKeys.SearchText]: text }, { replace })
}
isNavigating={isNavigating}
searchLabel="Search animals"
searchPlaceholder="Search name, number, color, person"
sort={{
options: SORT_OPTIONS.map(option => ({ ...option })),
value: sort.key,
isDefault: sort.isDefault,
onChange: next => {
const [column, direction] = next.split('-')
onSortChange(
listSort(column, direction === 'ascending' ? 'asc' : 'desc'),
)
},
}}
filters={useListFilters(state, loaderData)}
count={loaderData.matchCount}
isFiltered={loaderData.isFiltered}
noun={NOUN}
onClearFilters={state.clearFilters}
/>
)
// The same file builds the filters. The page builds more than these
// five; each kind is here.
/** Every filter, in the order the pills show them. */
function useListFilters(
state: AnimalListState,
loaderData: Route.ComponentProps['loaderData'],
): ListFilter[] {
const { groupStatuses, filterOptions } = loaderData
const { filterValues, setFilter } = state
return [
listField({
label: 'Status',
options: groupStatuses.map(status => ({
key: status.id,
label: status.label,
})),
value: filterValues(SearchKeys.Status),
onChange: next => setFilter({ [SearchKeys.Status]: next }),
}),
listField({
label: 'Gender',
options: genders.map(gender => ({ key: gender.id, label: gender.label })),
value: filterValues(SearchKeys.Gender),
onChange: next => setFilter({ [SearchKeys.Gender]: next }),
}),
listField({
label: 'Age',
options: AGE_OPTIONS.map(age => ({ key: age.key, label: age.label })),
value: filterValues(SearchKeys.Age),
onChange: next => setFilter({ [SearchKeys.Age]: next }),
}),
...(filterOptions.tags.length > 0
? [
moreField(state, {
label: 'Tags',
key: SearchKeys.Tags,
options: filterOptions.tags.map(tag => ({
key: tag.id,
label: tag.label,
})),
}),
]
: []),
careField(state),
]
}
function chosenLabels(
options: MultiSelectPopoverOption<string>[],
value: string[],
) {
return options.filter(o => value.includes(o.key)).map(o => o.label)
}
function listField({
label,
options,
value,
onChange,
}: {
label: string
options: MultiSelectPopoverOption<string>[]
value: string[]
onChange: (next: string[]) => void
}): ListFilter {
const setLabels = chosenLabels(options, value)
return {
label,
options,
value,
onChange,
setLabels,
setCount: setLabels.length > 0 ? 1 : 0,
}
}
/**
* A field under More that writes one list parameter. Its values read with
* the field's name in the More pill, as "Tags: Bottle Baby".
*/
function moreField(
state: AnimalListState,
{
label,
key,
options,
startsColumn,
}: {
label: string
key: SearchKeys
options: MultiSelectPopoverOption<string>[]
startsColumn?: boolean
},
): ListFilter {
const value = state.filterValues(key)
const setLabels = chosenLabels(options, value).map(
chosen => `${label}: ${chosen}`,
)
return {
label,
group: 'more',
startsColumn,
options,
value,
onChange: next => state.setFilter({ [key]: next }),
setLabels,
setCount: setLabels.length > 0 ? 1 : 0,
}
}
/**
* Care, under More: Needs a Foster, On medication, and a Medication date,
* which replaces On medication and the other way round. Its two options read
* on their own in the More pill.
*/
function careField(state: AnimalListState): ListFilter {
const { query, setFilter } = state
const { medicationDate } = query
const options = [
{ key: 'needsFoster', label: 'Needs a Foster' },
{ key: 'onMedication', label: 'On medication' },
]
const value = [
...(query.needsFoster === 'true' ? ['needsFoster'] : []),
...(!medicationDate && query.onMedication === 'true'
? ['onMedication']
: []),
]
return {
label: 'Care',
group: 'more',
options,
value,
setLabels: [
...chosenLabels(options, value),
...(medicationDate
? [`Medication date: ${formatCalendarDate(medicationDate)}`]
: []),
],
setCount: value.length + (medicationDate ? 1 : 0),
onChange: next =>
setFilter({
[SearchKeys.NeedsFoster]: next.includes('needsFoster') ? 'true' : null,
[SearchKeys.OnMedication]: next.includes('onMedication')
? 'true'
: null,
...(next.includes('onMedication') && !value.includes('onMedication')
? { [SearchKeys.MedicationDate]: null }
: {}),
}),
childrenAfterOption: 'onMedication',
children: (
<FilterDate
id="medication-date"
heading="Medication date"
value={medicationDate}
onChange={value =>
setFilter({
[SearchKeys.MedicationDate]: value,
...(value ? { [SearchKeys.OnMedication]: null } : {}),
})
}
/>
),
}
}A search or filter that matches nothing
NoResults with a hint whose link clears the filters, and Clear filters in the toolbar. Only after a search or filter. The header and toolbar are the code above; this is what replaces the rows.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group - Screenshot
- docs/
agents/ ui-patterns/ empty-search-1440.png
Animals
A list of all the animals in the system.
0 animals match
No animals match this search or filter.
Try a different search or .
// animal-list-rows.tsx, in place of the rows.
<NoResults
message="No animals match this search or filter."
hint={
<>
Try a different search or{' '}
<button
type="button"
onClick={state.clearFilters}
className="text-brand-600 hover:text-brand-700"
>
clear filters
</button>
.
</>
}
/>An empty tab
With no search or filter set and nothing to list, the toolbar drops its count line and the page shows EmptyState with the action that fills it, for a caller who can take it. The header and toolbar are the code above; this is what replaces the rows.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group
Animals
A list of all the animals in the system.
No animals on this tab yet.
// animal-list-rows.tsx, in place of the rows.
<EmptyState
icon="animal"
message={
// A user who sees only their assigned animals may have none
// on a tab that has animals, so "yet" would be untrue.
loaderData.showScopeBanner
? 'No animals assigned to you on this tab.'
: 'No animals on this tab yet.'
}
// Adding animals needs site-wide Edit, as deleting them does.
action={
group === 'active' && canDelete
? {
label: 'Add animal',
icon: 'add',
to: `/dashboard/accounts/${acctId}/sites/${siteId}/animals/active/new/edit`,
}
: undefined
}
/>The phone Filters drawer
Under 640px wide the pills sit behind one Filters button beside Sort, which reads "Filters · 2" with two fields set, and Clear filters moves to the end of the count line. The button opens every filter in a drawer whose primary, "Show 2 animals", closes it on the list it counts. Narrow the window to open it here.
- Route
- /
dashboard/ accounts/ :acctId/ sites/ :siteId/ animal-list/ :group - Screenshot
- docs/
agents/ ui-patterns/ list-page-390.png
Animals
A list of all the animals in the system.
2 animals match
- MochiAvailable
Siamese·Female
- JuniperAvailable
Labrador Retriever·Female
// No code of its own: under 640px ListToolbar draws the Filters
// button and drawer from the same filters.