Skip to content
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
1 change: 1 addition & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
123 changes: 123 additions & 0 deletions src/components/ResourceList/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,129 @@ Use persistent shortcut actions in rare cases when the action cannot be made ava
</Card>
```

### 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 = <Avatar customer size="medium" name={name} />;

return (
<ResourceList.Item
id={id}
url={url}
media={media}
sortOrder={index}
accessibilityLabel={`View details for ${name}`}
>
<h3>
<TextStyle variation="strong">{name}</TextStyle>
</h3>
<div>{location}</div>
</ResourceList.Item>
);
};

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 (
<Card>
<ResourceList
resourceName={resourceName}
items={items}
renderItem={this.renderItem}
selectedItems={this.state.selectedItems}
onSelectionChange={this.handleSelectionChange}
promotedBulkActions={promotedBulkActions}
bulkActions={bulkActions}
resolveItemId={resolveItemIds}
/>
</Card>
);
}
}

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.
Expand Down
4 changes: 4 additions & 0 deletions src/components/ResourceList/ResourceList.scss
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,7 @@ $item-wrapper-loading-height: rem(64px);
.DisabledPointerEvents {
@include disabled-pointer-events;
}

.disableTextSelection {
user-select: none;
}
61 changes: 53 additions & 8 deletions src/components/ResourceList/ResourceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type Items = any[];
export interface State {
selectMode: boolean;
loadingPosition: number;
lastSelected: number | null;
}

export interface Props {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -115,6 +118,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
this.state = {
selectMode: Boolean(selectedItems && selectedItems.length > 0),
loadingPosition: 0,
lastSelected: null,
};
}

Expand Down Expand Up @@ -533,6 +537,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
const resourceListClassName = classNames(
styles.ResourceList,
loading && styles.disabledPointerEvents,
selectMode && styles.disableTextSelection,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what was happening without this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll select text when multi-selecting, really bad experience 😬

);

const listMarkup = this.itemsExist() ? (
Expand Down Expand Up @@ -617,32 +622,72 @@ export class ResourceList extends React.Component<CombinedProps, State> {

return (
<li key={id} className={styles.ItemWrapper}>
{renderItem(item, id)}
{renderItem(item, id, index)}
</li>
);
};

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()) {
Expand Down
30 changes: 18 additions & 12 deletions src/components/ResourceList/components/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface BaseProps {
media?: React.ReactElement<AvatarProps | ThumbnailProps>;
persistActions?: boolean;
shortcutActions?: DisableableAction[];
sortOrder?: number;
children?: React.ReactNode;
}

Expand Down Expand Up @@ -158,15 +159,16 @@ export class Item extends React.Component<CombinedProps, State> {
testID="LargerSelectionArea"
>
<div onClick={stopPropagation} className={styles.CheckboxWrapper}>
<Checkbox
testID="Checkbox"
id={this.checkboxId}
label={label}
labelHidden
onChange={this.handleSelection}
checked={selected}
disabled={loading}
/>
<div onChange={this.handleLargerSelectionArea}>
<Checkbox
testID="Checkbox"
id={this.checkboxId}
label={label}
labelHidden
checked={selected}
disabled={loading}
/>
</div>
</div>
</div>
);
Expand Down Expand Up @@ -327,18 +329,22 @@ export class Item extends React.Component<CombinedProps, State> {

private handleLargerSelectionArea = (event: React.MouseEvent<any>) => {
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<any>) => {
Expand Down
19 changes: 15 additions & 4 deletions src/components/ResourceList/components/Item/tests/Item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,21 @@ describe('<Item />', () => {
});

it('calls onSelectionChange with the id of the item when clicking the LargerSelectionArea', () => {
const sortOrder = 0;
const wrapper = mountWithAppProvider(
<Provider value={mockSelectableContext}>
<Item id={itemId} url={url} />
<Item id={itemId} url={url} sortOrder={sortOrder} />
</Provider>,
);

findByTestID(wrapper, 'LargerSelectionArea').simulate('click');
findByTestID(wrapper, 'LargerSelectionArea').simulate('click', {
nativeEvent: {shiftKey: false},
});
expect(mockSelectableContext.onSelectionChange).toHaveBeenCalledWith(
true,
itemId,
sortOrder,
false,
);
});
});
Expand All @@ -299,21 +304,27 @@ describe('<Item />', () => {

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(
<Provider value={mockSelectableContext}>
<Provider value={mockSelectModeContext}>
<Item
id={itemId}
url={url}
onClick={onClick}
sortOrder={sortOrder}
accessibilityLabel={ariaLabel}
/>
</Provider>,
);

findByTestID(wrapper, 'Item-Wrapper').simulate('click');
findByTestID(wrapper, 'Item-Wrapper').simulate('click', {
nativeEvent: {shiftKey: false},
});
expect(mockSelectModeContext.onSelectionChange).toHaveBeenCalledWith(
true,
itemId,
sortOrder,
false,
);
});

Expand Down
Loading