Index filters

Use index filters to allow merchants to filter, search, and sort their index table data and create unique saved views from the results.

An IndexFilters component with view management, search, filtering, and sorting.

import {
  TextField,
  IndexTable,
  LegacyCard,
  IndexFilters,
  useSetIndexFiltersMode,
  useIndexResourceState,
  Text,
  ChoiceList,
  RangeSlider,
  Badge,
} from '@shopify/polaris';
import type {IndexFiltersProps, TabProps} from '@shopify/polaris';
import {useState, useCallback} from 'react';

function IndexFiltersDefaultExample() {
  const sleep = (ms: number) =>
    new Promise((resolve) => setTimeout(resolve, ms));
  const [itemStrings, setItemStrings] = useState([
    'All',
    'Unpaid',
    'Open',
    'Closed',
    'Local delivery',
    'Local pickup',
  ]);
  const deleteView = (index: number) => {
    const newItemStrings = [...itemStrings];
    newItemStrings.splice(index, 1);
    setItemStrings(newItemStrings);
    setSelected(0);
  };

  const duplicateView = async (name: string) => {
    setItemStrings([...itemStrings, name]);
    setSelected(itemStrings.length);
    await sleep(1);
    return true;
  };

  const tabs: TabProps[] = itemStrings.map((item, index) => ({
    content: item,
    index,
    onAction: () => {},
    id: `${item}-${index}`,
    isLocked: index === 0,
    actions:
      index === 0
        ? []
        : [
            {
              type: 'rename',
              onAction: () => {},
              onPrimaryAction: async (value: string): Promise<boolean> => {
                const newItemsStrings = tabs.map((item, idx) => {
                  if (idx === index) {
                    return value;
                  }
                  return item.content;
                });
                await sleep(1);
                setItemStrings(newItemsStrings);
                return true;
              },
            },
            {
              type: 'duplicate',
              onPrimaryAction: async (value: string): Promise<boolean> => {
                await sleep(1);
                duplicateView(value);
                return true;
              },
            },
            {
              type: 'edit',
            },
            {
              type: 'delete',
              onPrimaryAction: async () => {
                await sleep(1);
                deleteView(index);
                return true;
              },
            },
          ],
  }));
  const [selected, setSelected] = useState(0);
  const onCreateNewView = async (value: string) => {
    await sleep(500);
    setItemStrings([...itemStrings, value]);
    setSelected(itemStrings.length);
    return true;
  };
  const sortOptions: IndexFiltersProps['sortOptions'] = [
    {label: 'Order', value: 'order asc', directionLabel: 'Ascending'},
    {label: 'Order', value: 'order desc', directionLabel: 'Descending'},
    {label: 'Customer', value: 'customer asc', directionLabel: 'A-Z'},
    {label: 'Customer', value: 'customer desc', directionLabel: 'Z-A'},
    {label: 'Date', value: 'date asc', directionLabel: 'A-Z'},
    {label: 'Date', value: 'date desc', directionLabel: 'Z-A'},
    {label: 'Total', value: 'total asc', directionLabel: 'Ascending'},
    {label: 'Total', value: 'total desc', directionLabel: 'Descending'},
  ];
  const [sortSelected, setSortSelected] = useState(['order asc']);
  const {mode, setMode} = useSetIndexFiltersMode();
  const onHandleCancel = () => {};

  const onHandleSave = async () => {
    await sleep(1);
    return true;
  };

  const primaryAction: IndexFiltersProps['primaryAction'] =
    selected === 0
      ? {
          type: 'save-as',
          onAction: onCreateNewView,
          disabled: false,
          loading: false,
        }
      : {
          type: 'save',
          onAction: onHandleSave,
          disabled: false,
          loading: false,
        };
  const [accountStatus, setAccountStatus] = useState<string[] | undefined>(
    undefined,
  );
  const [moneySpent, setMoneySpent] = useState<[number, number] | undefined>(
    undefined,
  );
  const [taggedWith, setTaggedWith] = useState('');
  const [queryValue, setQueryValue] = useState('');

  const handleAccountStatusChange = useCallback(
    (value: string[]) => setAccountStatus(value),
    [],
  );
  const handleMoneySpentChange = useCallback(
    (value: [number, number]) => setMoneySpent(value),
    [],
  );
  const handleTaggedWithChange = useCallback(
    (value: string) => setTaggedWith(value),
    [],
  );
  const handleFiltersQueryChange = useCallback(
    (value: string) => setQueryValue(value),
    [],
  );
  const handleAccountStatusRemove = useCallback(
    () => setAccountStatus(undefined),
    [],
  );
  const handleMoneySpentRemove = useCallback(
    () => setMoneySpent(undefined),
    [],
  );
  const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []);
  const handleQueryValueRemove = useCallback(() => setQueryValue(''), []);
  const handleFiltersClearAll = useCallback(() => {
    handleAccountStatusRemove();
    handleMoneySpentRemove();
    handleTaggedWithRemove();
    handleQueryValueRemove();
  }, [
    handleAccountStatusRemove,
    handleMoneySpentRemove,
    handleQueryValueRemove,
    handleTaggedWithRemove,
  ]);

  const filters = [
    {
      key: 'accountStatus',
      label: 'Account status',
      filter: (
        <ChoiceList
          title="Account status"
          titleHidden
          choices={[
            {label: 'Enabled', value: 'enabled'},
            {label: 'Not invited', value: 'not invited'},
            {label: 'Invited', value: 'invited'},
            {label: 'Declined', value: 'declined'},
          ]}
          selected={accountStatus || []}
          onChange={handleAccountStatusChange}
          allowMultiple
        />
      ),
      shortcut: true,
    },
    {
      key: 'taggedWith',
      label: 'Tagged with',
      filter: (
        <TextField
          label="Tagged with"
          value={taggedWith}
          onChange={handleTaggedWithChange}
          autoComplete="off"
          labelHidden
        />
      ),
      shortcut: true,
    },
    {
      key: 'moneySpent',
      label: 'Money spent',
      filter: (
        <RangeSlider
          label="Money spent is between"
          labelHidden
          value={moneySpent || [0, 500]}
          prefix="$"
          output
          min={0}
          max={2000}
          step={1}
          onChange={handleMoneySpentChange}
        />
      ),
    },
  ];

  const appliedFilters: IndexFiltersProps['appliedFilters'] = [];
  if (accountStatus && !isEmpty(accountStatus)) {
    const key = 'accountStatus';
    appliedFilters.push({
      key,
      label: disambiguateLabel(key, accountStatus),
      onRemove: handleAccountStatusRemove,
    });
  }
  if (moneySpent) {
    const key = 'moneySpent';
    appliedFilters.push({
      key,
      label: disambiguateLabel(key, moneySpent),
      onRemove: handleMoneySpentRemove,
    });
  }
  if (!isEmpty(taggedWith)) {
    const key = 'taggedWith';
    appliedFilters.push({
      key,
      label: disambiguateLabel(key, taggedWith),
      onRemove: handleTaggedWithRemove,
    });
  }

  const orders = [
    {
      id: '1020',
      order: (
        <Text as="span" variant="bodyMd" fontWeight="semibold">
          #1020
        </Text>
      ),
      date: 'Jul 20 at 4:34pm',
      customer: 'Jaydon Stanton',
      total: '$969.44',
      paymentStatus: <Badge progress="complete">Paid</Badge>,
      fulfillmentStatus: <Badge progress="incomplete">Unfulfilled</Badge>,
    },
    {
      id: '1019',
      order: (
        <Text as="span" variant="bodyMd" fontWeight="semibold">
          #1019
        </Text>
      ),
      date: 'Jul 20 at 3:46pm',
      customer: 'Ruben Westerfelt',
      total: '$701.19',
      paymentStatus: <Badge progress="partiallyComplete">Partially paid</Badge>,
      fulfillmentStatus: <Badge progress="incomplete">Unfulfilled</Badge>,
    },
    {
      id: '1018',
      order: (
        <Text as="span" variant="bodyMd" fontWeight="semibold">
          #1018
        </Text>
      ),
      date: 'Jul 20 at 3.44pm',
      customer: 'Leo Carder',
      total: '$798.24',
      paymentStatus: <Badge progress="complete">Paid</Badge>,
      fulfillmentStatus: <Badge progress="incomplete">Unfulfilled</Badge>,
    },
  ];
  const resourceName = {
    singular: 'order',
    plural: 'orders',
  };

  const {selectedResources, allResourcesSelected, handleSelectionChange} =
    useIndexResourceState(orders);

  const rowMarkup = orders.map(
    (
      {id, order, date, customer, total, paymentStatus, fulfillmentStatus},
      index,
    ) => (
      <IndexTable.Row
        id={id}
        key={id}
        selected={selectedResources.includes(id)}
        position={index}
      >
        <IndexTable.Cell>
          <Text variant="bodyMd" fontWeight="bold" as="span">
            {order}
          </Text>
        </IndexTable.Cell>
        <IndexTable.Cell>{date}</IndexTable.Cell>
        <IndexTable.Cell>{customer}</IndexTable.Cell>
        <IndexTable.Cell>{total}</IndexTable.Cell>
        <IndexTable.Cell>{paymentStatus}</IndexTable.Cell>
        <IndexTable.Cell>{fulfillmentStatus}</IndexTable.Cell>
      </IndexTable.Row>
    ),
  );

  return (
    <LegacyCard>
      <IndexFilters
        sortOptions={sortOptions}
        sortSelected={sortSelected}
        queryValue={queryValue}
        queryPlaceholder="Searching in all"
        onQueryChange={handleFiltersQueryChange}
        onQueryClear={() => {}}
        onSort={setSortSelected}
        primaryAction={primaryAction}
        cancelAction={{
          onAction: onHandleCancel,
          disabled: false,
          loading: false,
        }}
        tabs={tabs}
        selected={selected}
        onSelect={setSelected}
        canCreateNewView
        onCreateNewView={onCreateNewView}
        filters={filters}
        appliedFilters={appliedFilters}
        onClearAll={handleFiltersClearAll}
        mode={mode}
        setMode={setMode}
      />
      <IndexTable
        resourceName={resourceName}
        itemCount={orders.length}
        selectedItemsCount={
          allResourcesSelected ? 'All' : selectedResources.length
        }
        onSelectionChange={handleSelectionChange}
        headings={[
          {title: 'Order'},
          {title: 'Date'},
          {title: 'Customer'},
          {title: 'Total', alignment: 'end'},
          {title: 'Payment status'},
          {title: 'Fulfillment status'},
        ]}
      >
        {rowMarkup}
      </IndexTable>
    </LegacyCard>
  );

  function disambiguateLabel(key: string, value: string | any[]): string {
    switch (key) {
      case 'moneySpent':
        return `Money spent is between $${value[0]} and $${value[1]}`;
      case 'taggedWith':
        return `Tagged with ${value}`;
      case 'accountStatus':
        return (value as string[]).map((val) => `Customer ${val}`).join(', ');
      default:
        return value as string;
    }
  }

  function isEmpty(value: string | any[]) {
    if (Array.isArray(value)) {
      return value.length === 0;
    } else {
      return value === '' || value == null;
    }
  }
}

Props

Want to help make this feature better? Please share your feedback.

interface IndexFiltersProps
sortOptions?[]

The available sorting choices. If not present, the sort button will not show.

sortSelected?string[]

The currently selected sort choice. Required if using sorting.

onSort?(value: string[]) => void

Optional callback invoked when a merchant changes the sort order. Required if using sorting.

onSortKeyChange?(value: string) => void

Optional callback when using saved views and changing the sort key.

onSortDirectionChange?(value: string) => void

Optional callback when using saved views and changing the sort direction.

onAddFilterClick?() => void

Callback when the add filter button is clicked, to be passed to AlphaFilters.

primaryAction?

The primary action to display.

cancelAction

The cancel action to display.

onEditStart?() => void

Optional callback invoked when a merchant begins to edit a view.

mode

The current mode of the IndexFilters component. Used to determine which view to show.

setMode(mode: ) => void

Callback to set the mode of the IndexFilters component.

disabled?boolean

Will disable all the elements within the IndexFilters component.

disableQueryField?boolean

Will disable just the query field.

disableStickyMode?boolean

If true, the sticky interaction on smaller devices will be disabled.

isFlushWhenSticky?boolean

If the component should go flush to the top of the page when sticking.

canCreateNewView?boolean

Whether the index supports creating new views.

onCreateNewView?(name: string) => Promise<boolean>

Callback invoked when a merchant creates a new view.

filteringAccessibilityLabel?string

Optional override to the default aria-label for the button that toggles the filtering mode.

filteringAccessibilityTooltip?string

Optional override to the default Tooltip message for the button that toggles the filtering mode.

closeOnChildOverlayClick?boolean

Whether the filter should close when clicking inside another Popover.

disableKeyboardShortcuts?boolean

Optional override to the default keyboard shortcuts available.

Merchants use filters to:

  • Create different subsets of list items
  • Search list items by typing a query into the text input
  • Sort list items by column

The way that merchants interact with index filters depends on the components that you decide to incorporate. It supports configuration of a search query input, sorting options, and one or more filters that can be made up of different inputs.

Merchants use the tabs in index tables to:

  • Control which view is visible
  • Edit the applied filters and search terms of a view
  • Create, rename, duplicate, or delete views

You can create views and control which actions can be performed on a particular view.

Anatomy

A diagram of the IndexFilters component showing the components it is composed of. Index filters are made up of the following:

  1. Tabs: A list of saved views. Each tab represents a subset of the list that has been sorted, filtered, and or queried and saved with a unique name. New views can be created directly from the tab list, or by editing the filters, query, or sort selection of an existing view and saving it as new.
  2. Search and filter, and sort buttons: The search and filter button allows merchants to toggle the index table from "View" mode to "Filter" mode. When clicked, the button reveals the search field and the filters that allow merchants to edit or create saved views. The sort button activates a popover displaying a list of options merchants can choose from to sort the list items. Merchants can also choose whether the list should be sorted in ascending or descending order.
  3. Filters: A set of useful ways to narrow down the list based on the common actions merchants may need to take on the data. The filters should present merchants with form inputs that help them include or exclude list items from the view based on their data.
  4. Action buttons: Primary and secondary actions that a merchant can take on the current view. The primary action will always be either "Save" or "Save as" depending on whether the view is mutable, and the secondary action will always be "Cancel".

Accessibility

The filters component relies on the accessibility features of several other components:

Maintain accessibility with custom features

Since custom HTML can be passed to the component for additional actions, ensure that the filtering system you build is accessible as a whole.

All merchants must be able to:

  • Identify and understand the labels of all controls
  • Be notified of state changes as they use the filter controls
  • Complete all actions using a keyboard

Best practices

Index filters should:

  • Reduce merchant effort by promoting the filtering categories that are most commonly used
  • Include no more than 2 or 3 promoted filters
  • Consider small screen sizes when designing the interface for each filter and the total number filters to include
  • Use children only for content that’s related or relevant to filtering

Content guidelines

Text field

The text field should be clearly labeled so it’s obvious to merchants what they should enter into the field.

Do

  • Filter orders

Don’t

  • Enter text here

Filter badges

Use the name of the filter if the purpose of the name is clear on its own. For example, when you see a filter badge that reads Fulfilled, it’s intuitive that it falls under the Fulfillment status category.

Do

  • Fulfilled, Unfulfilled

Don’t

  • Fulfillment: Fulfilled, Unfulfilled

If the filter name is ambiguous on its own, add a descriptive word related to the status. For example, Low doesn’t make sense out of context. Add the word “risk” so that merchants know it’s from the Risk category.

Do

  • High risk, Low risk

Don’t

  • High, Low

Group tags from the same category together.

Do

  • (Unfulfilled, Fulfilled)

Don’t

  • (Unfulfilled) (fulfilled)

If all tag pills selected: truncate in the middle

Do

  • Paid, par… unpaid

Don’t

  • All payment status filters selected, Paid, unpa…