[BUG] Wrong position of IndexFilter on mobile

We have a view with an IndexTable with an IndexFilter on our application future landing page and we face this bug:

On mobile screen when the user scroll the IndexFilter is badly positionned and it makes the IndexTable unusable. The bug is easily reproduceable in the Polaris Storybook.
Here is a screenshot of the behavior:

To make the fix easier we have created this simple component below that is reproducing it as well.

import {useCallback, useEffect, useState} from "react";
import {
    Card,
    ChoiceList,
    IndexFilters,
    IndexFiltersMode, IndexTable,
    RangeSlider,
    Text,
    TextField, useIndexResourceState,
    useSetIndexFiltersMode
} from "@shopify/polaris";


export default function Testindextablewithindexfilter() {

    function Table() {
        const customers = [
            {
                id: '3411',
                url: '#',
                name: 'Mae Jemison',
                location: 'Decatur, USA',
                orders: 20,
                amountSpent: '$2,400',
            },
            {
                id: '2561',
                url: '#',
                name: 'Ellen Ochoa',
                location: 'Los Angeles, USA',
                orders: 30,
                amountSpent: '$140',
            },
            {
                id: '1412',
                url: '#',
                name: 'Sarah Connor',
                location: 'Dublin, Ireland',
                orders: 27,
                amountSpent: '$1,890',
            },
            {
                id: '1523',
                url: '#',
                name: 'Lars Nielsen',
                location: 'Copenhagen, Denmark',
                orders: 31,
                amountSpent: '$2,340',
            },
            {
                id: '1634',
                url: '#',
                name: 'Maria Garcia',
                location: 'Barcelona, Spain',
                orders: 44,
                amountSpent: '$3,450',
            },
            {
                id: '1745',
                url: '#',
                name: 'Alexander Petrov',
                location: 'Moscow, Russia',
                orders: 29,
                amountSpent: '$2,100',
            },
            {
                id: '1856',
                url: '#',
                name: 'Isabella Santos',
                location: 'Lisbon, Portugal',
                orders: 36,
                amountSpent: '$2,780',
            },
            {
                id: '1967',
                url: '#',
                name: 'Hans Weber',
                location: 'Berlin, Germany',
                orders: 41,
                amountSpent: '$3,120',
            },
            {
                id: '2078',
                url: '#',
                name: 'Sophia Chen',
                location: 'Singapore',
                orders: 48,
                amountSpent: '$3,890',
            },
            {
                id: '2189',
                url: '#',
                name: 'Oliver Brown',
                location: 'Sydney, Australia',
                orders: 34,
                amountSpent: '$2,560',
            },
            {
                id: '2290',
                url: '#',
                name: 'Aisha Patel',
                location: 'Mumbai, India',
                orders: 25,
                amountSpent: '$1,670',
            },
            {
                id: '2301',
                url: '#',
                name: 'Lucas Martinez',
                location: 'Mexico City, Mexico',
                orders: 39,
                amountSpent: '$2,890',
            },
            {
                id: '2412',
                url: '#',
                name: 'Eva Andersson',
                location: 'Stockholm, Sweden',
                orders: 45,
                amountSpent: '$3,450',
            },
            {
                id: '2523',
                url: '#',
                name: 'Thomas Wilson',
                location: 'Toronto, Canada',
                orders: 32,
                amountSpent: '$2,340',
            },
            {
                id: '2634',
                url: '#',
                name: 'Nina KovaÄŤ',
                location: 'Zagreb, Croatia',
                orders: 21,
                amountSpent: '$1,230',
            },
            {
                id: '2745',
                url: '#',
                name: 'David Cohen',
                location: 'Tel Aviv, Israel',
                orders: 37,
                amountSpent: '$2,780',
            },
            {
                id: '2856',
                url: '#',
                name: 'Fatima Al-Sayed',
                location: 'Cairo, Egypt',
                orders: 28,
                amountSpent: '$1,890',
            },
            {
                id: '2967',
                url: '#',
                name: 'Jack Williams',
                location: 'Auckland, New Zealand',
                orders: 43,
                amountSpent: '$3,230',
            },
            {
                id: '3078',
                url: '#',
                name: 'Elena Popov',
                location: 'Bucharest, Romania',
                orders: 26,
                amountSpent: '$1,780',
            },
            {
                id: '3189',
                url: '#',
                name: 'Mohammed Al-Hassan',
                location: 'Riyadh, Saudi Arabia',
                orders: 35,
                amountSpent: '$2,670',
            },
            {
                id: '3290',
                url: '#',
                name: 'Julia Berg',
                location: 'Vienna, Austria',
                orders: 49,
                amountSpent: '$3,890',
            },
            {
                id: '3301',
                url: '#',
                name: 'Daniel Kim',
                location: 'Seoul, South Korea',
                orders: 52,
                amountSpent: '$4,120',
            },
        ];
        const resourceName = {
            singular: 'customer',
            plural: 'customers',
        };

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

        const rowMarkup = customers.map(
            ({id, name, location, orders, amountSpent}, index) => (
                <IndexTable.Row
                    id={id}
                    key={id}
                    selected={selectedResources.includes(id)}
                    position={index}
                >
                    <IndexTable.Cell>
                        <Text variant="bodyMd" fontWeight="bold" as="span">
                            {name}
                        </Text>
                    </IndexTable.Cell>
                    <IndexTable.Cell>
                        <Text as="span" variant="bodyMd">
                            {location}
                        </Text>
                    </IndexTable.Cell>
                    <IndexTable.Cell>
                        <Text as="span" variant="bodyMd">
                            {orders}
                        </Text>
                    </IndexTable.Cell>
                    <IndexTable.Cell>
                        <Text as="span" variant="bodyMd">
                            {amountSpent}
                        </Text>
                    </IndexTable.Cell>
                </IndexTable.Row>
            ),
        );
        return (
            <IndexTable
                resourceName={resourceName}
                itemCount={customers.length}
                selectedItemsCount={
                    allResourcesSelected ? 'All' : selectedResources.length
                }
                onSelectionChange={handleSelectionChange}
                headings={[
                    {title: 'Name'},
                    {title: 'Location'},
                    {title: 'Order count'},
                    {title: 'Amount spent'},
                ]}
            >
                {rowMarkup}
            </IndexTable>
        );
    }

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

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

    const tabs = itemStrings.map((item, index) => ({
        content: item,
        index,
        onAction: () => {},
        id: `${item}-${index}`,
        isLocked: index === 0,
        actions:
            index === 0
                ? []
                : [
                    {
                        type: 'rename',
                        onAction: () => {},
                        onPrimaryAction: async (value) => {
                            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 (name) => {
                            await sleep(1);
                            duplicateView(name);
                            return true;
                        },
                    },
                    {
                        type: 'edit',
                    },
                    {
                        type: 'delete',
                        onPrimaryAction: async (id) => {
                            await sleep(1);
                            deleteView(index);
                            return true;
                        },
                    },
                ],
    }));
    const [selected, setSelected] = useState(0);
    const onCreateNewView = async (value) => {
        await sleep(500);
        setItemStrings([...itemStrings, value]);
        setSelected(itemStrings.length);
        return true;
    };
    const 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(
        IndexFiltersMode.Filtering,
    );
    const onHandleCancel = () => {};

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

    const primaryAction =
        selected === 0
            ? {
                type: 'save-as',
                onAction: onCreateNewView,
                disabled: false,
                loading: false,
            }
            : {
                type: 'save',
                onAction: onHandleSave,
                disabled: false,
                loading: false,
            };
    const [accountStatus, setAccountStatus] = useState(null);
    const [moneySpent, setMoneySpent] = useState(null);
    const [taggedWith, setTaggedWith] = useState('');
    const [queryValue, setQueryValue] = useState('');
    const [uncontrolledLoading, setLoading] = useState(false);
    const loading = uncontrolledLoading;

    // Psuedo-loading state transitions
    useEffect(() => {
        if (queryValue !== '') {
            setLoading(true);
        }
        const timeoutId = setTimeout(() => {
            setLoading(false);
        }, 1500);
        return () => clearTimeout(timeoutId);
    }, [queryValue]);

    const handleAccountStatusChange = useCallback(
        (value) => setAccountStatus(value),
        [],
    );
    const handleMoneySpentChange = useCallback(
        (value) => setMoneySpent(value),
        [],
    );
    const handleTaggedWithChange = useCallback(
        (value) => setTaggedWith(value),
        [],
    );
    const handleFiltersQueryChange = useCallback(
        (value) => setQueryValue(value),
        [],
    );
    const handleAccountStatusRemove = useCallback(
        () => setAccountStatus(null),
        [],
    );
    const handleMoneySpentRemove = useCallback(() => setMoneySpent(null), []);
    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 = [];
    if (!isEmpty(accountStatus)) {
        const key = 'accountStatus';
        appliedFilters.push({
            key,
            label: disambiguateLabel(key, accountStatus),
            onRemove: handleAccountStatusRemove,
        });
    }
    if (!isEmpty(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,
        });
    }

    return (
        <Card padding="0">
            <IndexFilters
                loading={loading}
                sortOptions={sortOptions}
                sortSelected={sortSelected}
                queryValue={queryValue}
                queryPlaceholder="Searching in all"
                onQueryChange={handleFiltersQueryChange}
                onQueryClear={() => setQueryValue('')}
                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}
            />
            <Table />
        </Card>
    );

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

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

Perhaps share something visual like a screenshot or video showing the actual bug.

1 Like

You are absolutely right, sorry about that. I just did a screenshot showing the behaviour and I added it to the question.

The Polaris React index filter component has not been updated in a long time. I haven’t seen any other issues around this. If you are able to investigate further and propose a contribution to the repository that would be super helpful.

The demos on the documentation website look right?

The problem happens on mobile and it’s not reproducible on the demo in the documentation because it’s not possible to make the IndexTable scrolling within a mobile context.

It’s easy to reproduce in the Polaris storybook.

Cf. my screenshot below about those reproduction steps

Sadly, I’m not enough competent to investigate this issue myself. Sorry about that

To make the bug identification easier, I made a video watchable here https://youtu.be/UUHIrsxk_JE

The bug demo in the video is made with the code shared in the first message of this thread.

To avoid the bug to be buried, I created Polaris Github repository issue

Thanks Antonio. We are not actively working on the React components but I have let the team know about the issue.

Thank you for your response.

If Shopify is not actively working on React components, could you, please, share with me what road I should follow to migrate away from React an application that has been made with the Shopify Ruby app template 2.5 years ago? (link to template documentation, link to Github reposistory)

Should I migrate to Polaris Web Components when it will be available?
Is there a guide existing?

Please, point me to something as it I’m worried to get stuck in a non maintained stack.