From 31ed0df3f77eee42dea27494c9b3b4e8b41166df Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 13 May 2026 23:01:04 +0100 Subject: [PATCH] feat(tree): replace sidebar tree (react-aborist) with custom tree implementation (#2199) * feat(tree): replace react-arborist with custom tree implementation * feat(tree): keyboard arrow navigation between rows * feat(emoji-picker): focus search input on open * refactor(emoji): switch to @slidoapp/emoji-mart fork for accessibility * feat(tree): Home/End and typeahead keyboard navigation * feat(tree): roving tabindex and * to expand sibling subtrees * feat(tree): Space activation and ARIA refinements * fix(tree): move treeitem role to focusable row + aria-current --- apps/client/package.json | 23 +- .../client/src/components/ui/emoji-picker.tsx | 54 +- .../editor/components/emoji-menu/utils.ts | 4 +- .../components/mention/mention-list.tsx | 16 +- .../components/header/page-header-menu.tsx | 6 +- .../src/features/page/queries/page-query.ts | 20 +- .../page/tree/atoms/open-tree-nodes-atom.ts | 5 + .../features/page/tree/atoms/tree-api-atom.ts | 5 - .../doc-tree-drag-preview.module.css | 26 + .../tree/components/doc-tree-drag-preview.tsx | 9 + .../components/doc-tree-drop-indicator.tsx | 39 + .../page/tree/components/doc-tree-row.tsx | 398 ++++++++ .../page/tree/components/doc-tree.tsx | 541 ++++++++++ .../tree/components/space-tree-node-menu.tsx | 259 +++++ .../page/tree/components/space-tree-row.tsx | 288 ++++++ .../page/tree/components/space-tree.tsx | 754 ++------------ .../hooks/drop-op-to-move-payload.test.ts | 100 ++ .../tree/hooks/drop-op-to-move-payload.ts | 36 + .../page/tree/hooks/use-tree-mutation.ts | 437 ++++---- .../page/tree/model/tree-model.test.ts | 329 ++++++ .../features/page/tree/model/tree-model.ts | 222 ++++ .../page/tree/model/tree-model.types.ts | 20 + .../features/page/tree/styles/tree.module.css | 204 ++-- .../atoms/open-shared-tree-nodes-atom.ts | 3 + .../features/share/components/shared-tree.tsx | 269 ++--- .../components/sidebar/space-sidebar.tsx | 6 +- .../src/features/websocket/use-tree-socket.ts | 152 +-- apps/client/vitest.config.ts | 17 + apps/server/src/ws/ws.service.ts | 11 +- package.json | 1 - patches/react-arborist@3.4.0.patch | 33 - pnpm-lock.yaml | 958 +++++++++++++----- 32 files changed, 3816 insertions(+), 1429 deletions(-) create mode 100644 apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts delete mode 100644 apps/client/src/features/page/tree/atoms/tree-api-atom.ts create mode 100644 apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css create mode 100644 apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx create mode 100644 apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx create mode 100644 apps/client/src/features/page/tree/components/doc-tree-row.tsx create mode 100644 apps/client/src/features/page/tree/components/doc-tree.tsx create mode 100644 apps/client/src/features/page/tree/components/space-tree-node-menu.tsx create mode 100644 apps/client/src/features/page/tree/components/space-tree-row.tsx create mode 100644 apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.test.ts create mode 100644 apps/client/src/features/page/tree/hooks/drop-op-to-move-payload.ts create mode 100644 apps/client/src/features/page/tree/model/tree-model.test.ts create mode 100644 apps/client/src/features/page/tree/model/tree-model.ts create mode 100644 apps/client/src/features/page/tree/model/tree-model.types.ts create mode 100644 apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts create mode 100644 apps/client/vitest.config.ts delete mode 100644 patches/react-arborist@3.4.0.patch diff --git a/apps/client/package.json b/apps/client/package.json index f85c008e1..854c9f95d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -7,13 +7,18 @@ "build": "tsc && vite build", "lint": "eslint .", "preview": "vite preview", - "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" + "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.0", + "@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.15", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.4", "@casl/react": "^5.0.1", "@docmost/editor-ext": "workspace:*", - "@emoji-mart/data": "^1.2.1", - "@emoji-mart/react": "^1.1.1", "@excalidraw/excalidraw": "0.18.0-3a5ef40", "@mantine/core": "^8.3.18", "@mantine/dates": "^8.3.18", @@ -22,13 +27,16 @@ "@mantine/modals": "^8.3.18", "@mantine/notifications": "^8.3.18", "@mantine/spotlight": "^8.3.18", + "@slidoapp/emoji-mart": "^5.8.7", + "@slidoapp/emoji-mart-data": "^1.2.4", + "@slidoapp/emoji-mart-react": "^1.1.5", "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "5.90.17", + "@tanstack/react-virtual": "3.13.24", "alfaaz": "^1.1.0", "axios": "1.16.0", "blueimp-load-image": "^5.16.0", "clsx": "^2.1.1", - "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", "highlightjs-sap-abap": "^0.3.0", "i18next": "25.10.1", @@ -44,7 +52,6 @@ "mitt": "^3.0.1", "posthog-js": "1.372.2", "react": "^18.3.1", - "react-arborist": "3.4.0", "react-clear-modal": "^2.0.18", "react-dom": "^18.3.1", "react-drawio": "^1.0.7", @@ -59,6 +66,8 @@ "devDependencies": { "@eslint/js": "^9.28.0", "@tanstack/eslint-plugin-query": "^5.94.4", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", "@types/blueimp-load-image": "^5.16.6", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", @@ -72,6 +81,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^15.13.0", + "jsdom": "^25.0.0", "optics-ts": "^2.4.1", "postcss": "^8.5.12", "postcss-preset-mantine": "^1.18.0", @@ -79,6 +89,7 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", - "vite": "8.0.5" + "vite": "8.0.5", + "vitest": "^4.1.6" } } diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index 804d1b0f4..c360998a3 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useState } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import { ActionIcon, Popover, @@ -7,9 +7,24 @@ import { } from "@mantine/core"; import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks"; import { Suspense } from "react"; -const Picker = React.lazy(() => import("@emoji-mart/react")); import { useTranslation } from "react-i18next"; +// Load the picker module AND the emoji data in parallel inside the lazy +// resolution, then bind the data into the component. React.lazy only finishes +// suspending once both are in memory, so the Suspense boundary hides the +// Remove button until the Picker can render with real content. +const Picker = React.lazy(async () => { + const [pickerModule, dataModule] = await Promise.all([ + import("@slidoapp/emoji-mart-react"), + import("@slidoapp/emoji-mart-data"), + ]); + const PickerComp = pickerModule.default; + const data = dataModule.default; + return { + default: (props: any) => , + }; +}); + export interface EmojiPickerInterface { onEmojiSelect: (emoji: any) => void; icon: ReactNode; @@ -19,6 +34,7 @@ export interface EmojiPickerInterface { size?: string; variant?: string; c?: string; + tabIndex?: number; }; } @@ -50,6 +66,38 @@ function EmojiPicker({ } }); + // emoji-mart's built-in autoFocus calls .focus() without preventScroll, which + // makes the browser scroll every scrollable ancestor of the search input to + // bring it on screen — including the page editor's scroll container, so the + // page jumps to the top whenever the picker is opened from a scrolled-down + // position. The search input lives inside the custom + // element's shadow root, so we poll for it after the dropdown mounts and + // focus it ourselves with preventScroll. + useEffect(() => { + if (!opened || !dropdown) return; + let cancelled = false; + let rafId = 0; + const tryFocus = (attempts: number) => { + if (cancelled) return; + const pickerEl = dropdown.querySelector("em-emoji-picker"); + const input = pickerEl?.shadowRoot?.querySelector( + 'input[type="search"]', + ); + if (input) { + input.focus({ preventScroll: true }); + return; + } + if (attempts < 60) { + rafId = requestAnimationFrame(() => tryFocus(attempts + 1)); + } + }; + rafId = requestAnimationFrame(() => tryFocus(0)); + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + }; + }, [opened, dropdown]); + const handleEmojiSelect = (emoji) => { onEmojiSelect(emoji); handlers.close(); @@ -74,6 +122,7 @@ function EmojiPicker({ c={actionIconProps?.c || "gray"} variant={actionIconProps?.variant || "transparent"} size={actionIconProps?.size} + tabIndex={actionIconProps?.tabIndex} onClick={handlers.toggle} aria-label={t("Pick emoji")} aria-haspopup="dialog" @@ -85,7 +134,6 @@ function EmojiPicker({ (await import("@emoji-mart/data")).default} onEmojiSelect={handleEmojiSelect} perLine={8} skinTonePosition="search" diff --git a/apps/client/src/features/editor/components/emoji-menu/utils.ts b/apps/client/src/features/editor/components/emoji-menu/utils.ts index 8a86ee501..bded7bcd5 100644 --- a/apps/client/src/features/editor/components/emoji-menu/utils.ts +++ b/apps/client/src/features/editor/components/emoji-menu/utils.ts @@ -21,7 +21,7 @@ let _emojiIndex: EmojiIndexEntry[] | null = null; export const buildEmojiIndex = async (): Promise => { if (_emojiIndex) return _emojiIndex; - const { default: data } = await import("@emoji-mart/data"); + const { default: data } = await import('@slidoapp/emoji-mart-data'); _emojiIndex = (Object.values((data as any).emojis) as any[]) .filter((e) => e.id && e.name && e.skins?.[0]?.native) .map((e) => ({ @@ -74,7 +74,7 @@ let _cats: EmojiCategory[] | null = null; export const getEmojiCategories = async (): Promise => { if (_cats) return _cats; const [{ default: data }, index] = await Promise.all([ - import("@emoji-mart/data"), + import("@slidoapp/emoji-mart-data"), buildEmojiIndex(), ]); const byId = new Map(index.map((e) => [e.id, e])); diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 330bded9a..8f6269060 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from "react"; @@ -36,7 +35,7 @@ import { usePageQuery, } from "@/features/page/queries/page-query"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useTranslation } from "react-i18next"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -53,7 +52,6 @@ const MentionList = forwardRef((props, ref) => { const [renderItems, setRenderItems] = useState([]); const { t } = useTranslation(); const [data, setData] = useAtom(treeDataAtom); - const tree = useMemo(() => new SimpleTree(data), [data]); const createPageMutation = useCreatePageMutation(); const emit = useQueryEmit(); const isInCommentContext = props.isInCommentContext ?? false; @@ -220,20 +218,20 @@ const MentionList = forwardRef((props, ref) => { try { createdPage = await createPageMutation.mutateAsync(payload); const parentId = page.id || null; - const data = { + const newNode: SpaceTreeNode = { id: createdPage.id, slugId: createdPage.slugId, name: createdPage.title, position: createdPage.position, spaceId: createdPage.spaceId, parentPageId: createdPage.parentPageId, + hasChildren: false, children: [], - } as any; + }; - const lastIndex = tree.data.length; + const lastIndex = data.length; - tree.create({ parentId, index: lastIndex, data }); - setData(tree.data); + setData(treeModel.insert(data, parentId, newNode, lastIndex)); props.command({ id: uuid7(), @@ -251,7 +249,7 @@ const MentionList = forwardRef((props, ref) => { payload: { parentId, index: lastIndex, - data, + data: newNode, }, }); }, 50); diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 81c25e825..6e481b7aa 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -29,7 +29,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts"; import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; -import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; +import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { Trans, useTranslation } from "react-i18next"; @@ -134,7 +134,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { pageId: extractPageSlugId(pageSlug), }); const { openDeleteModal } = useDeletePageModal(); - const [tree] = useAtom(treeApiAtom); + const { handleDelete } = useTreeMutation(page?.spaceId ?? ""); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ @@ -183,7 +183,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }; const handleDeletePage = () => { - openDeleteModal({ onConfirm: () => tree?.delete(page.id) }); + openDeleteModal({ onConfirm: () => handleDelete(page.id) }); }; const handleToggleFavorite = () => { diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 89526aa69..1ed704ce1 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -37,7 +37,7 @@ import { validate as isValidUuid } from "uuid"; import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; -import { SimpleTree } from "react-arborist"; +import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; @@ -170,11 +170,8 @@ export function useRestorePageMutation() { onSuccess: async (restoredPage) => { notifications.show({ message: "Page restored successfully" }); - // Add the restored page back to the tree - const treeApi = new SimpleTree(treeData); - // Check if the page already exists in the tree (it shouldn't) - if (!treeApi.find(restoredPage.id)) { + if (!treeModel.find(treeData, restoredPage.id)) { // Create the tree node data with hasChildren from backend const nodeData: SpaceTreeNode = { id: restoredPage.id, @@ -193,24 +190,17 @@ export function useRestorePageMutation() { let index = 0; if (parentId) { - const parentNode = treeApi.find(parentId); + const parentNode = treeModel.find(treeData, parentId); if (parentNode) { index = parentNode.children?.length || 0; } } else { // Root level page - index = treeApi.data.length; + index = treeData.length; } // Add the node to the tree - treeApi.create({ - parentId, - index, - data: nodeData, - }); - - // Update the tree data - setTreeData(treeApi.data); + setTreeData(treeModel.insert(treeData, parentId, nodeData, index)); // Emit websocket event to sync with other users setTimeout(() => { diff --git a/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts new file mode 100644 index 000000000..3dd2d98bc --- /dev/null +++ b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +export type OpenMap = Record; + +export const openTreeNodesAtom = atom({}); diff --git a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts b/apps/client/src/features/page/tree/atoms/tree-api-atom.ts deleted file mode 100644 index f12106f99..000000000 --- a/apps/client/src/features/page/tree/atoms/tree-api-atom.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from "jotai"; -import { TreeApi } from "react-arborist"; -import { SpaceTreeNode } from "../types"; - -export const treeApiAtom = atom | null>(null); diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css new file mode 100644 index 000000000..acd0da016 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.module.css @@ -0,0 +1,26 @@ +.preview { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-6) + ); + color: light-dark( + var(--mantine-color-gray-9), + var(--mantine-color-dark-0) + ); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); + border: 1px solid light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx new file mode 100644 index 000000000..f8a8a88b1 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drag-preview.tsx @@ -0,0 +1,9 @@ +import styles from './doc-tree-drag-preview.module.css'; + +type Props = { + label: string; +}; + +export function DocTreeDragPreview({ label }: Props) { + return
{label || 'Untitled'}
; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx new file mode 100644 index 000000000..9d6352ab5 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-drop-indicator.tsx @@ -0,0 +1,39 @@ +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import styles from '../styles/tree.module.css'; + +type Props = { + instruction: Instruction; + indentPx: number; +}; + +export function DocTreeDropIndicator({ instruction, indentPx }: Props) { + const blocked = instruction.type === 'instruction-blocked'; + const inst = blocked ? instruction.desired : instruction; + + const style = { + ['--drop-line-indent' as never]: `${indentPx}px`, + } as React.CSSProperties; + + if (inst.type === 'reorder-above') { + return ( +
+ ); + } + if (inst.type === 'reorder-below') { + return ( +
+ ); + } + // 'combine' (make-child) is rendered via [data-receiving-drop] on the row itself. + return null; +} diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx new file mode 100644 index 000000000..347f1f3e7 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -0,0 +1,398 @@ +import { + memo, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createRoot } from 'react-dom/client'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { + attachInstruction, + extractInstruction, + type Instruction, + type ItemMode, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { triggerPostMoveFlash } from '@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash'; +import * as liveRegion from '@atlaskit/pragmatic-drag-and-drop-live-region'; + +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { treeModel } from '../model/tree-model'; +import { DocTreeDropIndicator } from './doc-tree-drop-indicator'; +import { DocTreeDragPreview } from './doc-tree-drag-preview'; +import type { RenderRowProps } from './doc-tree'; +import styles from '../styles/tree.module.css'; + +type Props = { + node: TreeNode; + level: number; + isLastSibling: boolean; + openIds: ReadonlySet; + selectedId?: string; + // Roving tabindex: the single row that currently carries tabIndex={0}. + activeId?: string; + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel: number; + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + readOnly: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + getDragLabel: (node: TreeNode) => string; + contextId: symbol; + registerRowElement: (id: string, el: HTMLElement | null) => void; + // Stable accessor — calling it returns the latest tree. Avoids passing the + // tree itself as a prop (which would break memo and re-run every row's DnD + // useEffect on every mutation). + getRootData: () => TreeNode[]; +}; + +const DRAG_TYPE = 'doc-tree-item'; +const AUTO_EXPAND_MS = 500; + +function DocTreeRowInner(props: Props) { + const { + node, + level, + isLastSibling, + openIds, + selectedId, + activeId, + renderRow, + indentPerLevel, + onMove, + onToggle, + readOnly, + disableDrag, + disableDrop, + getDragLabel, + contextId, + registerRowElement, + getRootData, + } = props; + + const isOpen = openIds.has(node.id); + // "Has children" includes both already-loaded children AND the consumer's + // own server-side flag (`hasChildren` is a docmost convention on + // SpaceTreeNode / SharedPageTreeNode). The flag lets the chevron and the + // auto-expand timer recognize unloaded subtrees so the consumer's lazy-load + // (via onToggle) can populate them on demand. + const hasLoadedChildren = !!node.children && node.children.length > 0; + const declaredHasChildren = + (node as { hasChildren?: boolean }).hasChildren === true; + const hasChildren = hasLoadedChildren || declaredHasChildren; + const isSelected = selectedId === node.id; + + const rowRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(null); + const autoExpandTimerRef = useRef | null>(null); + + const cancelAutoExpand = useCallback(() => { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } + }, []); + + const toggleOpen = useCallback(() => { + onToggle(node.id, !isOpen); + }, [onToggle, node.id, isOpen]); + + useEffect(() => { + registerRowElement(node.id, rowRef.current); + return () => registerRowElement(node.id, null); + }, [registerRowElement, node.id]); + + // Restore lazy-loaded children when the row mounts open but its children + // aren't loaded (e.g. cross-space page move drops a node into a new tree + // that still has its id in openIds). Calling onToggle(id, true) is + // idempotent for open state and triggers the consumer's lazy-load. + useEffect(() => { + if (isOpen && declaredHasChildren && !hasLoadedChildren) { + onToggle(node.id, true); + } + }, [isOpen, declaredHasChildren, hasLoadedChildren, node.id, onToggle]); + + useEffect(() => { + const el = rowRef.current; + if (!el || readOnly) return; + const dragDisabled = disableDrag?.(node) ?? false; + const dropDisabled = disableDrop?.(node) ?? false; + + const cleanups: Array<() => void> = []; + + if (!dragDisabled) { + cleanups.push( + draggable({ + element: el, + getInitialData: () => ({ + id: node.id, + type: DRAG_TYPE, + uniqueContextId: contextId, + isOpenOnDragStart: isOpen, + }), + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: pointerOutsideOfPreview({ x: '16px', y: '8px' }), + render: ({ container }) => { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, + }); + }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + ); + } + + if (!dropDisabled) { + const mode: ItemMode = + isOpen && hasChildren + ? 'expanded' + : isLastSibling + ? 'last-in-group' + : 'standard'; + // Always block 'reparent' (out of scope per spec). + // Block 'reorder-below' when the row is open with children — ambiguous gesture, + // force users to drop into the folder via 'make-child' instead. + const block: Instruction['type'][] = ['reparent']; + if (isOpen && hasChildren) block.push('reorder-below'); + + cleanups.push( + dropTargetForElements({ + element: el, + canDrop: ({ source }) => + source.data.type === DRAG_TYPE && + source.data.uniqueContextId === contextId && + source.data.id !== node.id && + !treeModel.isDescendant( + getRootData(), + source.data.id as string, + node.id, + ), + getData: ({ input, element }) => + attachInstruction( + { id: node.id, type: DRAG_TYPE }, + { + input, + element, + currentLevel: level, + indentPerLevel, + mode, + block, + }, + ), + onDrag: ({ self }) => { + const inst = extractInstruction(self.data); + setInstruction(inst); + // Auto-expand on hover over any collapsed row that has children, + // regardless of the specific instruction type. Reorder-before and + // reorder-after also benefit: once expanded, the user can see the + // children and refine their drop target. + if ( + inst && + hasChildren && + !isOpen && + !autoExpandTimerRef.current + ) { + autoExpandTimerRef.current = setTimeout(() => { + onToggle(node.id, true); + autoExpandTimerRef.current = null; + }, AUTO_EXPAND_MS); + } + }, + onDragLeave: () => { + setInstruction(null); + cancelAutoExpand(); + }, + onDrop: ({ source, self }) => { + setInstruction(null); + cancelAutoExpand(); + const inst = extractInstruction(self.data); + if (!inst || inst.type === 'instruction-blocked') return; + const sourceId = source.data.id as string; + const op: DropOp = + inst.type === 'reorder-above' + ? { kind: 'reorder-before', targetId: node.id } + : inst.type === 'reorder-below' + ? { kind: 'reorder-after', targetId: node.id } + : inst.type === 'make-child' + ? { kind: 'make-child', targetId: node.id } + : null!; + if (!op) return; + onMove(sourceId, op); + triggerPostMoveFlash(el); + const liveTree = getRootData(); + const parentName = + op.kind === 'make-child' + ? getDragLabel(node) + : (() => { + const sib = treeModel.siblingsOf(liveTree, op.targetId); + const parent = sib?.parentId + ? treeModel.find(liveTree, sib.parentId) + : null; + return parent ? getDragLabel(parent) : 'root'; + })(); + const sourceNode = treeModel.find(liveTree, sourceId); + const sourceLabel = sourceNode + ? getDragLabel(sourceNode) + : 'item'; + liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`); + // After a make-child drop, expand this row so the user sees the + // just-dropped child — especially important when the row had no + // children before (chevron just appeared) so the drop would + // otherwise be invisible. + if (op.kind === 'make-child') onToggle(node.id, true); + if (source.data.isOpenOnDragStart) onToggle(sourceId, true); + }, + }), + ); + } + + return combine(...cleanups); + }, [ + node, + level, + isOpen, + hasChildren, + isLastSibling, + readOnly, + disableDrag, + disableDrop, + contextId, + indentPerLevel, + getDragLabel, + onMove, + onToggle, + getRootData, + cancelAutoExpand, + ]); + + useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]); + + const effectiveInst = + instruction?.type === 'instruction-blocked' + ? instruction.desired + : instruction; + const blocked = instruction?.type === 'instruction-blocked'; + const receivingDrop: 'before' | 'after' | 'make-child' | null = (() => { + if (!effectiveInst) return null; + if (effectiveInst.type === 'reorder-above') return 'before'; + if (effectiveInst.type === 'reorder-below') return 'after'; + if (effectiveInst.type === 'make-child') return 'make-child'; + return null; + })(); + + // Treeitem semantics ride on the row's focusable element (the consumer's + // ). The outer
  • is presentational layout. aria-label uses the row's + // label so the SR's accessible name is just the page title, not the + // concatenation of inner action-button aria-labels. + const treeItemProps = { + role: 'treeitem' as const, + 'aria-level': level + 1, + 'aria-expanded': hasChildren ? isOpen : undefined, + 'aria-selected': isSelected ? (true as const) : undefined, + 'aria-current': isSelected ? ('page' as const) : undefined, + 'aria-label': getDragLabel(node), + 'data-row-id': node.id, + }; + + return ( +
    +
    + {renderRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + isDragging, + isReceivingDrop: receivingDrop, + rowRef, + tabIndex: activeId === node.id ? 0 : -1, + treeItemProps, + toggleOpen, + })} +
    + {instruction && ( + + )} +
    + ); +} + +// Custom memo comparator. The default shallow compare re-renders every row +// when `openIds` (a Set) or `selectedId` (a string) on the parent changes, +// because all rows receive the same reference via {...props} spread. With 1K +// rows that's a perceptible stall on every expand and every navigate. +// +// Resolve openIds / selectedId per-row: only re-render if THIS row's own +// open-state or selected-state actually flipped. Everything else uses +// reference equality (callbacks are useCallback-stable from the parent). +function arePropsEqual( + prev: Props, + next: Props, +): boolean { + if (prev.node !== next.node) return false; + if (prev.level !== next.level) return false; + if (prev.isLastSibling !== next.isLastSibling) return false; + if (prev.readOnly !== next.readOnly) return false; + if (prev.contextId !== next.contextId) return false; + if (prev.indentPerLevel !== next.indentPerLevel) return false; + if (prev.renderRow !== next.renderRow) return false; + if (prev.onMove !== next.onMove) return false; + if (prev.onToggle !== next.onToggle) return false; + if (prev.disableDrag !== next.disableDrag) return false; + if (prev.disableDrop !== next.disableDrop) return false; + if (prev.getDragLabel !== next.getDragLabel) return false; + if (prev.registerRowElement !== next.registerRowElement) return false; + if (prev.getRootData !== next.getRootData) return false; + + const id = next.node.id; + // openIds: only this row's own membership matters. + if (prev.openIds.has(id) !== next.openIds.has(id)) return false; + // selectedId: re-render only the rows whose isSelected actually flipped. + const wasSelected = prev.selectedId === id; + const isSelected = next.selectedId === id; + if (wasSelected !== isSelected) return false; + // activeId: same trick — only the outgoing and incoming active rows + // re-render when the user moves focus through the tree. + const wasActive = prev.activeId === id; + const isActive = next.activeId === id; + if (wasActive !== isActive) return false; + + return true; +} + +export const DocTreeRow = memo( + DocTreeRowInner, + arePropsEqual, +) as typeof DocTreeRowInner; diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx new file mode 100644 index 000000000..3dbbfda16 --- /dev/null +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -0,0 +1,541 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import type { TreeNode, DropOp } from '../model/tree-model.types'; +import { treeModel } from '../model/tree-model'; +import { DocTreeRow } from './doc-tree-row'; +import styles from '../styles/tree.module.css'; + +export type RenderRowProps = { + node: TreeNode; + level: number; + isOpen: boolean; + hasChildren: boolean; + isSelected: boolean; + isDragging: boolean; + isReceivingDrop: 'before' | 'after' | 'make-child' | null; + + rowRef: Ref; + // Roving tabindex: exactly one row in the tree carries tabIndex={0} (the + // active row); every other row gets tabIndex={-1}. Consumers must spread + // this onto the same element they wire rowRef to. + tabIndex: 0 | -1; + // Treeitem semantics for the row's focusable element. Consumers MUST spread + // these onto the same element rowRef points at, so the focused element IS + // the treeitem. This makes screen readers announce "treeitem" (not "link") + // and replaces the descendant-text accname with the row's label, so action + // button labels inside the row don't get concatenated. + treeItemProps: { + role: 'treeitem'; + 'aria-level': number; + 'aria-expanded'?: boolean; + 'aria-selected'?: true; + 'aria-current'?: 'page'; + 'aria-label': string; + 'data-row-id': string; + }; + toggleOpen: () => void; +}; + +export type DocTreeProps = { + data: TreeNode[]; + openIds: ReadonlySet; + selectedId?: string; + + renderRow: (props: RenderRowProps) => ReactNode; + indentPerLevel?: number; + rowHeight?: number; + emptyState?: ReactNode; + + onMove: (sourceId: string, op: DropOp) => void | Promise; + onToggle: (id: string, isOpen: boolean) => void; + onSelect?: (id: string) => void; + + readOnly?: boolean; + disableDrag?: (node: TreeNode) => boolean; + disableDrop?: (node: TreeNode) => boolean; + + getDragLabel: (node: TreeNode) => string; + uniqueContextId?: symbol; + + // Accessible name for the tree itself (e.g. "Pages"). Rendered as + // aria-label on the