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:
@@ -16,10 +16,12 @@ import "./app-web-root.css";
|
||||
import "./preview.css";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import type { StoryContext } from "storybook/internal/csf";
|
||||
|
||||
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
|
||||
import { setLanguage } from "../src/core/i18n/i18n";
|
||||
import { StoryContext } from "storybook/internal/csf";
|
||||
import { DragDropProvider } from "@dnd-kit/react";
|
||||
import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
@@ -172,7 +174,28 @@ const withEventPresentationProvider: Decorator = (Story, context) => {
|
||||
);
|
||||
};
|
||||
|
||||
const preview = {
|
||||
/**
|
||||
* Wrap all stories in a DragDropProvider that excludes the Accessibility plugin.
|
||||
* dnd-kit's Accessibility plugin adds aria attributes (tabindex, aria-pressed, etc.)
|
||||
* that conflict with the existing ARIA roles used in the room list components.
|
||||
*/
|
||||
const withDragDropProvider: Decorator = (Story) => {
|
||||
return (
|
||||
<DragDropProvider
|
||||
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 })],
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Story />
|
||||
</DragDropProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
tags: ["autodocs", "snapshot"],
|
||||
initialGlobals: {
|
||||
rootCss: "storybook",
|
||||
@@ -181,7 +204,14 @@ const preview = {
|
||||
eventLayout: "group",
|
||||
eventDensity: "default",
|
||||
},
|
||||
decorators: [withRootCss, withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
|
||||
decorators: [
|
||||
withRootCss,
|
||||
withThemeProvider,
|
||||
withEventPresentationProvider,
|
||||
withTooltipProvider,
|
||||
withI18nProvider,
|
||||
withDragDropProvider,
|
||||
],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
|
||||
@@ -40,6 +40,21 @@ or in CSS file:
|
||||
@import url("@element-hq/web-shared-components");
|
||||
```
|
||||
|
||||
### Sub-path Imports
|
||||
|
||||
Callers running outside the browser DOM (e.g. inside an `AudioWorkletGlobalScope`
|
||||
or a worker) can pull in the small standalone `numbers` utility bundle without
|
||||
loading the rest of the package bundle, which transitively imports React,
|
||||
dnd-kit, and other code that touches `window` / `document`:
|
||||
|
||||
```javascript
|
||||
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
|
||||
```
|
||||
|
||||
The sub-path exposes the same functions listed under [Formatting](#formatting)
|
||||
and ships as its own ES/CJS bundle in `dist/numbers.{js,umd.cjs}`. Prefer the
|
||||
main package entry for everything else.
|
||||
|
||||
### Using Components
|
||||
|
||||
There are two kinds of components in this library:
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -21,6 +21,16 @@
|
||||
"default": "./dist/element-web-shared-components.js"
|
||||
}
|
||||
},
|
||||
"./numbers": {
|
||||
"require": {
|
||||
"types": "./dist/numbers.d.ts",
|
||||
"default": "./dist/numbers.umd.cjs"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/numbers.d.ts",
|
||||
"default": "./dist/numbers.js"
|
||||
}
|
||||
},
|
||||
"./dist/element-web-shared-components.css": {
|
||||
"require": "./dist/element-web-shared-components.css",
|
||||
"import": "./dist/element-web-shared-components.css"
|
||||
@@ -51,6 +61,9 @@
|
||||
"lint:types": "nx lint:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.4.0",
|
||||
"@dnd-kit/dom": "^0.4.0",
|
||||
"@dnd-kit/react": "^0.4.0",
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@vector-im/compound-design-tokens": "catalog:",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3836
-3719
File diff suppressed because it is too large
Load Diff
+11
@@ -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;
|
||||
}
|
||||
+81
@@ -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 = {};
|
||||
+23
@@ -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);
|
||||
});
|
||||
});
|
||||
+46
@@ -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>
|
||||
);
|
||||
});
|
||||
+9
@@ -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";
|
||||
+79
@@ -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>
|
||||
);
|
||||
});
|
||||
+5
@@ -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;
|
||||
|
||||
+12
-36
@@ -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>
|
||||
);
|
||||
|
||||
+2
@@ -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";
|
||||
|
||||
+32
-17
@@ -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} />;
|
||||
}
|
||||
|
||||
+4
@@ -87,6 +87,10 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+14
-1
@@ -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}
|
||||
|
||||
+3
@@ -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: {
|
||||
|
||||
+35
-2
@@ -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 = {
|
||||
|
||||
+77
-11
@@ -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";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, esmExternalRequirePlugin, type Plugin } from "vite";
|
||||
@@ -16,23 +16,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cssLayerOrder = "@layer compound-tokens, compound-web, shared-components, app-web;";
|
||||
const sharedComponentsLayer = "shared-components";
|
||||
|
||||
const cssAssetFileName = "element-web-shared-components.css";
|
||||
|
||||
function layerCssAssets(): Plugin {
|
||||
return {
|
||||
name: "element-web-shared-components-css-layer",
|
||||
writeBundle(_options, bundle): void {
|
||||
for (const asset of Object.values(bundle)) {
|
||||
if (asset.type !== "asset" || asset.fileName !== "element-web-shared-components.css") {
|
||||
continue;
|
||||
}
|
||||
// Rename + layer-wrap the emitted CSS file. With multi-entry lib mode,
|
||||
// vite/rolldown derives CSS filenames from the unscoped package name (dropping
|
||||
// the `element-` prefix), so we rename on disk to keep the path stable for
|
||||
// consumers importing `@element-hq/web-shared-components/.../*.css`.
|
||||
writeBundle(options): void {
|
||||
const outDir = options.dir ?? resolve(__dirname, "dist");
|
||||
const expectedPath = resolve(outDir, cssAssetFileName);
|
||||
const renamedFromPath = resolve(outDir, "web-shared-components.css");
|
||||
|
||||
const cssPath = resolve(__dirname, "dist", asset.fileName);
|
||||
const source = readFileSync(cssPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(cssPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
if (existsSync(renamedFromPath)) {
|
||||
renameSync(renamedFromPath, expectedPath);
|
||||
}
|
||||
|
||||
// No CSS emitted in this build (e.g. storybook's vite build doesn't produce
|
||||
// the library CSS bundle), or already renamed and layered on a prior pass.
|
||||
if (!existsSync(expectedPath)) return;
|
||||
|
||||
const source = readFileSync(expectedPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) return;
|
||||
writeFileSync(expectedPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -40,10 +48,20 @@ function layerCssAssets(): Plugin {
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
// Two entries: the main bundle and a standalone `numbers` utility that callers
|
||||
// running outside the browser DOM (e.g. AudioWorkletGlobalScope) can import without
|
||||
// pulling in the rest of the package — which transitively loads dnd-kit and
|
||||
// other window/document-dependent code.
|
||||
entry: {
|
||||
"element-web-shared-components": resolve(__dirname, "src/index.ts"),
|
||||
"numbers": resolve(__dirname, "src/core/utils/numbers.ts"),
|
||||
},
|
||||
name: "Element Web Shared Components",
|
||||
// the proper extensions will be added
|
||||
fileName: "element-web-shared-components",
|
||||
// Multi-entry mode needs both formats explicit; UMD doesn't support multi-entry
|
||||
// (single global), so we ship ES + CJS and use the `.umd.cjs` extension for CJS
|
||||
// to keep the existing package.json `require` paths working.
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format, entryName) => `${entryName}.${format === "es" ? "js" : "umd.cjs"}`,
|
||||
},
|
||||
outDir: "dist",
|
||||
rolldownOptions: {
|
||||
|
||||
Reference in New Issue
Block a user