diff --git a/UNRELEASED.md b/UNRELEASED.md
index d04098a0f2d..ccf0079c2f7 100644
--- a/UNRELEASED.md
+++ b/UNRELEASED.md
@@ -18,6 +18,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f
- Made the `action` prop optional on `EmptyState` ([#1583](https://github.com/Shopify/polaris-react/pull/1583))
- Prevented Firefox from showing an extra dotted border on focused buttons ([#1409](https://github.com/Shopify/polaris-react/pull/1409))
+- Added `resolveItemId` prop to `ResourceList` which is used in the new multiselect feature ([#1261](https://github.com/Shopify/polaris-react/pull/1261))
### Bug fixes
diff --git a/src/components/ResourceList/README.md b/src/components/ResourceList/README.md
index 1114ff31488..3d27177f716 100644
--- a/src/components/ResourceList/README.md
+++ b/src/components/ResourceList/README.md
@@ -615,6 +615,129 @@ Use persistent shortcut actions in rare cases when the action cannot be made ava
```
+### Resource list with multiselect
+
+Allows merchants to select or deselect multiple items at once.
+
+```jsx
+class ResourceListExample extends React.Component {
+ state = {
+ selectedItems: [],
+ };
+
+ handleSelectionChange = (selectedItems) => {
+ this.setState({selectedItems});
+ };
+
+ renderItem = (item, _, index) => {
+ const {id, url, name, location} = item;
+ const media = ;
+
+ return (
+
+
+ {name}
+
+ {location}
+
+ );
+ };
+
+ render() {
+ const resourceName = {
+ singular: 'customer',
+ plural: 'customers',
+ };
+
+ const items = [
+ {
+ id: 231,
+ url: 'customers/231',
+ name: 'Mae Jemison',
+ location: 'Decatur, USA',
+ },
+ {
+ id: 246,
+ url: 'customers/246',
+ name: 'Ellen Ochoa',
+ location: 'Los Angeles, USA',
+ },
+ {
+ id: 276,
+ url: 'customers/276',
+ name: 'Joe Smith',
+ location: 'Arizona, USA',
+ },
+ {
+ id: 349,
+ url: 'customers/349',
+ name: 'Haden Jerado',
+ location: 'Decatur, USA',
+ },
+ {
+ id: 419,
+ url: 'customers/419',
+ name: 'Tom Thommas',
+ location: 'Florida, USA',
+ },
+ {
+ id: 516,
+ url: 'customers/516',
+ name: 'Emily Amrak',
+ location: 'Texas, USA',
+ },
+ ];
+
+ const promotedBulkActions = [
+ {
+ content: 'Edit customers',
+ onAction: () => console.log('Todo: implement bulk edit'),
+ },
+ ];
+
+ const bulkActions = [
+ {
+ content: 'Add tags',
+ onAction: () => console.log('Todo: implement bulk add tags'),
+ },
+ {
+ content: 'Remove tags',
+ onAction: () => console.log('Todo: implement bulk remove tags'),
+ },
+ {
+ content: 'Delete customers',
+ onAction: () => console.log('Todo: implement bulk delete'),
+ },
+ ];
+
+ return (
+
+
+
+ );
+ }
+}
+
+function resolveItemIds({id}) {
+ return id;
+}
+```
+
### Resource list with all of its elements
Use as a broad example that includes most props available to resource list.
diff --git a/src/components/ResourceList/ResourceList.scss b/src/components/ResourceList/ResourceList.scss
index c42a1c64b9e..a9bba742c4b 100644
--- a/src/components/ResourceList/ResourceList.scss
+++ b/src/components/ResourceList/ResourceList.scss
@@ -236,3 +236,7 @@ $item-wrapper-loading-height: rem(64px);
.DisabledPointerEvents {
@include disabled-pointer-events;
}
+
+.disableTextSelection {
+ user-select: none;
+}
diff --git a/src/components/ResourceList/ResourceList.tsx b/src/components/ResourceList/ResourceList.tsx
index bd84057313b..952edbcfb0e 100644
--- a/src/components/ResourceList/ResourceList.tsx
+++ b/src/components/ResourceList/ResourceList.tsx
@@ -35,6 +35,7 @@ export type Items = any[];
export interface State {
selectMode: boolean;
loadingPosition: number;
+ lastSelected: number | null;
}
export interface Props {
@@ -69,9 +70,11 @@ export interface Props {
/** Callback when selection is changed */
onSelectionChange?(selectedItems: SelectedItems): void;
/** Function to render each list item */
- renderItem(item: any, id: string): React.ReactNode;
+ renderItem(item: any, id: string, index: number): React.ReactNode;
/** Function to customize the unique ID for each item */
idForItem?(item: any, index: number): string;
+ /** Function to resolve an id from a item */
+ resolveItemId?(item: any): string;
}
export type CombinedProps = Props & WithAppProviderProps;
@@ -115,6 +118,7 @@ export class ResourceList extends React.Component {
this.state = {
selectMode: Boolean(selectedItems && selectedItems.length > 0),
loadingPosition: 0,
+ lastSelected: null,
};
}
@@ -533,6 +537,7 @@ export class ResourceList extends React.Component {
const resourceListClassName = classNames(
styles.ResourceList,
loading && styles.disabledPointerEvents,
+ selectMode && styles.disableTextSelection,
);
const listMarkup = this.itemsExist() ? (
@@ -617,32 +622,72 @@ export class ResourceList extends React.Component {
return (
- {renderItem(item, id)}
+ {renderItem(item, id, index)}
);
};
- private handleSelectionChange = (selected: boolean, id: string) => {
+ private handleMultiSelectionChange = (
+ lastSelected: number,
+ currentSelected: number,
+ resolveItemId: (item: any) => string,
+ ) => {
+ const min = Math.min(lastSelected, currentSelected);
+ const max = Math.max(lastSelected, currentSelected);
+ return this.props.items.slice(min, max + 1).map(resolveItemId);
+ };
+
+ private handleSelectionChange = (
+ selected: boolean,
+ id: string,
+ sortOrder: number | undefined,
+ shiftKey: boolean,
+ ) => {
const {
onSelectionChange,
selectedItems,
items,
idForItem = defaultIdForItem,
+ resolveItemId,
} = this.props;
+ const {lastSelected} = this.state;
if (selectedItems == null || onSelectionChange == null) {
return;
}
- const newlySelectedItems =
+ let newlySelectedItems =
selectedItems === SELECT_ALL_ITEMS
? getAllItemsOnPage(items, idForItem)
: [...selectedItems];
- if (selected) {
- newlySelectedItems.push(id);
- } else {
- newlySelectedItems.splice(newlySelectedItems.indexOf(id), 1);
+ if (sortOrder !== undefined) {
+ this.setState({lastSelected: sortOrder});
+ }
+
+ let selectedIds: string[] = [id];
+
+ if (
+ shiftKey &&
+ lastSelected != null &&
+ sortOrder !== undefined &&
+ resolveItemId
+ ) {
+ selectedIds = this.handleMultiSelectionChange(
+ lastSelected,
+ sortOrder,
+ resolveItemId,
+ );
+ }
+ newlySelectedItems = [...new Set([...newlySelectedItems, ...selectedIds])];
+
+ if (!selected) {
+ for (let i = 0; i < selectedIds.length; i++) {
+ newlySelectedItems.splice(
+ newlySelectedItems.indexOf(selectedIds[i]),
+ 1,
+ );
+ }
}
if (newlySelectedItems.length === 0 && !isSmallScreen()) {
diff --git a/src/components/ResourceList/components/Item/Item.tsx b/src/components/ResourceList/components/Item/Item.tsx
index 6f839ca922e..bdcdfc865aa 100644
--- a/src/components/ResourceList/components/Item/Item.tsx
+++ b/src/components/ResourceList/components/Item/Item.tsx
@@ -40,6 +40,7 @@ export interface BaseProps {
media?: React.ReactElement;
persistActions?: boolean;
shortcutActions?: DisableableAction[];
+ sortOrder?: number;
children?: React.ReactNode;
}
@@ -158,15 +159,16 @@ export class Item extends React.Component {
testID="LargerSelectionArea"
>
);
@@ -327,18 +329,22 @@ export class Item extends React.Component {
private handleLargerSelectionArea = (event: React.MouseEvent) => {
stopPropagation(event);
- this.handleSelection(!this.state.selected);
+ this.handleSelection(!this.state.selected, event.nativeEvent.shiftKey);
};
- private handleSelection = (value: boolean) => {
+ private handleSelection = (value: boolean, shiftKey: boolean) => {
const {
id,
+ sortOrder,
context: {onSelectionChange},
} = this.props;
+
if (id == null || onSelectionChange == null) {
return;
}
- onSelectionChange(value, id);
+
+ this.setState({focused: true, focusedInner: true});
+ onSelectionChange(value, id, sortOrder, shiftKey);
};
private handleClick = (event: React.MouseEvent) => {
diff --git a/src/components/ResourceList/components/Item/tests/Item.test.tsx b/src/components/ResourceList/components/Item/tests/Item.test.tsx
index c4a8b573ced..d4ccf67a5d9 100644
--- a/src/components/ResourceList/components/Item/tests/Item.test.tsx
+++ b/src/components/ResourceList/components/Item/tests/Item.test.tsx
@@ -270,16 +270,21 @@ describe(' ', () => {
});
it('calls onSelectionChange with the id of the item when clicking the LargerSelectionArea', () => {
+ const sortOrder = 0;
const wrapper = mountWithAppProvider(
-
+
,
);
- findByTestID(wrapper, 'LargerSelectionArea').simulate('click');
+ findByTestID(wrapper, 'LargerSelectionArea').simulate('click', {
+ nativeEvent: {shiftKey: false},
+ });
expect(mockSelectableContext.onSelectionChange).toHaveBeenCalledWith(
true,
itemId,
+ sortOrder,
+ false,
);
});
});
@@ -299,21 +304,27 @@ describe(' ', () => {
it('calls onSelectionChange with the id of the item even if url or onClick is present', () => {
const onClick = jest.fn();
+ const sortOrder = 0;
const wrapper = mountWithAppProvider(
-
+
,
);
- findByTestID(wrapper, 'Item-Wrapper').simulate('click');
+ findByTestID(wrapper, 'Item-Wrapper').simulate('click', {
+ nativeEvent: {shiftKey: false},
+ });
expect(mockSelectModeContext.onSelectionChange).toHaveBeenCalledWith(
true,
itemId,
+ sortOrder,
+ false,
);
});
diff --git a/src/components/ResourceList/tests/ResourceList.test.tsx b/src/components/ResourceList/tests/ResourceList.test.tsx
index cef54d22c96..be1f2bc2c1e 100644
--- a/src/components/ResourceList/tests/ResourceList.test.tsx
+++ b/src/components/ResourceList/tests/ResourceList.test.tsx
@@ -722,6 +722,124 @@ describe('', () => {
expect(resourceList.find(BulkActions).prop('selectMode')).toBe(false);
});
});
+
+ describe('multiselect', () => {
+ it('selects shift selected items if resolveItemId was provided', () => {
+ function resolveItemId(item: any) {
+ return item.id;
+ }
+ const onSelectionChange = jest.fn();
+ const resourceList = mountWithAppProvider(
+ ,
+ );
+ const firstItem = resourceList.find(Item).first();
+ findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
+
+ const lastItem = resourceList.find(Item).last();
+ findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
+ nativeEvent: {shiftKey: true},
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['5', '6', '7']);
+ });
+
+ it('does not select shift selected items if resolveItemId was not provided', () => {
+ const onSelectionChange = jest.fn();
+ const resourceList = mountWithAppProvider(
+ ,
+ );
+ const firstItem = resourceList.find(Item).first();
+ findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
+
+ const lastItem = resourceList.find(Item).last();
+ findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
+ nativeEvent: {shiftKey: true},
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['7']);
+ });
+
+ it('does not select shift selected items if sortOrder is not provided', () => {
+ function resolveItemId(item: any) {
+ return item.id;
+ }
+
+ function renderItem(item: any, id: any) {
+ return (
+
+ Item {id}
+ {item.title}
+
+ );
+ }
+
+ const onSelectionChange = jest.fn();
+ const resourceList = mountWithAppProvider(
+ ,
+ );
+ const firstItem = resourceList.find(Item).first();
+ findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
+
+ const lastItem = resourceList.find(Item).last();
+ findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
+ nativeEvent: {shiftKey: true},
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['7']);
+ });
+
+ it('deselects shift selected items if resolveItemId was provided', () => {
+ const selectedItems = ['6', '7'];
+ function resolveItemId(item: any) {
+ return item.id;
+ }
+ const onSelectionChange = jest.fn();
+ const resourceList = mountWithAppProvider(
+ ,
+ );
+ // Sets {lastSeleced: 0}
+ const firstItem = resourceList.find(Item).first();
+ findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
+
+ const lastItem = resourceList.find(Item).last();
+ findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
+ nativeEvent: {shiftKey: true},
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith([]);
+ });
+ });
});
function idForItem(item: any) {
@@ -736,11 +854,12 @@ function renderCustomMarkup(item: any) {
return {item.title}
;
}
-function renderItem(item: any, id: any) {
+function renderItem(item: any, id: any, index: number) {
return (
Item {id}
diff --git a/src/components/ResourceList/types.ts b/src/components/ResourceList/types.ts
index a184f0ad902..18f29368491 100644
--- a/src/components/ResourceList/types.ts
+++ b/src/components/ResourceList/types.ts
@@ -11,5 +11,10 @@ export interface ResourceListContext {
plural: string;
};
loading?: boolean;
- onSelectionChange?(selected: boolean, id: string): void;
+ onSelectionChange?(
+ selected: boolean,
+ id: string,
+ sortNumber: number | undefined,
+ shiftKey: boolean,
+ ): void;
}