Skip to content

Commit

Permalink
feat(multiselect): display previously selected at the top (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
petermakowski authored Jan 31, 2024
1 parent f0a72a5 commit 67325e0
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 62 deletions.
9 changes: 6 additions & 3 deletions src/lib/components/MultiSelect/MultiSelect.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
@include vf-base;
@include vf-p-lists;

$dropdown-max-height: 20rem;
$dropdown-max-height: 20rem;

.multi-select {
position: relative;
Expand Down Expand Up @@ -46,6 +46,8 @@
position: absolute;
right: 0;
top: calc(100% - #{$input-margin-bottom});
max-height: $dropdown-max-height;
overflow: auto;
}

.multi-select__dropdown--side-by-side {
Expand All @@ -61,13 +63,14 @@
@extend %vf-list;

margin-bottom: $sph--x-small;
max-height: $dropdown-max-height;
overflow: auto;
}

.multi-select__footer {
background: white;
display: flex;
flex-wrap: wrap;
position: sticky;
bottom: 0;
justify-content: space-between;
border-top: 1px solid $color-mid-light;
padding: $sph--small $sph--large 0 $sph--large;
Expand Down
16 changes: 11 additions & 5 deletions src/lib/components/MultiSelect/MultiSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ export default meta;

export const CondensedExample = {
args: {
items: Array.from({ length: 10 }, (_, i) => ({
label: `Item ${i + 1}`,
value: i + 1,
})),
items: [
...Array.from({ length: 26 }, (_, i) => ({
label: `${String.fromCharCode(i + 65)}`,
value: `${String.fromCharCode(i + 65)}`,
})),
...Array.from({ length: 26 }, (_, i) => ({
label: `Item ${i + 1}`,
value: i + 1,
})),
],
selectedItems: [
{ label: "Item 1", value: 1 },
{ label: "A", value: "A" },
{ label: "Item 2", value: 2 },
],
variant: "condensed",
Expand Down
45 changes: 39 additions & 6 deletions src/lib/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,18 @@ it("sorts grouped options alphabetically", async () => {
render(<MultiSelect items={itemsUnsorted} />);
await userEvent.click(screen.getByRole("combobox"));

const checkGroupOrder = (groupName: string, expectedLabels: string[]) => {
const checkGroupOrder = async (
groupName: string,
expectedLabels: string[],
) => {
const group = screen.getByRole("list", { name: groupName });
within(group)
.getAllByRole("listitem")
.forEach((item, index) => {
expect(item).toHaveTextContent(expectedLabels[index]);
});
await waitFor(() =>
within(group)
.getAllByRole("listitem")
.forEach((item, index) =>
expect(item).toHaveTextContent(expectedLabels[index]),
),
);
};

checkGroupOrder("Group 1", ["item A", "item B"]);
Expand All @@ -200,3 +205,31 @@ it("hides group title when no items match the search query", async () => {
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Group 2" })).toBeInTheDocument();
});

it("displays previously selected items at the top of the list in a sorted order", async () => {
const items = [
{ label: "item two", value: 2 },
{ label: "item one", value: 1 },
];
const unSortedSelectedItems = [items[1], items[0]];

render(<MultiSelect items={items} selectedItems={unSortedSelectedItems} />);

await userEvent.click(screen.getByRole("combobox"));

const listItems = screen.getAllByRole("listitem");

await waitFor(() => {
expect(listItems[0]).toHaveTextContent("item one");
expect(listItems[1]).toHaveTextContent("item two");
});
});

it("opens and closes the dropdown on click", async () => {
render(<MultiSelect variant="condensed" items={items} />);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
expect(screen.getByRole("listbox")).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
121 changes: 73 additions & 48 deletions src/lib/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export type MultiSelectProps = {
variant?: "condensed" | "search";
};

type ValueSet = Set<MultiSelectItem["value"]>;
type GroupFn = (
items: Parameters<typeof getGroupedItems>[0],
) => ReturnType<typeof getGroupedItems>;
type SortFn = typeof sortAlphabetically;
type MultiSelectDropdownProps = {
isOpen: boolean;
items: MultiSelectItem[];
Expand All @@ -43,30 +48,39 @@ type MultiSelectDropdownProps = {
header?: ReactNode;
updateItems: (newItems: MultiSelectItem[]) => void;
footer?: ReactNode;
groupFn?: GroupFn;
sortFn?: SortFn;
shouldPinSelectedItems?: boolean;
groupFn?: (
items: Parameters<typeof getGroupedItems>[0],
) => ReturnType<typeof getGroupedItems>;
sortFn?: (
items: Parameters<typeof getSortedItems>[0],
) => ReturnType<typeof getSortedItems>;
} & React.HTMLAttributes<HTMLDivElement>;

const getSortedItems = (items: MultiSelectItem[]) =>
[...items].sort((a, b) =>
a.label.localeCompare(b.label, "en", { numeric: true }),
);
const sortAlphabetically = (a: MultiSelectItem, b: MultiSelectItem) => {
return a.label.localeCompare(b.label, "en", { numeric: true });
};

const getGroupedItems = (items: MultiSelectItem[]) =>
items.reduce(
(groups, item) => {
const group = item.group || "Ungrouped";
groups[group] = groups[group] || [];
groups[group].push(item);
return groups;
},
{} as Record<string, MultiSelectItem[]>,
);
const createSortSelectedItems =
(previouslySelectedItemValues: ValueSet) =>
(a: MultiSelectItem, b: MultiSelectItem) => {
if (previouslySelectedItemValues) {
const aIsPreviouslySelected = previouslySelectedItemValues.has(a.value);
const bIsPreviouslySelected = previouslySelectedItemValues.has(b.value);
if (aIsPreviouslySelected && !bIsPreviouslySelected) return -1;
if (!aIsPreviouslySelected && bIsPreviouslySelected) return 1;
}
return 0;
};

const getGroupedItems = (items: MultiSelectItem[]) => {
const groups = new Map<string, MultiSelectItem[]>();

items.forEach((item) => {
const group = item.group || "Ungrouped";
const groupItems = groups.get(group) || [];
groupItems.push(item);
groups.set(group, groupItems);
});

return Array.from(groups, ([group, items]) => ({ group, items }));
};

export const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({
items,
Expand All @@ -76,17 +90,10 @@ export const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({
updateItems,
isOpen,
footer,
sortFn = getSortedItems,
sortFn = sortAlphabetically,
groupFn = getGroupedItems,
...props
}: MultiSelectDropdownProps) => {
const hasGroup = useMemo(() => items.some((item) => item.group), [items]);
const sortedItems = useMemo(() => sortFn(items), [items, sortFn]);
const groupedItems = useMemo(
() => (hasGroup ? groupFn(sortedItems) : { Ungrouped: sortedItems }),
[items, groupFn],
);

const selectedItemValues = useMemo(
() => new Set(selectedItems.map((item) => item.value)),
[selectedItems],
Expand All @@ -95,39 +102,56 @@ export const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({
() => new Set(disabledItems.map((item) => item.value)),
[disabledItems],
);
const [previouslySelectedItemValues, setPreviouslySelectedItemValues] =
useState<ValueSet>(new Set(selectedItemValues));

useEffect(() => {
if (isOpen) {
setPreviouslySelectedItemValues(new Set(selectedItemValues));
}
}, [isOpen]);

const hasGroup = useMemo(() => items.some((item) => item.group), [items]);
const groupedItems = useMemo(
() => (hasGroup ? groupFn(items) : [{ group: "Ungrouped", items }]),
[items, groupFn],
);
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { checked, value } = event.target;
const foundItem = items.find((item) => `${item.value}` === value);
if (foundItem) {
const newSelectedItems = checked
? [...selectedItems, foundItem]
: selectedItems.filter((item) => `${item.value}` !== value) ?? [];
updateItems(newSelectedItems);
}
};

return (
<FadeInDown isVisible={isOpen} className={"put-above"}>
<div className="multi-select__dropdown" role="listbox" {...props}>
{header ? header : null}
{Object.entries(groupedItems).map(([group, items]) => (
{groupedItems.map(({ group, items }) => (
<div className="multi-select__group" key={group}>
{hasGroup ? (
<h5 className="multi-select__dropdown-header">{group}</h5>
) : null}
<ul className="multi-select__dropdown-list" aria-label={group}>
{items.map((item) => {
const isSelected = selectedItemValues.has(item.value);
const isDisabled = disabledItemValues.has(item.value);
return (
{items
.sort(sortFn)
.sort(createSortSelectedItems(previouslySelectedItemValues))
.map((item) => (
<li key={item.value} className="multi-select__dropdown-item">
<CheckboxInput
disabled={isDisabled}
disabled={disabledItemValues.has(item.value)}
label={item.label}
checked={isSelected}
onChange={() =>
updateItems(
isSelected
? selectedItems.filter(
(i) => i.value !== item.value,
)
: [...selectedItems, item],
)
}
checked={selectedItemValues.has(item.value)}
value={item.value}
onChange={handleOnChange}
key={item.value}
/>
</li>
);
})}
))}
</ul>
</div>
))}
Expand Down Expand Up @@ -225,8 +249,9 @@ export const MultiSelect: React.FC<MultiSelectProps> = ({
aria-controls={dropdownId}
aria-expanded={isDropdownOpen}
className="multi-select__select-button"
onFocus={() => setIsDropdownOpen(true)}
onClick={() => setIsDropdownOpen(true)}
onClick={() => {
setIsDropdownOpen((isOpen) => !isOpen);
}}
>
<span className="multi-select__condensed-text">
{selectedItems.length > 0
Expand Down

0 comments on commit 67325e0

Please sign in to comment.