295 lines
8.1 KiB
TypeScript
295 lines
8.1 KiB
TypeScript
import React, { useState, useContext, useEffect } from "react";
|
|
import cloneDeep from "lodash.clonedeep";
|
|
import Fuse from "fuse.js";
|
|
|
|
import { useKeyboard, useBlur } from "./KeyboardContext";
|
|
|
|
import { getGroupItems, groupsFromIds } from "../helpers/group";
|
|
|
|
import shortcuts from "../shortcuts";
|
|
import { Group, GroupItem } from "../types/Group";
|
|
|
|
export type GroupSelectMode = "single" | "multiple" | "range";
|
|
export type GroupSelectModeChangeEventHandler = (
|
|
selectMode: GroupSelectMode
|
|
) => void;
|
|
export type GroupOpenEventHandler = (groupId: string) => void;
|
|
export type GroupCloseEventHandler = () => void;
|
|
export type GroupsChangeEventHandler = (newGroups: Group[]) => void;
|
|
export type SubgroupsChangeEventHandler = (
|
|
items: GroupItem[],
|
|
groupId: string
|
|
) => void;
|
|
export type GroupSelectEventHandler = (groupId: string) => void;
|
|
export type GroupsSelectEventHandler = (groupIds: string[]) => void;
|
|
export type GroupClearSelectionEventHandler = () => void;
|
|
export type GroupFilterChangeEventHandler = (filter: string) => void;
|
|
export type GroupClearFilterEventHandler = () => void;
|
|
|
|
type GroupContextValue = {
|
|
groups: Group[];
|
|
activeGroups: Group[] | GroupItem[];
|
|
openGroupId: string | undefined;
|
|
openGroupItems: GroupItem[];
|
|
filter: string | undefined;
|
|
filteredGroupItems: GroupItem[];
|
|
selectedGroupIds: string[];
|
|
selectMode: GroupSelectMode;
|
|
onSelectModeChange: GroupSelectModeChangeEventHandler;
|
|
onGroupOpen: GroupOpenEventHandler;
|
|
onGroupClose: GroupCloseEventHandler;
|
|
onGroupsChange: GroupsChangeEventHandler;
|
|
onSubgroupChange: SubgroupsChangeEventHandler;
|
|
onGroupSelect: GroupSelectEventHandler;
|
|
onClearSelection: GroupClearSelectionEventHandler;
|
|
onFilterChange: GroupFilterChangeEventHandler;
|
|
onFilterClear: GroupClearFilterEventHandler;
|
|
};
|
|
|
|
const GroupContext =
|
|
React.createContext<GroupContextValue | undefined>(undefined);
|
|
|
|
type GroupProviderProps = {
|
|
groups: Group[];
|
|
itemNames: Record<string, string>;
|
|
onGroupsChange: GroupsChangeEventHandler;
|
|
onGroupsSelect: GroupsSelectEventHandler;
|
|
disabled: boolean;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
export function GroupProvider({
|
|
groups,
|
|
itemNames,
|
|
onGroupsChange,
|
|
onGroupsSelect,
|
|
disabled,
|
|
children,
|
|
}: GroupProviderProps) {
|
|
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
|
|
const [selectMode, setSelectMode] = useState<GroupSelectMode>("single");
|
|
|
|
/**
|
|
* Group Open
|
|
*/
|
|
const [openGroupId, setOpenGroupId] = useState<string>();
|
|
const [openGroupItems, setOpenGroupItems] = useState<GroupItem[]>([]);
|
|
useEffect(() => {
|
|
if (openGroupId) {
|
|
const openGroups = groupsFromIds([openGroupId], groups);
|
|
if (openGroups.length === 1) {
|
|
const openGroup = openGroups[0];
|
|
setOpenGroupItems(getGroupItems(openGroup));
|
|
} else {
|
|
// Close group if we can't find it
|
|
// This can happen if it was deleted or all it's items were deleted
|
|
setOpenGroupItems([]);
|
|
setOpenGroupId(undefined);
|
|
}
|
|
} else {
|
|
setOpenGroupItems([]);
|
|
}
|
|
}, [openGroupId, groups]);
|
|
|
|
function handleGroupOpen(groupId: string) {
|
|
setSelectedGroupIds([]);
|
|
setOpenGroupId(groupId);
|
|
}
|
|
|
|
function handleGroupClose() {
|
|
setSelectedGroupIds([]);
|
|
setOpenGroupId(undefined);
|
|
}
|
|
|
|
/**
|
|
* Search
|
|
*/
|
|
const [filter, setFilter] = useState<string>();
|
|
const [filteredGroupItems, setFilteredGroupItems] = useState<GroupItem[]>([]);
|
|
const [fuse, setFuse] = useState<Fuse<GroupItem & { name: string }>>();
|
|
// Update search index when items change
|
|
useEffect(() => {
|
|
let items = [];
|
|
for (let group of groups) {
|
|
const itemsToAdd = getGroupItems(group);
|
|
const namedItems = itemsToAdd.map((item) => ({
|
|
...item,
|
|
name: itemNames[item.id],
|
|
}));
|
|
items.push(...namedItems);
|
|
}
|
|
setFuse(new Fuse(items, { keys: ["name"] }));
|
|
}, [groups, itemNames]);
|
|
|
|
// Perform search when search changes
|
|
useEffect(() => {
|
|
if (filter && fuse) {
|
|
const query = fuse.search(filter);
|
|
setFilteredGroupItems(query.map((result) => result.item));
|
|
setOpenGroupId(undefined);
|
|
} else {
|
|
setFilteredGroupItems([]);
|
|
}
|
|
}, [filter, fuse]);
|
|
|
|
/**
|
|
* Handlers
|
|
*/
|
|
|
|
const activeGroups = openGroupId
|
|
? openGroupItems
|
|
: filter
|
|
? filteredGroupItems
|
|
: groups;
|
|
|
|
function handleGroupsChange(newGroups: Group[]) {
|
|
onGroupsChange(newGroups);
|
|
}
|
|
|
|
function handleSubgroupChange(items: GroupItem[], groupId: string) {
|
|
const groupIndex = groups.findIndex((group) => group.id === groupId);
|
|
let updatedGroups = cloneDeep(groups);
|
|
const group = updatedGroups[groupIndex];
|
|
if (group.type === "group") {
|
|
updatedGroups[groupIndex] = {
|
|
...group,
|
|
items,
|
|
};
|
|
onGroupsChange(updatedGroups);
|
|
} else {
|
|
throw new Error(`Group ${group} not a subgroup`);
|
|
}
|
|
}
|
|
|
|
function handleGroupSelect(groupId: string) {
|
|
let groupIds: string[] = [];
|
|
switch (selectMode) {
|
|
case "single":
|
|
groupIds = [groupId];
|
|
break;
|
|
case "multiple":
|
|
if (selectedGroupIds.includes(groupId)) {
|
|
groupIds = selectedGroupIds.filter((id) => id !== groupId);
|
|
} else {
|
|
groupIds = [...selectedGroupIds, groupId];
|
|
}
|
|
break;
|
|
case "range":
|
|
if (selectedGroupIds.length > 0) {
|
|
const currentIndex = activeGroups.findIndex((g) => g.id === groupId);
|
|
const lastIndex = activeGroups.findIndex(
|
|
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
|
|
);
|
|
let idsToAdd: string[] = [];
|
|
let idsToRemove: string[] = [];
|
|
const direction = currentIndex > lastIndex ? 1 : -1;
|
|
for (
|
|
let i = lastIndex + direction;
|
|
direction < 0 ? i >= currentIndex : i <= currentIndex;
|
|
i += direction
|
|
) {
|
|
const id = activeGroups[i].id;
|
|
if (selectedGroupIds.includes(id)) {
|
|
idsToRemove.push(id);
|
|
} else {
|
|
idsToAdd.push(id);
|
|
}
|
|
}
|
|
groupIds = [...selectedGroupIds, ...idsToAdd].filter(
|
|
(id) => !idsToRemove.includes(id)
|
|
);
|
|
} else {
|
|
groupIds = [groupId];
|
|
}
|
|
break;
|
|
default:
|
|
groupIds = [];
|
|
}
|
|
setSelectedGroupIds(groupIds);
|
|
onGroupsSelect(groupIds);
|
|
}
|
|
|
|
function handleClearSelection() {
|
|
setSelectedGroupIds([]);
|
|
onGroupsSelect([]);
|
|
}
|
|
|
|
/**
|
|
* Shortcuts
|
|
*/
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (disabled) {
|
|
return;
|
|
}
|
|
if (shortcuts.selectRange(event)) {
|
|
setSelectMode("range");
|
|
}
|
|
if (shortcuts.selectMultiple(event)) {
|
|
setSelectMode("multiple");
|
|
}
|
|
}
|
|
|
|
function handleKeyUp(event: KeyboardEvent) {
|
|
if (disabled) {
|
|
return;
|
|
}
|
|
if (shortcuts.selectRange(event) && selectMode === "range") {
|
|
setSelectMode("single");
|
|
}
|
|
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
|
|
setSelectMode("single");
|
|
}
|
|
}
|
|
|
|
useKeyboard(handleKeyDown, handleKeyUp);
|
|
|
|
// Set select mode to single when cmd+tabing
|
|
function handleBlur() {
|
|
setSelectMode("single");
|
|
}
|
|
|
|
useBlur(handleBlur);
|
|
|
|
const value = {
|
|
groups,
|
|
activeGroups,
|
|
openGroupId,
|
|
openGroupItems,
|
|
filter,
|
|
filteredGroupItems,
|
|
selectedGroupIds,
|
|
selectMode,
|
|
onSelectModeChange: setSelectMode,
|
|
onGroupOpen: handleGroupOpen,
|
|
onGroupClose: handleGroupClose,
|
|
onGroupsChange: handleGroupsChange,
|
|
onSubgroupChange: handleSubgroupChange,
|
|
onGroupSelect: handleGroupSelect,
|
|
onClearSelection: handleClearSelection,
|
|
onFilterChange: setFilter,
|
|
onFilterClear: () => setFilter(undefined),
|
|
};
|
|
|
|
return (
|
|
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
|
|
);
|
|
}
|
|
|
|
GroupProvider.defaultProps = {
|
|
groups: [],
|
|
itemNames: {},
|
|
onGroupsChange: () => {},
|
|
onGroupsSelect: () => {},
|
|
disabled: false,
|
|
};
|
|
|
|
export function useGroup() {
|
|
const context = useContext(GroupContext);
|
|
if (context === undefined) {
|
|
throw new Error("useGroup must be used within a GroupProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export default GroupContext;
|