Skip to content

Commit

Permalink
feat(useSelectionState): add isItemSelectable option (#112)
Browse files Browse the repository at this point in the history
* feat(useselectionstate): add isItemSelectable option

* Add memoization

* Deselect automatically if a selected item becomes unselectable

* Add new example and fix ids

* More memoization
  • Loading branch information
mturley authored Sep 6, 2022
1 parent 65bbdf5 commit 1255de2
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 37 deletions.
36 changes: 28 additions & 8 deletions src/hooks/useSelectionState/useSelectionState.stories.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks';
import { useSelectionState } from './useSelectionState';
import { Checkboxes, ExpandableTable, ExternalState } from './useSelectionState.stories.tsx';
import {
Checkboxes,
ExpandableTable,
ExternalState,
NonSelectableItems,
} from './useSelectionState.stories.tsx';
import GithubLink from '../../../.storybook/helpers/GithubLink';

<Meta title="Hooks/useSelectionState" component={useSelectionState} />
Expand All @@ -23,13 +28,15 @@ interface ISelectionStateArgs<T> {
items: T[];
initialSelected?: T[]; // Defaults to []
isEqual?: (a: T, b: T) => boolean; // Defaults to (a, b) => a === b
isItemSelectable?: (item: T) => boolean; // Defaults to () => true
externalState?: [T[], React.Dispatch<React.SetStateAction<T[]>>];
}

// Return value:
interface ISelectionState<T> {
selectedItems: T[];
isItemSelected: (item: T) => boolean;
isItemSelectable: (item: T) => boolean;
toggleItemSelected: (item: T, isSelecting?: boolean) => void;
selectMultiple: (items: T[], isSelecting: boolean) => void;
areAllSelected: boolean;
Expand All @@ -51,12 +58,6 @@ By default, this uses referential equality (`===`), which works great if you're
However, **if your item objects can change between renders, you need to specify an alternate implementation of the `isEqual` argument.**
For example, if your items have `id` properties, you can use `isEqual: (a, b) => a.id === b.id`.

## Lifting state to an external scope

If necessary, you can pass the optional `externalState` prop in order to manage the actual selections array itself from outside the hook.
This may be useful if you need to manage the state as part of a centralized form, but you still want the logic from these methods.
The type of `externalState` is the same as the array returned from `React.useState` (a tuple of `value` and `setValue`.)

## Examples

### Checkboxes
Expand All @@ -71,10 +72,29 @@ The type of `externalState` is the same as the array returned from `React.useSta
<Story story={ExpandableTable} />
</Canvas>

### Checkboxes with external state
### Lifting state to an external scope

If necessary, you can pass the optional `externalState` prop in order to manage the actual selections array itself from outside the hook.
This may be useful if you need to manage the state as part of a centralized form, but you still want the logic from these methods.
The type of `externalState` is the same as the array returned from `React.useState` (a tuple of `value` and `setValue`.)

<Canvas>
<Story story={ExternalState} />
</Canvas>

### Disabling selection of certain items

You can use the optional `isItemSelectable: (item: T) => boolean` callback to control whether an item's selection should be allowed.
A non-selectable item will not be selected when calling `toggleItemSelected`, `selectMultiple` or `selectAll`,
and it will be ignored when determining the value of `areAllSelected` (if all _selectable_ items are selected, `areAllSelected` will be `true`).

The same `isItemSelectable` callback you pass in will also be included in the return value object so that it can be easily reused
for rendering purposes (for example, disabling checkboxes for items that are not selectable) without having to lift it out.

If the selectability of an item changes while it is selected, it will automatically be deselected.

<Canvas>
<Story story={NonSelectableItems} />
</Canvas>

<GithubLink path="src/hooks/useSelectionState/useSelectionState.ts" />
73 changes: 69 additions & 4 deletions src/hooks/useSelectionState/useSelectionState.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const Checkboxes: React.FunctionComponent = () => {
return (
<div>
<Checkbox
id="select-all"
id="example-1-select-all"
label="Select all"
isChecked={areAllSelected}
onChange={(checked) => selectAll(checked)}
Expand All @@ -33,7 +33,7 @@ export const Checkboxes: React.FunctionComponent = () => {
{fruits.map((fruit) => (
<Checkbox
key={fruit.name}
id={`${fruit.name}-checkbox`}
id={`example-1-${fruit.name}-checkbox`}
label={fruit.name}
isChecked={isItemSelected(fruit)}
onChange={() => toggleItemSelected(fruit)}
Expand Down Expand Up @@ -126,7 +126,7 @@ export const ExternalState: React.FunctionComponent = () => {
return (
<div>
<Checkbox
id="select-all"
id="example-3-select-all"
label="Select all"
isChecked={areAllSelected}
onChange={(checked) => selectAll(checked)}
Expand All @@ -135,7 +135,7 @@ export const ExternalState: React.FunctionComponent = () => {
{fruits.map((fruit) => (
<Checkbox
key={fruit.name}
id={`${fruit.name}-checkbox`}
id={`example-3-${fruit.name}-checkbox`}
label={fruit.name}
isChecked={isItemSelected(fruit)}
onChange={() => toggleItemSelected(fruit)}
Expand All @@ -150,3 +150,68 @@ export const ExternalState: React.FunctionComponent = () => {
</div>
);
};

export const NonSelectableItems: React.FunctionComponent = () => {
interface IFruit {
name: string;
isRound: boolean;
}

const fruits: IFruit[] = [
{ name: 'Apple', isRound: true },
{ name: 'Orange', isRound: true },
{ name: 'Banana', isRound: false },
];

const [nonRoundFruitsAllowed, setNonRoundFruitsAllowed] = React.useState(true);

const {
selectedItems,
isItemSelected,
isItemSelectable,
toggleItemSelected,
areAllSelected,
selectAll,
} = useSelectionState<IFruit>({
items: fruits,
isEqual: (a, b) => a.name === b.name,
isItemSelectable: (item) => item.isRound || nonRoundFruitsAllowed,
});

return (
<div>
<Checkbox
id="allow-non-round"
label="Allow non-round fruits"
isChecked={nonRoundFruitsAllowed}
onChange={setNonRoundFruitsAllowed}
/>
<br />
<hr />
<br />
<Checkbox
id="example-4-select-all"
label="Select all"
isChecked={areAllSelected}
onChange={(checked) => selectAll(checked)}
/>
<br />
{fruits.map((fruit) => (
<Checkbox
key={fruit.name}
id={`example-4-${fruit.name}-checkbox`}
label={fruit.name}
isChecked={isItemSelected(fruit)}
onChange={() => toggleItemSelected(fruit)}
isDisabled={!isItemSelectable(fruit)}
/>
))}
{selectedItems.length > 0 ? (
<>
<br />
<p>Do something with these! {selectedItems.map((fruit) => fruit.name).join(', ')}</p>
</>
) : null}
</div>
);
};
81 changes: 56 additions & 25 deletions src/hooks/useSelectionState/useSelectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ export interface ISelectionStateArgs<T> {
items: T[];
initialSelected?: T[];
isEqual?: (a: T, b: T) => boolean;
isItemSelectable?: (item: T) => boolean;
externalState?: [T[], React.Dispatch<React.SetStateAction<T[]>>];
}

export interface ISelectionState<T> {
selectedItems: T[];
isItemSelected: (item: T) => boolean;
isItemSelectable: (item: T) => boolean;
toggleItemSelected: (item: T, isSelecting?: boolean) => void;
selectMultiple: (items: T[], isSelecting: boolean) => void;
areAllSelected: boolean;
Expand All @@ -21,46 +23,75 @@ export const useSelectionState = <T>({
items,
initialSelected = [],
isEqual = (a, b) => a === b,
isItemSelectable = () => true,
externalState,
}: ISelectionStateArgs<T>): ISelectionState<T> => {
const internalState = React.useState<T[]>(initialSelected);
const [selectedItems, setSelectedItems] = externalState || internalState;

const isItemSelected = (item: T) => selectedItems.some((i) => isEqual(item, i));
const selectableItems = React.useMemo(() => items.filter(isItemSelectable), [
items,
isItemSelectable,
]);

const toggleItemSelected = (item: T, isSelecting = !isItemSelected(item)) => {
if (isSelecting) {
setSelectedItems([...selectedItems, item]);
} else {
setSelectedItems(selectedItems.filter((i) => !isEqual(i, item)));
}
};
const isItemSelected = React.useCallback(
(item: T) => selectedItems.some((i) => isEqual(item, i)),
[isEqual, selectedItems]
);

const selectMultiple = (items: T[], isSelecting: boolean) => {
const otherSelectedItems = selectedItems.filter(
(selected) => !items.some((item) => isEqual(selected, item))
);
if (isSelecting) {
setSelectedItems([...otherSelectedItems, ...items]);
} else {
setSelectedItems(otherSelectedItems);
// If isItemSelectable changes and a selected item is no longer selectable, deselect it
React.useEffect(() => {
if (!selectedItems.every(isItemSelectable)) {
setSelectedItems(selectedItems.filter(isItemSelectable));
}
};
}, [isItemSelectable, selectedItems, setSelectedItems]);

const selectAll = (isSelecting = true) => setSelectedItems(isSelecting ? items : []);
const areAllSelected = selectedItems.length === items.length;
const toggleItemSelected = React.useCallback(
(item: T, isSelecting = !isItemSelected(item)) => {
if (isSelecting && isItemSelectable(item)) {
setSelectedItems([...selectedItems, item]);
} else {
setSelectedItems(selectedItems.filter((i) => !isEqual(i, item)));
}
},
[isEqual, isItemSelectable, isItemSelected, selectedItems, setSelectedItems]
);

const selectMultiple = React.useCallback(
(itemsSubset: T[], isSelecting: boolean) => {
const otherSelectedItems = selectedItems.filter(
(selected) => !itemsSubset.some((item) => isEqual(selected, item))
);
const itemsToSelect = itemsSubset.filter(isItemSelectable);
if (isSelecting) {
setSelectedItems([...otherSelectedItems, ...itemsToSelect]);
} else {
setSelectedItems(otherSelectedItems);
}
},
[isEqual, isItemSelectable, selectedItems, setSelectedItems]
);

const selectAll = React.useCallback(
(isSelecting = true) => setSelectedItems(isSelecting ? selectableItems : []),
[selectableItems, setSelectedItems]
);
const areAllSelected = selectedItems.length === selectableItems.length;

// Preserve original order of items
let selectedItemsInOrder: T[] = [];
if (areAllSelected) {
selectedItemsInOrder = items;
} else if (selectedItems.length > 0) {
selectedItemsInOrder = items.filter(isItemSelected);
}
const selectedItemsInOrder = React.useMemo(() => {
if (areAllSelected) {
return selectableItems;
} else if (selectedItems.length > 0) {
return selectableItems.filter(isItemSelected);
}
return [];
}, [areAllSelected, isItemSelected, selectableItems, selectedItems.length]);

return {
selectedItems: selectedItemsInOrder,
isItemSelected,
isItemSelectable,
toggleItemSelected,
selectMultiple,
areAllSelected,
Expand Down

0 comments on commit 1255de2

Please sign in to comment.