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 /dashboard/accounts/:acctId/sites/:siteId/animal-list/:group

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.