Skip to content
This repository was archived by the owner on Sep 30, 2025. It is now read-only.
Merged
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
5 changes: 5 additions & 0 deletions .changeset/lazy-hornets-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/polaris': minor
---

Added `allowFiltering` prop on `ActionList`, and `filterActions` prop on Page Header
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export function WithFiltering() {
<div style={{height: '250px', maxWidth: '350px'}}>
<ActionList
actionRole="menuitem"
allowFiltering
items={Array.from({length: 8}).map((_, index) => ({
content: `Item #${index + 1}`,
}))}
Expand Down
30 changes: 18 additions & 12 deletions polaris-react/src/components/ActionList/ActionList.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import React, {useMemo, useRef, useState} from 'react';
import React, {useContext, useMemo, useRef, useState} from 'react';

import type {ActionListItemDescriptor, ActionListSection} from '../../types';
import {Key} from '../../types';
import {
wrapFocusNextFocusableMenuItem,
wrapFocusPreviousFocusableMenuItem,
} from '../../utilities/focus';
import {useI18n} from '../../utilities/i18n';
import {Box} from '../Box';
import {KeypressListener} from '../KeypressListener';
import {useI18n} from '../../utilities/i18n';
import {FilterActionsContext} from '../FilterActionsProvider';

import {SearchField, Item, Section} from './components';
import type {ItemProps} from './components';
import {Item, SearchField, Section} from './components';

export interface ActionListProps {
/** Collection of actions for list */
Expand All @@ -20,20 +21,25 @@ export interface ActionListProps {
sections?: readonly ActionListSection[];
/** Defines a specific role attribute for each action in the list */
actionRole?: 'menuitem' | string;
/** Allow users to filter items in the list. Will only show if more than 8 items in the list. The item content of every items must be a string for this to work */
allowFiltering?: boolean;
/** Callback when any item is clicked or keypressed */
onActionAnyItem?: ActionListItemDescriptor['onAction'];
}

const FILTER_ACTIONS_THRESHOLD = 8;

export type ActionListItemProps = ItemProps;

export function ActionList({
items,
sections = [],
actionRole,
allowFiltering,
onActionAnyItem,
}: ActionListProps) {
const i18n = useI18n();

const filterActions = useContext(FilterActionsContext);
let finalSections: readonly ActionListSection[] = [];
const actionListRef = useRef<HTMLDivElement & HTMLUListElement>(null);
const [searchText, setSeachText] = useState('');
Expand Down Expand Up @@ -116,12 +122,6 @@ export function ActionList({
</>
) : null;

const totalActions =
finalSections?.reduce(
(acc: number, section) => acc + section.items.length,
0,
) || 0;

const totalFilteredActions = useMemo(() => {
const totalSectionItems =
filteredSections?.reduce(
Expand All @@ -132,11 +132,17 @@ export function ActionList({
return totalSectionItems;
}, [filteredSections]);

const showSearch = totalActions >= 8;
const totalActions =
finalSections?.reduce(
(acc: number, section) => acc + section.items.length,
0,
) || 0;

const hasManyActions = totalActions >= FILTER_ACTIONS_THRESHOLD;

return (
<>
{showSearch && isFilterable && (
{(allowFiltering || filterActions) && hasManyActions && isFilterable && (
<Box padding="2" paddingBlockEnd={totalFilteredActions > 0 ? '0' : '2'}>
<SearchField
placeholder={i18n.translate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ describe('<ActionList />', () => {
it('does not render search with 7 or less items', () => {
const actionList = mountWithApp(
<ActionList
allowFiltering
items={[
{content: 'Item 1'},
{content: 'Item 2'},
Expand All @@ -258,6 +259,7 @@ describe('<ActionList />', () => {
it('renders search with 8 or more items', () => {
const actionList = mountWithApp(
<ActionList
allowFiltering
items={[
{content: 'Item 1'},
{content: 'Item 2'},
Expand All @@ -276,10 +278,32 @@ describe('<ActionList />', () => {
expect(actionList).toContainReactComponentTimes(SearchField, 1);
});

it('renders search with 10 or more items or section items', () => {
it('does not renders search with 8 and no allowFiltering', () => {
const actionList = mountWithApp(
<ActionList
items={[
{content: 'Item 1'},
{content: 'Item 2'},
{content: 'Item 3'},
{content: 'Item 4'},
{content: 'Item 5'},
{content: 'Item 6'},
{content: 'Item 7'},
{content: 'Item 8'},
{content: 'Item 9'},
{content: 'Item 10'},
]}
/>,
);

expect(actionList).not.toContainReactComponentTimes(SearchField, 1);
});

it('renders search with 8 or more items or section items', () => {
const actionList = mountWithApp(
<ActionList
items={[{content: 'Item 1'}, {content: 'Item 2'}]}
allowFiltering
sections={[
{
title: '',
Expand Down Expand Up @@ -308,6 +332,7 @@ describe('<ActionList />', () => {
const actionList = mountWithApp(
<ActionList
items={[{content: 'IteM 1'}, {content: 'Item 2'}]}
allowFiltering
sections={[
{
title: 'Section 1',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {PropsWithChildren} from 'react';
import React, {createContext} from 'react';

export const FilterActionsContext = createContext<boolean>(false);

type FilterActionsProviderProps = PropsWithChildren<{
filterActions: boolean;
}>;

export function FilterActionsProvider({
children,
filterActions,
}: FilterActionsProviderProps) {
return (
<FilterActionsContext.Provider value={filterActions}>
{children}
</FilterActionsContext.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './FilterActionsProvider';
4 changes: 3 additions & 1 deletion polaris-react/src/components/Page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export function Page({
divider && hasHeaderContent && styles.divider,
);

const headerMarkup = hasHeaderContent ? <Header {...rest} /> : null;
const headerMarkup = hasHeaderContent ? (
<Header filterActions {...rest} />
) : null;

return (
<div className={pageClassName}>
Expand Down
64 changes: 35 additions & 29 deletions polaris-react/src/components/Page/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {isReactElement} from '../../../../utilities/is-react-element';
import {Box} from '../../../Box';
import {HorizontalStack} from '../../../HorizontalStack';
import {useFeatures} from '../../../../utilities/features';
import {FilterActionsProvider} from '../../../FilterActionsProvider';

import {Title} from './components';
import type {TitleProps} from './components';
Expand All @@ -49,6 +50,8 @@ interface PrimaryAction
export interface HeaderProps extends TitleProps {
/** Visually hide the title */
titleHidden?: boolean;
/** Enables filtering action list items */
filterActions?: boolean;
/** Primary page-level action */
primaryAction?: PrimaryAction | React.ReactNode;
/** Page-level pagination */
Expand Down Expand Up @@ -79,6 +82,7 @@ export function Header({
titleHidden = false,
primaryAction,
pagination,
filterActions,
additionalNavigation,
backAction,
secondaryActions = [],
Expand Down Expand Up @@ -229,35 +233,37 @@ export function Header({
visuallyHidden={titleHidden}
>
<div className={headerClassNames}>
<ConditionalRender
condition={[slot1, slot2, slot3, slot4].some(notNull)}
>
<div className={styles.Row}>
{slot1}
{slot2}
<ConditionalRender condition={[slot3, slot4].some(notNull)}>
<div className={styles.RightAlign}>
<ConditionalWrapper
condition={[slot3, slot4].every(notNull)}
wrapper={(children) => (
<div className={styles.Actions}>{children}</div>
)}
>
{slot3}
{slot4}
</ConditionalWrapper>
</div>
</ConditionalRender>
</div>
</ConditionalRender>
<ConditionalRender condition={[slot5, slot6].some(notNull)}>
<div className={styles.Row}>
<HorizontalStack gap="4">{slot5}</HorizontalStack>
<ConditionalRender condition={slot6 != null}>
<div className={styles.RightAlign}>{slot6}</div>
</ConditionalRender>
</div>
</ConditionalRender>
<FilterActionsProvider filterActions={Boolean(filterActions)}>
<ConditionalRender
condition={[slot1, slot2, slot3, slot4].some(notNull)}
>
<div className={styles.Row}>
{slot1}
{slot2}
<ConditionalRender condition={[slot3, slot4].some(notNull)}>
<div className={styles.RightAlign}>
<ConditionalWrapper
condition={[slot3, slot4].every(notNull)}
wrapper={(children) => (
<div className={styles.Actions}>{children}</div>
)}
>
{slot3}
{slot4}
</ConditionalWrapper>
</div>
</ConditionalRender>
</div>
</ConditionalRender>
<ConditionalRender condition={[slot5, slot6].some(notNull)}>
<div className={styles.Row}>
<HorizontalStack gap="4">{slot5}</HorizontalStack>
<ConditionalRender condition={slot6 != null}>
<div className={styles.RightAlign}>{slot6}</div>
</ConditionalRender>
</div>
</ConditionalRender>
</FilterActionsProvider>
</div>
</Box>
);
Expand Down