[WEB-1501] dev: multiple select core components (#4667)
* dev: multiple select core components * chore: added export statement
This commit is contained in:
parent
c8c86a33f8
commit
98ebe88c86
10 changed files with 691 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ export * from "./use-label";
|
|||
export * from "./use-member";
|
||||
export * from "./use-mention";
|
||||
export * from "./use-module";
|
||||
export * from "./use-multiple-select-store";
|
||||
|
||||
export * from "./pages/use-project-page";
|
||||
export * from "./pages/use-page";
|
||||
|
|
|
|||
9
web/hooks/store/use-multiple-select-store.ts
Normal file
9
web/hooks/store/use-multiple-select-store.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { useContext } from "react";
|
||||
// store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
|
||||
export const useMultipleSelectStore = () => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useMultipleSelectStore must be used within StoreProvider");
|
||||
return context.multipleSelect;
|
||||
};
|
||||
365
web/hooks/use-multiple-select.ts
Normal file
365
web/hooks/use-multiple-select.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { useMultipleSelectStore } from "@/hooks/store";
|
||||
|
||||
export type TEntityDetails = {
|
||||
entityID: string;
|
||||
groupID: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
containerRef: React.MutableRefObject<HTMLElement | null>;
|
||||
entities: Record<string, string[]>; // { groupID: entityIds[] }
|
||||
};
|
||||
|
||||
export type TSelectionSnapshot = {
|
||||
isSelectionActive: boolean;
|
||||
selectedEntityIds: string[];
|
||||
};
|
||||
|
||||
export type TSelectionHelper = {
|
||||
handleClearSelection: () => void;
|
||||
handleEntityClick: (event: React.MouseEvent, entityID: string, groupId: string) => void;
|
||||
getIsEntitySelected: (entityID: string) => boolean;
|
||||
getIsEntityActive: (entityID: string) => boolean;
|
||||
handleGroupClick: (groupID: string) => void;
|
||||
isGroupSelected: (groupID: string) => "empty" | "partial" | "complete";
|
||||
};
|
||||
|
||||
export const useMultipleSelect = (props: Props) => {
|
||||
const { containerRef, entities } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const {
|
||||
updateSelectedEntityDetails,
|
||||
bulkUpdateSelectedEntityDetails,
|
||||
getActiveEntityDetails,
|
||||
updateActiveEntityDetails,
|
||||
getPreviousActiveEntity,
|
||||
updatePreviousActiveEntity,
|
||||
getNextActiveEntity,
|
||||
updateNextActiveEntity,
|
||||
getLastSelectedEntityDetails,
|
||||
clearSelection,
|
||||
getIsEntitySelected,
|
||||
getIsEntityActive,
|
||||
} = useMultipleSelectStore();
|
||||
|
||||
const groups = useMemo(() => Object.keys(entities), [entities]);
|
||||
|
||||
const entitiesList: TEntityDetails[] = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.map((groupID) =>
|
||||
entities[groupID].map((entityID) => ({
|
||||
entityID,
|
||||
groupID,
|
||||
}))
|
||||
)
|
||||
.flat(1),
|
||||
[entities, groups]
|
||||
);
|
||||
|
||||
const getPreviousAndNextEntities = useCallback(
|
||||
(entityID: string) => {
|
||||
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||||
|
||||
// entity position
|
||||
const isFirstEntity = currentEntityIndex === 0;
|
||||
const isLastEntity = currentEntityIndex === entitiesList.length - 1;
|
||||
|
||||
let previousEntity: TEntityDetails | null = null;
|
||||
let nextEntity: TEntityDetails | null = null;
|
||||
|
||||
if (isLastEntity) {
|
||||
nextEntity = null;
|
||||
} else {
|
||||
nextEntity = entitiesList[currentEntityIndex + 1];
|
||||
}
|
||||
|
||||
if (isFirstEntity) {
|
||||
previousEntity = null;
|
||||
} else {
|
||||
previousEntity = entitiesList[currentEntityIndex - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
previousEntity,
|
||||
nextEntity,
|
||||
};
|
||||
},
|
||||
[entitiesList]
|
||||
);
|
||||
|
||||
const handleActiveEntityChange = useCallback(
|
||||
(entityDetails: TEntityDetails | null, shouldScroll: boolean = true) => {
|
||||
if (!entityDetails) {
|
||||
updateActiveEntityDetails(null);
|
||||
updatePreviousActiveEntity(null);
|
||||
updateNextActiveEntity(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateActiveEntityDetails(entityDetails);
|
||||
|
||||
// scroll to get the active element in view
|
||||
const activeElement = document.querySelector(
|
||||
`[data-entity-id="${entityDetails.entityID}"][data-entity-group-id="${entityDetails.groupID}"]`
|
||||
);
|
||||
if (activeElement && containerRef.current && shouldScroll) {
|
||||
const SCROLL_OFFSET = 200;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const elementRect = activeElement.getBoundingClientRect();
|
||||
|
||||
const isInView =
|
||||
elementRect.top >= containerRect.top + SCROLL_OFFSET &&
|
||||
elementRect.bottom <= containerRect.bottom - SCROLL_OFFSET;
|
||||
|
||||
if (!isInView) {
|
||||
containerRef.current.scrollBy({
|
||||
top: elementRect.top < containerRect.top + SCROLL_OFFSET ? -50 : 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { previousEntity: previousActiveEntity, nextEntity: nextActiveEntity } = getPreviousAndNextEntities(
|
||||
entityDetails.entityID
|
||||
);
|
||||
updatePreviousActiveEntity(previousActiveEntity);
|
||||
updateNextActiveEntity(nextActiveEntity);
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
getPreviousAndNextEntities,
|
||||
updateActiveEntityDetails,
|
||||
updateNextActiveEntity,
|
||||
updatePreviousActiveEntity,
|
||||
]
|
||||
);
|
||||
|
||||
const handleEntitySelection = useCallback(
|
||||
(
|
||||
entityDetails: TEntityDetails | TEntityDetails[],
|
||||
shouldScroll: boolean = true,
|
||||
forceAction: "force-add" | "force-remove" | null = null
|
||||
) => {
|
||||
if (Array.isArray(entityDetails)) {
|
||||
bulkUpdateSelectedEntityDetails(entityDetails, forceAction === "force-add" ? "add" : "remove");
|
||||
if (forceAction === "force-add" && entityDetails.length > 0) {
|
||||
handleActiveEntityChange(entityDetails[entityDetails.length - 1], shouldScroll);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceAction) {
|
||||
if (forceAction === "force-add") {
|
||||
console.log("force adding");
|
||||
updateSelectedEntityDetails(entityDetails, "add");
|
||||
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||
}
|
||||
if (forceAction === "force-remove") {
|
||||
updateSelectedEntityDetails(entityDetails, "remove");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelected = getIsEntitySelected(entityDetails.entityID);
|
||||
if (isSelected) {
|
||||
updateSelectedEntityDetails(entityDetails, "remove");
|
||||
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||
} else {
|
||||
updateSelectedEntityDetails(entityDetails, "add");
|
||||
handleActiveEntityChange(entityDetails, shouldScroll);
|
||||
}
|
||||
},
|
||||
[bulkUpdateSelectedEntityDetails, getIsEntitySelected, handleActiveEntityChange, updateSelectedEntityDetails]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description toggle entity selection
|
||||
* @param {React.MouseEvent} event
|
||||
* @param {string} entityID
|
||||
* @param {string} groupID
|
||||
*/
|
||||
const handleEntityClick = useCallback(
|
||||
(e: React.MouseEvent, entityID: string, groupID: string) => {
|
||||
const lastSelectedEntityDetails = getLastSelectedEntityDetails();
|
||||
if (e.shiftKey && lastSelectedEntityDetails) {
|
||||
const currentEntityIndex = entitiesList.findIndex((entity) => entity?.entityID === entityID);
|
||||
|
||||
const lastEntityIndex = entitiesList.findIndex(
|
||||
(entity) => entity?.entityID === lastSelectedEntityDetails.entityID
|
||||
);
|
||||
if (lastEntityIndex < currentEntityIndex) {
|
||||
for (let i = lastEntityIndex + 1; i <= currentEntityIndex; i++) {
|
||||
const entityDetails = entitiesList[i];
|
||||
if (entityDetails) {
|
||||
handleEntitySelection(entityDetails, false);
|
||||
}
|
||||
}
|
||||
} else if (lastEntityIndex > currentEntityIndex) {
|
||||
for (let i = currentEntityIndex; i <= lastEntityIndex - 1; i++) {
|
||||
const entityDetails = entitiesList[i];
|
||||
if (entityDetails) {
|
||||
handleEntitySelection(entityDetails, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const startIndex = lastEntityIndex + 1;
|
||||
const endIndex = currentEntityIndex;
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const entityDetails = entitiesList[i];
|
||||
if (entityDetails) {
|
||||
handleEntitySelection(entityDetails, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handleEntitySelection({ entityID, groupID }, false);
|
||||
},
|
||||
[entitiesList, handleEntitySelection, getLastSelectedEntityDetails]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description check if any entity of the group is selected
|
||||
* @param {string} groupID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isGroupSelected = useCallback(
|
||||
(groupID: string) => {
|
||||
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||||
const totalSelected = groupEntities.filter((entity) => getIsEntitySelected(entity.entityID ?? "")).length;
|
||||
if (totalSelected === 0) return "empty";
|
||||
if (totalSelected === groupEntities.length) return "complete";
|
||||
return "partial";
|
||||
},
|
||||
[entitiesList, getIsEntitySelected]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description toggle group selection
|
||||
* @param {string} groupID
|
||||
*/
|
||||
const handleGroupClick = useCallback(
|
||||
(groupID: string) => {
|
||||
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||||
const groupSelectionStatus = isGroupSelected(groupID);
|
||||
// groupEntities.map((entity) => {
|
||||
// console.log("group click");
|
||||
// handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||||
// });
|
||||
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||||
},
|
||||
[entitiesList, handleEntitySelection, isGroupSelected]
|
||||
);
|
||||
|
||||
// clear selection on escape key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") clearSelection();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [clearSelection]);
|
||||
|
||||
// select entities on shift + arrow up/down key press
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.shiftKey) return;
|
||||
|
||||
const activeEntityDetails = getActiveEntityDetails();
|
||||
const nextActiveEntity = getNextActiveEntity();
|
||||
const previousActiveEntity = getPreviousActiveEntity();
|
||||
|
||||
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||||
if (!nextActiveEntity) return;
|
||||
handleEntitySelection(nextActiveEntity);
|
||||
}
|
||||
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||||
if (!previousActiveEntity) return;
|
||||
handleEntitySelection(previousActiveEntity);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
getActiveEntityDetails,
|
||||
handleEntitySelection,
|
||||
getLastSelectedEntityDetails,
|
||||
getNextActiveEntity,
|
||||
getPreviousActiveEntity,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey) return;
|
||||
const activeEntityDetails = getActiveEntityDetails();
|
||||
// set active entity id to the first entity
|
||||
if (["ArrowUp", "ArrowDown"].includes(e.key) && !activeEntityDetails) {
|
||||
const firstElementDetails = entitiesList[0];
|
||||
if (!firstElementDetails) return;
|
||||
handleActiveEntityChange(firstElementDetails);
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown" && activeEntityDetails) {
|
||||
if (!activeEntityDetails) return;
|
||||
const { nextEntity: nextActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||||
if (nextActiveEntity) {
|
||||
handleActiveEntityChange(nextActiveEntity);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp" && activeEntityDetails) {
|
||||
if (!activeEntityDetails) return;
|
||||
const { previousEntity: previousActiveEntity } = getPreviousAndNextEntities(activeEntityDetails.entityID);
|
||||
if (previousActiveEntity) {
|
||||
handleActiveEntityChange(previousActiveEntity);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [getActiveEntityDetails, entitiesList, groups, getPreviousAndNextEntities, handleActiveEntityChange]);
|
||||
|
||||
// clear selection on route change
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => clearSelection();
|
||||
|
||||
router.events.on("routeChangeComplete", handleRouteChange);
|
||||
|
||||
return () => {
|
||||
router.events.off("routeChangeComplete", handleRouteChange);
|
||||
};
|
||||
}, [clearSelection, router.events]);
|
||||
|
||||
/**
|
||||
* @description helper functions for selection
|
||||
*/
|
||||
const helpers: TSelectionHelper = useMemo(
|
||||
() => ({
|
||||
handleClearSelection: clearSelection,
|
||||
handleEntityClick,
|
||||
getIsEntitySelected,
|
||||
getIsEntityActive,
|
||||
handleGroupClick,
|
||||
isGroupSelected,
|
||||
}),
|
||||
[clearSelection, getIsEntityActive, getIsEntitySelected, handleEntityClick, handleGroupClick, isGroupSelected]
|
||||
);
|
||||
|
||||
return helpers;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue