diff --git a/package.json b/package.json index 473271fc9..d6d334c66 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "papaparse": "5.5.3", "prism-react-renderer": "2.4.1", "react": "19.1.1", - "react-accessible-treeview": "2.11.0", "react-date-range": "2.0.1", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c319a3243..64ebc9c8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,9 +143,6 @@ importers: react: specifier: 19.1.1 version: 19.1.1 - react-accessible-treeview: - specifier: 2.11.0 - version: 2.11.0(classnames@2.5.1)(prop-types@15.8.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-date-range: specifier: 2.0.1 version: 2.0.1(date-fns@4.1.0)(react@19.1.1) @@ -5468,14 +5465,6 @@ packages: react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-accessible-treeview@2.11.0: - resolution: {integrity: sha512-KgyDQNq64tfy1AOOlxc2qzkDXjQ8dAlPb2jrFlamF2BagkrM3s4qQKg6YaOuAcZLw1wtps4RfFB2sEO3Hh+SvA==} - peerDependencies: - classnames: ^2.2.6 - prop-types: ^15.7.2 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-date-range@2.0.1: resolution: {integrity: sha512-jwKYc9zcjYMg2hWbPht+6BF2wjGG5DkRVNJLRXn2Y0B/QCOOnvQX6YXziZVujVADWmgsBaoQnILdmzYw+Bwh0g==} peerDependencies: @@ -12646,13 +12635,6 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) - react-accessible-treeview@2.11.0(classnames@2.5.1)(prop-types@15.8.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): - dependencies: - classnames: 2.5.1 - prop-types: 15.8.1 - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - react-date-range@2.0.1(date-fns@4.1.0)(react@19.1.1): dependencies: classnames: 2.5.1 diff --git a/src/components/tree/ExpandButton.tsx b/src/components/tree/ExpandButton.tsx new file mode 100644 index 000000000..d36ec8c27 --- /dev/null +++ b/src/components/tree/ExpandButton.tsx @@ -0,0 +1,82 @@ +import { JSX, MouseEventHandler } from 'react'; + +import { IconButton } from '@mui/material'; + +import ItemIcon from '@/ui/icons/ItemIcon'; + +import { PartialItemWithChildren } from './utils'; + +type Props = { + element: PartialItemWithChildren; + level: number; + isExpanded: boolean; + toggleExpand: (id: string) => void; +}; + +export function ExpandButton({ + element, + level, + isExpanded, + toggleExpand, +}: Readonly): JSX.Element { + const handleExpandClick: MouseEventHandler = (e) => { + e.stopPropagation(); + toggleExpand(element.id); + }; + + return ( + <> + {/* icon type for root level items */} + {level === 0 && element.metadata?.type && ( + + )} + {level > 0 && ( + + {/* lucid icons */} + {isExpanded ? ( + + + + ) : ( + + + + )} + + )} + + ); +} diff --git a/src/modules/player/ItemNavigation.tsx b/src/components/tree/ItemNavigation.tsx similarity index 88% rename from src/modules/player/ItemNavigation.tsx rename to src/components/tree/ItemNavigation.tsx index a34b6db09..8281ce8a6 100644 --- a/src/modules/player/ItemNavigation.tsx +++ b/src/components/tree/ItemNavigation.tsx @@ -13,13 +13,13 @@ import { hooks } from '@/config/queryClient'; import { MAIN_MENU_ID, TREE_VIEW_ID } from '@/config/selectors'; import MainMenu from '@/ui/MainMenu/MainMenu'; -import { LoadingTree } from './tree/LoadingTree'; -import { TreeView } from './tree/TreeView'; -import { combineUuids, shuffleAllButLastItemInArray } from './utils/shuffle'; +import { LoadingTree } from './LoadingTree'; +import { TreeView } from './TreeView'; +import { combineUuids, shuffleAllButLastItemInArray } from './shuffle'; const { useItem, useDescendants } = hooks; -const DrawerNavigation = ({ +export const ItemNavigation = ({ rootId, itemId, shuffle = false, @@ -66,7 +66,6 @@ const DrawerNavigation = ({ id={TREE_VIEW_ID} rootItems={[rootItem]} items={[rootItem, ...shuffledDescendants]} - firstLevelStyle={{ fontWeight: 'bold' }} onTreeItemSelect={handleNavigationOnClick} itemId={itemId} allowedTypes={types} @@ -93,5 +92,3 @@ const DrawerNavigation = ({ return null; }; - -export default DrawerNavigation; diff --git a/src/modules/player/tree/LoadingTree.tsx b/src/components/tree/LoadingTree.tsx similarity index 100% rename from src/modules/player/tree/LoadingTree.tsx rename to src/components/tree/LoadingTree.tsx diff --git a/src/modules/player/tree/TreeErrorBoundary.tsx b/src/components/tree/TreeErrorBoundary.tsx similarity index 100% rename from src/modules/player/tree/TreeErrorBoundary.tsx rename to src/components/tree/TreeErrorBoundary.tsx diff --git a/src/components/tree/TreeView.tsx b/src/components/tree/TreeView.tsx new file mode 100644 index 000000000..5349f2d47 --- /dev/null +++ b/src/components/tree/TreeView.tsx @@ -0,0 +1,143 @@ +import { type JSX, useState } from 'react'; + +import { Collapse, List, ListItemButton, Typography } from '@mui/material'; + +import { DiscriminatedItem, ItemType, getIdsFromPath } from '@graasp/sdk'; + +import { ErrorBoundary } from '@sentry/react'; + +import { buildTreeItemClass } from '@/config/selectors'; + +import { ExpandButton } from './ExpandButton'; +import { TreeErrorBoundary } from './TreeErrorBoundary'; +import { PartialItemWithChildren, buildItemsTree } from './utils'; + +export const GRAASP_MENU_ITEMS = [ItemType.FOLDER, ItemType.SHORTCUT]; + +function ListItem({ + expandedIds, + selectedIds, + item, + // bug: to allow animation of collapse, the sidebar should avoid to re-render the whole tree on navigation + toggleExpand, + level = 0, + onClick, +}: Readonly<{ + expandedIds: string[]; + selectedIds: string[]; + item: PartialItemWithChildren; + toggleExpand: (id: string) => void; + level?: number; + onClick?: (id: string) => void; +}>): JSX.Element { + const isExpanded = expandedIds.includes(item.id); + const isSelected = selectedIds.includes(item.id); + + return ( + <> + { + onClick?.(item.id); + if (!isExpanded) { + toggleExpand(item.id); + } + }} + > + {Boolean(item.children?.length) && ( + + )} + {item.name} + + + + {item.children?.map((child) => ( + + ))} + + + + ); +} + +type TreeViewProps = { + id: string; + header?: string; + rootItems: (DiscriminatedItem & { children?: DiscriminatedItem[] })[]; + items?: (DiscriminatedItem & { children?: DiscriminatedItem[] })[]; + onTreeItemSelect?: (value: string) => void; + itemId: string; + /** + * Item whose type is not in the list is filtered out. If the array is empty, no item is filtered. + */ + allowedTypes?: DiscriminatedItem['type'][]; +}; + +export function TreeView({ + id, + header, + items, + rootItems, + onTreeItemSelect, + allowedTypes = [], + itemId, +}: Readonly): JSX.Element { + const itemsToShow = items?.filter((item) => + allowedTypes.length ? allowedTypes.includes(item.type) : true, + ); + const [expandedIds, setExpandedIds] = useState(() => { + const focusedItem = itemsToShow?.find((i) => i.id === itemId); + const defaultExpandedIds = rootItems[0]?.id ? [rootItems[0].id] : []; + return focusedItem ? getIdsFromPath(focusedItem.path) : defaultExpandedIds; + }); + + const itemTree = buildItemsTree(itemsToShow ?? [], rootItems); + const tree = Object.values(itemTree); + + const toggleExpand = (targetId: string) => { + setExpandedIds((prev) => + prev.includes(targetId) + ? prev.filter((expandedId) => expandedId !== targetId) + : [...prev, targetId], + ); + }; + + return ( + }> + {header && ( + + {header} + + )} + + + + + ); +} diff --git a/src/modules/player/utils/shuffle.test.ts b/src/components/tree/shuffle.test.ts similarity index 100% rename from src/modules/player/utils/shuffle.test.ts rename to src/components/tree/shuffle.test.ts diff --git a/src/modules/player/utils/shuffle.ts b/src/components/tree/shuffle.ts similarity index 100% rename from src/modules/player/utils/shuffle.ts rename to src/components/tree/shuffle.ts diff --git a/src/modules/player/tree/utils.ts b/src/components/tree/utils.ts similarity index 98% rename from src/modules/player/tree/utils.ts rename to src/components/tree/utils.ts index 49b409222..53f1e2d56 100644 --- a/src/modules/player/tree/utils.ts +++ b/src/components/tree/utils.ts @@ -30,7 +30,7 @@ export type ItemMetaData = { mimetype?: string; }; -type PartialItemWithChildren = { +export type PartialItemWithChildren = { id: string; name: string; metadata: ItemMetaData; diff --git a/src/modules/player/navigationIsland/PreviousNextButtons.tsx b/src/modules/player/navigationIsland/PreviousNextButtons.tsx index 80ed41796..9cb392cb7 100644 --- a/src/modules/player/navigationIsland/PreviousNextButtons.tsx +++ b/src/modules/player/navigationIsland/PreviousNextButtons.tsx @@ -6,17 +6,16 @@ import { useParams, useSearch } from '@tanstack/react-router'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { useAuth } from '@/AuthContext'; +import { + combineUuids, + shuffleAllButLastItemInArray, +} from '@/components/tree/shuffle'; import { hooks } from '@/config/queryClient'; import { NEXT_ITEM_NAV_BUTTON_ID, PREVIOUS_ITEM_NAV_BUTTON_ID, } from '@/config/selectors'; -import { - combineUuids, - shuffleAllButLastItemInArray, -} from '~player/utils/shuffle'; - import { LoadingButton, NavigationButton } from './customButtons'; function getPrevious( diff --git a/src/modules/player/tree/Node.tsx b/src/modules/player/tree/Node.tsx deleted file mode 100644 index 1d5834b0b..000000000 --- a/src/modules/player/tree/Node.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { JSX } from 'react'; -import type { IBranchProps, INode, LeafProps } from 'react-accessible-treeview'; - -import { Box, IconButton, Typography, colors } from '@mui/material'; - -import { UUID } from '@graasp/sdk'; - -import { buildTreeItemClass } from '@/config/selectors'; -import ItemIcon from '@/ui/icons/ItemIcon'; - -import { ItemMetaData } from './utils'; - -// Props here is passed from TreeView react-accessible-treeview component -export type NodeProps = { - element: INode; - isBranch: boolean; - isExpanded: boolean; - level: number; - isSelected: boolean; - getNodeProps: () => IBranchProps | LeafProps; - onSelect: (id: UUID) => void; - firstLevelStyle?: object; -}; - -export function TreeNode({ - element, - isBranch, - isExpanded, - getNodeProps, - onSelect, - level, - isSelected, - firstLevelStyle = {}, -}: Readonly): JSX.Element { - return ( - - {/* icon type for root level items */} - {level === 1 && element.metadata?.type && ( - - )} - {level !== 1 && isBranch && ( - - {/* lucid icons */} - {isExpanded ? ( - - - - ) : ( - - - - )} - - )} - { - // to prevent folding expanded elements by clicking the name - if (isExpanded) { - e.preventDefault(); - } - onSelect(element.id as UUID); - }} - > - - {element.name} - - - - ); -} diff --git a/src/modules/player/tree/TreeView.tsx b/src/modules/player/tree/TreeView.tsx deleted file mode 100644 index 2fe4559ea..000000000 --- a/src/modules/player/tree/TreeView.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import type { JSX } from 'react'; -import AccessibleTreeView, { - INode, - INodeRendererProps, - flattenTree, -} from 'react-accessible-treeview'; - -import { Box, SxProps, Typography } from '@mui/material'; - -import { - DiscriminatedItem, - ItemType, - UnionOfConst, - getIdsFromPath, -} from '@graasp/sdk'; - -import { ErrorBoundary } from '@sentry/react'; - -import { TreeNode } from './Node'; -import { TreeErrorBoundary } from './TreeErrorBoundary'; -import { ItemMetaData, buildItemsTree } from './utils'; - -export const GRAASP_MENU_ITEMS = [ItemType.FOLDER, ItemType.SHORTCUT]; - -type TreeViewProps = { - id: string; - header?: string; - rootItems: DiscriminatedItem[]; - items?: DiscriminatedItem[]; - onTreeItemSelect?: (value: string) => void; - onlyShowContainerItems?: boolean; - firstLevelStyle?: object; - sx?: SxProps; - itemId: string; - /** - * Item whose type is not in the list is filtered out. If the array is empty, no item is filtered. - */ - allowedTypes?: DiscriminatedItem['type'][]; -}; - -export function TreeView({ - id, - header, - items, - rootItems, - onTreeItemSelect, - allowedTypes = [], - firstLevelStyle, - sx = {}, - itemId, -}: Readonly): JSX.Element { - const itemsToShow = items?.filter((item) => - allowedTypes.length ? allowedTypes.includes(item.type) : true, - ); - const focusedItem = itemsToShow?.find((i) => i.id === itemId); - - // types based on TreeView types - const onSelect = (value: string) => { - onTreeItemSelect?.(value); - }; - - const nodeRenderer = ({ - element, - getNodeProps, - isBranch, - isSelected, - isExpanded, - level, - }: INodeRendererProps) => ( - } - getNodeProps={getNodeProps} - isBranch={isBranch} - isSelected={isSelected} - isExpanded={isExpanded} - level={level} - firstLevelStyle={firstLevelStyle} - onSelect={onSelect} - /> - ); - - const itemTree = buildItemsTree(itemsToShow ?? [], rootItems); - const tree = Object.values(itemTree); - - const defaultExpandedIds = rootItems[0]?.id ? [rootItems[0].id] : []; - - const selectedIds = itemId ? [itemId] : []; - const expandedIds = focusedItem - ? getIdsFromPath(focusedItem.path) - : defaultExpandedIds; - - // need to filter the expandedIds to only include items that are present in the tree - // we should not include parents that are above the current player root - const availableItemIds = itemsToShow?.map(({ id: elemId }) => elemId); - // filter the items to expand to only keep the ones that are present in the tree. - // if there are no items in the tree we short circuit the filtering - const accessibleExpandedItems = availableItemIds?.length - ? expandedIds.filter((e) => availableItemIds?.includes(e)) - : []; - - return ( - }> - - {header && ( - - {header} - - )} - }>({ - // here there should be a root item for all children which basically is gonna be an empty name - name: '', - children: tree, - })} - nodeRenderer={nodeRenderer} - selectedIds={selectedIds} - expandedIds={accessibleExpandedItems} - /> - - - ); -} diff --git a/src/routes/builder/items/$itemId.tsx b/src/routes/builder/items/$itemId.tsx index 28b4c4094..69a906fd7 100644 --- a/src/routes/builder/items/$itemId.tsx +++ b/src/routes/builder/items/$itemId.tsx @@ -19,6 +19,9 @@ import { z } from 'zod'; import { useAuth } from '@/AuthContext'; import { EnrollContent } from '@/components/EnrollContent'; +import { ItemNavigation } from '@/components/tree/ItemNavigation'; +import { LoadingTree } from '@/components/tree/LoadingTree'; +import { GRAASP_MENU_ITEMS } from '@/components/tree/TreeView'; import { ButtonLink } from '@/components/ui/ButtonLink'; import { HeaderRightContent } from '@/components/ui/HeaderRightContent'; import { MainMenuItem } from '@/components/ui/MainMenuItem'; @@ -46,10 +49,7 @@ import { FilterItemsContextProvider } from '~builder/components/context/FilterIt import ModalProviders from '~builder/components/context/ModalProviders'; import { OutletContext } from '~builder/contexts/OutletContext'; import { ItemLayoutMode } from '~builder/enums'; -import ItemNavigation from '~player/ItemNavigation'; import { RequestAccessContent } from '~player/access/RequestAccessContent'; -import { LoadingTree } from '~player/tree/LoadingTree'; -import { GRAASP_MENU_ITEMS } from '~player/tree/TreeView'; const schema = z.object({ chatOpen: z.boolean().optional(), diff --git a/src/routes/player/$rootId/$itemId.tsx b/src/routes/player/$rootId/$itemId.tsx index 00d85820c..52f2d783a 100644 --- a/src/routes/player/$rootId/$itemId.tsx +++ b/src/routes/player/$rootId/$itemId.tsx @@ -9,6 +9,8 @@ import { Outlet, createFileRoute, useNavigate } from '@tanstack/react-router'; import { fallback, zodValidator } from '@tanstack/zod-adapter'; import { z } from 'zod'; +import { ItemNavigation } from '@/components/tree/ItemNavigation'; +import { GRAASP_MENU_ITEMS } from '@/components/tree/TreeView'; import { CustomLink } from '@/components/ui/CustomLink'; import { HeaderRightContent } from '@/components/ui/HeaderRightContent'; import { NS } from '@/config/constants'; @@ -19,9 +21,6 @@ import PlatformSwitch from '@/ui/PlatformSwitch/PlatformSwitch'; import { Platform } from '@/ui/PlatformSwitch/hooks'; import { useMobileView } from '@/ui/hooks/useMobileView'; -import ItemNavigation from '~player/ItemNavigation'; -import { GRAASP_MENU_ITEMS } from '~player/tree/TreeView'; - const playerSchema = z.object({ shuffle: z.boolean().optional(), fullscreen: fallback(z.boolean(), false).default(false),