Room list: drag and drop rooms into sections (#33366)

* chore: add dnd kit deps

* chore: patch dnd kit to fix ts error

* feat(sc): add drag-and-drop to room list item and wrapper

* feat(sc): make the room list header a droppable element

* feat(sc): add dnd to room list view

* feat(tags): can tag room as CHAT

* feat(vm): implement `changeRoomSection`

* feat(sc): disable dragging in flat list

* fix: disable keyboard navigation when dragging element

* test(sc): update snapshots

* test(sc): add dnd test

* test(e2e): add e2e tests for room drag and drop

* test(vm): add tests for changeRoomSection

* fix: remove focus visible when dropping with the mouse

* test(playwright): update existing screenshots

* chore(sc): move numbers out of main build

The Ew RecorderWorklet imports shared component bundle. However if the
bundle uses some deps using document/window which, the worklet will not
work.

The solution is to put the used functions into a separate bundle.

* doc(sc): add subpath import into README

* doc: typo barrel/bundle

* test: improve test expect

* refactor: add utils to section tag

* fix: incorrect check in tagRoom

* fix: add doc about dndkit tunning
This commit is contained in:
Florian Duros
2026-05-13 09:06:22 +00:00
committed by GitHub
parent 97da3be67a
commit 85aca65a81
50 changed files with 4845 additions and 3871 deletions
@@ -81,6 +81,13 @@ export interface VirtualizedListProps<Item, Context> extends Omit<
*/
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
/**
* When true, keyboard navigation (Arrow keys, Home, End, Page Up/Down) is disabled.
* All key events are forwarded directly to `onKeyDown` instead.
* Use this to prevent the list from scrolling while an item is being dragged via keyboard.
*/
disableKeyboardNavigation?: boolean;
/**
* Optional total count of items (for virtualization with partial data loading).
* If provided, this will be used instead of items.length for the total count.
@@ -164,6 +171,7 @@ export function useVirtualizedList<Item, Context>(
getItemKey,
context,
onKeyDown,
disableKeyboardNavigation,
totalCount,
rangeChanged,
mapScrollIndex,
@@ -260,6 +268,13 @@ export function useVirtualizedList<Item, Context>(
return;
}
// When keyboard navigation is disabled (e.g. during a keyboard drag),
// forward all events to the parent handler without handling navigation.
if (disableKeyboardNavigation) {
onKeyDown?.(e);
return;
}
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
@@ -300,7 +315,16 @@ export function useVirtualizedList<Item, Context>(
onKeyDown?.(e);
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
[
scrollToIndex,
scrollToItem,
tabIndexKey,
keyToIndexMap,
visibleRange,
items,
onKeyDown,
disableKeyboardNavigation,
],
);
/**
@@ -40,6 +40,7 @@ const RoomListViewWrapperImpl = ({
updateVisibleRooms,
renderAvatar: renderAvatarProp,
closeToast,
changeRoomSection,
...rest
}: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -50,6 +51,7 @@ const RoomListViewWrapperImpl = ({
getSectionHeaderViewModel,
updateVisibleRooms,
closeToast,
changeRoomSection,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
};
@@ -102,6 +104,7 @@ const meta = {
isFlatList: true,
toast: undefined,
closeToast: fn(),
changeRoomSection: fn(),
},
parameters: {
design: {
@@ -74,6 +74,8 @@ export interface RoomListViewActions {
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
/** Called to close the toast message */
closeToast: () => void;
/** Called to change the section of a room */
changeRoomSection: (roomId: string, tag: string) => void;
}
/**
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.dragOverlay {
--padding-top: 0px;
--padding-bottom: 0px;
}
@@ -0,0 +1,81 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type Room,
type RoomListItemViewActions,
type RoomListItemViewSnapshot,
} from "../RoomListItemWrapper/RoomListItemView";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
import { useMockedViewModel } from "../../../core/viewmodel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot";
import { mockedActions } from "../RoomListItemWrapper/RoomListItemView/mocked-actions";
import { renderAvatar } from "../../story-mocks";
type RoomListItemDragOverlayProps = RoomListItemViewSnapshot &
RoomListItemViewActions & {
renderAvatar: (room: Room) => React.ReactElement;
};
const RoomListItemDragOverlayWrapperImpl = ({
onOpenRoom,
onMarkAsRead,
onMarkAsUnread,
onToggleFavorite,
onToggleLowPriority,
onInvite,
onCopyRoomLink,
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
renderAvatar: renderAvatarProp,
...rest
}: RoomListItemDragOverlayProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onOpenRoom,
onMarkAsRead,
onMarkAsUnread,
onToggleFavorite,
onToggleLowPriority,
onInvite,
onCopyRoomLink,
onLeaveRoom,
onSetRoomNotifState,
onCreateSection,
onToggleSection,
});
return <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
};
const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView);
const meta = {
title: "Room List/RoomListItemDragOverlayView",
component: RoomListItemDragOverlayWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<Story />
</div>
),
],
args: {
...defaultSnapshot,
...mockedActions,
renderAvatar,
},
} satisfies Meta<typeof RoomListItemDragOverlayWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListItemDragOverlayView.stories";
import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot";
const { Default } = composeStories(stories);
describe("<RoomListItemDragOverlayView />", () => {
it("renders the room name from the view model", () => {
render(<Default />);
expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name);
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, type ReactNode } from "react";
import classNames from "classnames";
import { Flex } from "../../../core/utils/Flex";
import { type Room, RoomListItemContent, type RoomListItemViewModel } from "../RoomListItemWrapper/RoomListItemView";
import roomListItemStyles from "../RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css";
import styles from "./RoomListItemDragOverlayView.module.css";
/**
* Props for {@link RoomListItemDragOverlayView}.
*/
export interface RoomListItemDragOverlayViewProps {
/** The room item view model — same one used by the real list item */
vm: RoomListItemViewModel;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
}
/**
* Visual clone of a room list item rendered inside the dnd drag overlay.
*
* Reuses {@link RoomListItemContent} for the inner layout and adds the outer
* wrapper styles that the live list item normally provides (height, width,
* typography), so the floating clone matches a real item.
*/
export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlayView({
vm,
renderAvatar,
}: RoomListItemDragOverlayViewProps): JSX.Element {
return (
<Flex
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
gap="var(--cpd-space-3x)"
align="stretch"
>
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} isDragging={true} />
</Flex>
);
});
@@ -0,0 +1,9 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
export type { RoomListItemDragOverlayViewProps } from "./RoomListItemDragOverlayView";
@@ -0,0 +1,79 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, type ReactNode } from "react";
import { Text } from "@vector-im/compound-web";
import classNames from "classnames";
import { Flex } from "../../../../core/utils/Flex";
import { useViewModel } from "../../../../core/viewmodel";
import { NotificationDecoration } from "./NotificationDecoration";
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
import { type Room, type RoomListItemViewModel } from "./RoomListItemView";
import styles from "./RoomListItemView.module.css";
/**
* Props for {@link RoomListItemContent}.
*/
export interface RoomListItemContentProps {
/** The room item view model */
vm: RoomListItemViewModel;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
/** Whether the item is being dragged */
isDragging?: boolean;
}
/**
* The inner content of a room list item: avatar, room name, message preview,
* hover menu and notification decoration. Used both inside the full
* {@link RoomListItemView} and inside the drag overlay.
*/
export const RoomListItemContent = memo(function RoomListItemContent({
vm,
renderAvatar,
isDragging = false,
}: RoomListItemContentProps): JSX.Element {
const item = useViewModel(vm);
return (
<Flex
className={classNames(styles.container, {
[styles.dragging]: isDragging,
})}
gap="var(--cpd-space-3x)"
align="center"
>
{renderAvatar(item.room)}
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.ellipsis}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
</div>
{item.messagePreview && (
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
{item.messagePreview}
</Text>
)}
</div>
{!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && (
<RoomListItemHoverMenu
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
vm={vm}
/>
)}
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...item.notification} />
</div>
</Flex>
</Flex>
);
});
@@ -70,6 +70,11 @@
min-width: 0;
}
.dragging {
outline: 1px solid var(--cpd-color-border-interactive-hovered);
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 90%, transparent);
}
.content {
flex: 1;
min-width: 0;
@@ -5,14 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, memo, useEffect, useRef, type ReactNode } from "react";
import React, { type JSX, memo, useEffect, useRef, type ReactNode, type Ref } from "react";
import classNames from "classnames";
import { Text } from "@vector-im/compound-web";
import { useMergeRefs } from "react-merge-refs";
import { Flex } from "../../../../core/utils/Flex";
import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
import { type NotificationDecorationData } from "./NotificationDecoration";
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
import { RoomListItemContent } from "./RoomListItemContent";
import { type RoomNotifState } from "./RoomNotifs";
import styles from "./RoomListItemView.module.css";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
@@ -150,6 +150,7 @@ export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLBut
isLastItem: boolean;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
ref?: Ref<Element>;
}
/**
@@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({
isFirstItem,
isLastItem,
renderAvatar,
ref,
...props
}: RoomListItemViewProps): JSX.Element {
const ref = useRef<HTMLButtonElement>(null);
const internalRef = useRef<HTMLButtonElement>(null);
const mergedRef = useMergeRefs([ref, internalRef]);
const item = useViewModel(vm);
useEffect(() => {
if (isFocused) {
ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions);
internalRef.current?.focus({ preventScroll: true } as FocusOptions);
}
}, [isFocused]);
@@ -182,7 +185,7 @@ export const RoomListItemView = memo(function RoomListItemView({
<RoomListItemContextMenu vm={vm}>
<Flex
as="button"
ref={ref}
ref={mergedRef}
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
[styles.selected]: isSelected,
[styles.bold]: item.isBold,
@@ -193,41 +196,14 @@ export const RoomListItemView = memo(function RoomListItemView({
gap="var(--cpd-space-3x)"
align="stretch"
type="button"
aria-selected={isSelected}
aria-label={a11yLabel}
onClick={vm.onOpenRoom}
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
tabIndex={isFocused ? 0 : -1}
aria-selected={props.role === "option" ? isSelected : undefined}
{...props}
>
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
{renderAvatar(item.room)}
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<div className={styles.ellipsis}>
<div className={styles.roomName} title={item.name} data-testid="room-name">
{item.name}
</div>
{item.messagePreview && (
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
{item.messagePreview}
</Text>
)}
</div>
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
<RoomListItemHoverMenu
showMoreOptionsMenu={item.showMoreOptionsMenu}
showNotificationMenu={item.showNotificationMenu}
vm={vm}
/>
)}
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...item.notification} />
</div>
</Flex>
</Flex>
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} />
</Flex>
</RoomListItemContextMenu>
);
@@ -14,6 +14,8 @@ export type {
RoomListItemViewProps,
Section,
} from "./RoomListItemView";
export { RoomListItemContent } from "./RoomListItemContent";
export type { RoomListItemContentProps } from "./RoomListItemContent";
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
@@ -6,9 +6,14 @@
*/
import React, { memo, type JSX } from "react";
import { useDraggable } from "@dnd-kit/react";
import { Feedback } from "@dnd-kit/dom";
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
import { useMergeRefs } from "react-merge-refs";
import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
import { useViewModel } from "../../../core/viewmodel";
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
/** Index of this room in the list */
@@ -22,19 +27,8 @@ export interface RoomListItemWrapperProps extends RoomListItemViewProps {
}
/**
* Wrapper around RoomListItemView that adds accessibility props based on the room's position in the list and whether the list is flat or grouped.
* In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props.
*
* @example
* ``
* <RoomListItemWrapper
* roomIndex={0}
* roomIndexInSection={0}
* roomCount={10}
* isInFlatList={true}
* {...otherRoomListItemViewProps}
* />
* ```
* Wraps RoomListItemView with the correct accessibility and drag-and-drop props
* based on whether the list is flat (listbox) or grouped (treegrid).
*/
export const RoomListItemWrapper = memo(function RoomListItemWrapper({
roomIndex,
@@ -43,9 +37,30 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({
isInFlatList,
...rest
}: RoomListItemWrapperProps): JSX.Element {
const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" };
const item = <RoomListItemView {...rest} {...itemA11yProps} />;
if (isInFlatList) {
return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />;
}
if (isInFlatList) return item;
return <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>{item}</div>;
return (
<div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>
<div role="gridcell" aria-selected={rest.isSelected}>
<DraggableWrapper {...rest} />
</div>
</div>
);
});
/**
* Wraps RoomListItemView with the drag-and-drop functionality. This is only used for treegrid mode, as flat list items are not draggable.
*/
function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
const item = useViewModel(props.vm);
const { ref: draggableRef, handleRef } = useDraggable({
id: item.id,
// We clone the item in the dnd overlay to avoid putting a hole in the list
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
});
const dndRef = useMergeRefs([draggableRef, handleRef]);
return <RoomListItemView {...props} ref={dndRef} />;
}
@@ -87,6 +87,10 @@
padding-bottom: 0;
}
.dropTarget {
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
}
.menu {
display: none;
}
@@ -10,6 +10,7 @@ import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons
import classNames from "classnames";
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useDroppable } from "@dnd-kit/react";
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
import styles from "./RoomListSectionHeaderView.module.css";
@@ -103,12 +104,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
const { ref, isDropTarget } = useDroppable({
id,
});
return (
<div
aria-expanded={isExpanded}
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
>
<button
ref={ref}
type="button"
role="gridcell"
className={classNames(styles.header, {
@@ -127,7 +133,14 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex className={styles.container} align="center" justify="space-between" gap="var(--cpd-space-2x)">
<Flex
className={classNames(styles.container, {
[styles.dropTarget]: isDropTarget,
})}
align="center"
justify="space-between"
gap="var(--cpd-space-2x)"
>
<Flex align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon
className={styles.chevron}
@@ -36,6 +36,7 @@ const RoomListWrapperImpl = ({
updateVisibleRooms,
closeToast,
renderAvatar: renderAvatarProp,
changeRoomSection,
...rest
}: RoomListStoryProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -46,6 +47,7 @@ const RoomListWrapperImpl = ({
getSectionHeaderViewModel,
updateVisibleRooms,
closeToast,
changeRoomSection,
});
return (
@@ -85,6 +87,7 @@ const meta = {
renderAvatar,
isFlatList: true,
closeToast: fn(),
changeRoomSection: fn(),
},
parameters: {
design: {
@@ -6,10 +6,11 @@
*/
import React from "react";
import { render, screen, fireEvent } from "@test-utils";
import { render, screen, fireEvent, waitFor } from "@test-utils";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./VirtualizedRoomListView.stories";
@@ -65,6 +66,38 @@ describe("<VirtualizedRoomListView />", () => {
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
});
describe("drag and drop", () => {
beforeEach(() => {
// Storybook fn() spies are shared across tests; vi.clearAllMocks() may not
// reach them, so explicitly reset call history for the spy under test.
(Sections.args.changeRoomSection as any).mockClear?.();
});
it("should call changeRoomSection when drag ends successfully", async () => {
// KeyboardSensor: Space=start, ArrowDown moves position 10px/press, Space=drop.
// "General" (room 0) center is ~78px below the container top; "chats" section
// header starts ~130px below that. 15 presses × 10px = 150px → drag position
// enters the "chats" header area, making it the active droppable target.
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const roomButton = await screen.findByRole("button", { name: "Open room General" });
roomButton.focus();
await user.keyboard(" "); // start drag
for (let i = 0; i < 15; i++) {
await user.keyboard("{ArrowDown}"); // move down 10px per press
}
await user.keyboard(" "); // drop onto current target
await waitFor(() => {
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
});
});
});
describe("scrollToSectionTag", () => {
it("skips scroll when scrollToSectionTag does not match any section", () => {
const roomListState = {
@@ -8,6 +8,8 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
import { isEqual } from "lodash";
import { DragDropProvider, DragOverlay, useDragOperation } from "@dnd-kit/react";
import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
import { useViewModel } from "../../core/viewmodel";
@@ -18,9 +20,10 @@ import {
type VirtualizedListContext,
} from "../../core/VirtualizedList";
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList } from "../../core/VirtualizedList";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListItemWrapper } from "./RoomListItemWrapper";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
import styles from "./VirtualizedRoomListView.module.css";
/**
@@ -383,15 +386,78 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
}
return (
<GroupedVirtualizedList<string, string, Context>
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}
getItemComponent={getItemComponentForGroupedList}
isGroupHeaderFocusable={isGroupHeaderFocusable}
/>
<DragDropProvider
onDragEnd={(event) => {
if (event.canceled) return;
const { target, source } = event.operation;
if (!source || !target) return;
vm.changeRoomSection(source.id as string, target.id as string);
}}
sensors={[
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
}),
// By default, the KeyboardSensor uses both space and enter to start dragging, which interferes with the keyboard enter shortcut to open a room.
KeyboardSensor.configure({
keyboardCodes: {
start: ["Space"],
cancel: ["Escape"],
end: ["Space"],
up: ["ArrowUp"],
down: ["ArrowDown"],
left: ["ArrowLeft"],
right: ["ArrowRight"],
},
}),
]}
>
<DragOverlay dropAnimation={null}>
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
</DragOverlay>
<GroupedRoomList
{...commonProps}
{...getContainerAccessibleProps("treegrid", totalCount)}
scrollHandleRef={setVirtuosoHandle}
groups={groups}
getHeaderKey={getHeaderKey}
getGroupHeaderComponent={getGroupHeaderComponent}
getItemComponent={getItemComponentForGroupedList}
isGroupHeaderFocusable={isGroupHeaderFocusable}
/>
</DragDropProvider>
);
}
/**
* Inner component rendered inside DragDropProvider that renders the grouped virtualized list.
* Uses useDragOperation to detect active keyboard drags and disable the list's own keyboard
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
*/
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
const { source } = useDragOperation();
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
}
interface DragOverlayContentProps {
/** The room list view model */
vm: RoomListViewModel;
/** Function to render the room avatar */
renderAvatar: (room: Room) => ReactNode;
}
/**
* Component rendered in the drag overlay when dragging a room item. Renders a copy of the dragged item to avoid dragging the actual element out of virtualization.
*/
function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX.Element | null {
const { source } = useDragOperation();
if (!source) return null;
const itemVm = vm.getRoomItemViewModel(source.id as string);
if (!itemVm) return null;
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
}
@@ -9,3 +9,4 @@ export { VirtualizedRoomListView } from "./VirtualizedRoomListView";
export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";
export * from "./RoomListSectionHeaderView";
export * from "./RoomListItemWrapper";
export * from "./RoomListItemDragOverlayView";