Room list: add drag and drop of sections to reorder them (#33606)

* feat(rls): centralize room-list section ordering via getOrderedSections

Introduce a single source of truth for the ordered list of section
tags (defaults + custom) in section.ts. RoomListStoreV3 no longer
hard-codes the default tag order — it asks section.ts.

* feat(vm): add section reorder logic to RoomListStoreV3 and view model

* refactor(sc): extract RoomListSectionHeaderContent from RoomListSectionHeaderView

* feat(sc): add drag-and-drop reordering for room-list sections

* test(sc): update existing tests

* test: add new tests

* test(sc): add snapshot test for overlay

* test(e2e): add playright test for dnd of sections

* feat: tweak opacity

* fix: section detection when dragged

* feat: use relative position in section order

* chore: move style to room list section header view

* test: add e2e test for moving section before another

* feat: make favourite and low priority no draggable

* test: remove deprecated e2e test

* fix: type correctly dnd provider and hooks

* fix: use String instead of casting to string

* fix: wrong a11y attributes on section header

* test: fix keyboard navigation e2e tests

* feat: use custom a11y announcement for dnd

* fix: add aria-hidden to the overlay

* chore: remove duplicated code in a11Y announcement

* fix: increase keyboard drag offset

* fix: tests

* fix: virtuoso computed item key

* fix: aria-expanded double annoncement why dragging

* fix: re-add screen reader instructions

* chore: remove unused import

* fix: reduce keyboard drag offset

* fix: improve text readback on unread section

* feat: use a custom a11y plugin instead of buitin a11y plugin

* chore: formatting

* test: fix incorrect tests

* chore: use randomUUID

* fix: try to make test working

* fix: put back mock

* fix: circular import

* Revert "fix: circular import"

This reverts commit 6c69313ade9ed2ab17a4622c692b435fbdb94ad2.

* chore: add @dnd-kit/abstract to optimizeDeps.include

* test: fix e2e tests

* fix: disable interaction with section when dragging

* fix: be more explicit if the section will be dropped before or after

* fix: add info that space is dropping too

* fix: scroll to dropped section

* test: fix virtualized room list test

* chore: update lang

* chore: fix dead code analyze

* test: add provider section story

* fix: lang

* fix: again lang...

* fix: improve voice over on chrome

* test: upate snapshot

* fix: add readback when a section is over a non droppable element
This commit is contained in:
Florian Duros
2026-06-26 10:00:44 +00:00
committed by GitHub
parent 4e3f47b948
commit 1f83ba4bbb
40 changed files with 2064 additions and 342 deletions
@@ -413,8 +413,16 @@ export function useVirtualizedList<Item, Context>(
[handleRef],
);
// Key items by id, not position, so react-virtuoso preserves (moves) the existing DOM
// node when an item's absolute index shifts — e.g. sections collapsing on drag start removes
// the rooms above a header, shifting its index. Without this, Virtuoso's default key is the
// index, so the wrapper (and the focused header inside it) remounts, the roving-tabindex effect
// refocuses the fresh node, and screen readers re-announce the header mid-drag.
const computeItemKey = useCallback((_index: number, item: Item): string => getItemKey(item), [getItemKey]);
return {
...virtuosoProps,
computeItemKey,
ref: setRef,
scrollerRef,
onKeyDown: keyDownCallback,
@@ -103,6 +103,17 @@
"room_list": {
"a11y": {
"default": "Open room %(roomName)s",
"drag_cancelled": "Dragging cancelled",
"drag_end": "%(source)s was dropped on %(target)s",
"drag_end_after": "%(source)s was dropped after %(target)s",
"drag_end_before": "%(source)s was dropped before %(target)s",
"drag_end_original": "%(source)s returned to its original position",
"drag_instructions": "Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
"drag_over": "%(source)s is over %(target)s",
"drag_over_after": "%(source)s will be dropped after %(target)s",
"drag_over_before": "%(source)s will be dropped before %(target)s",
"drag_over_original": "%(source)s will return to its original position",
"drag_start": "Dragging %(source)s",
"invitation": "Open room %(roomName)s invitation.",
"mention": {
"one": "Open room %(roomName)s with 1 unread mention.",
@@ -172,7 +183,7 @@
"more_options": "More options",
"remove_section": "Remove section",
"toggle": "Toggle %(section)s section",
"toggle_unread": "Toggle %(section)s section with unread room(s)"
"toggle_unread": "Toggle %(section)s section with unread rooms"
},
"show_message_previews": "Show message previews",
"sort": "Sort",
@@ -41,6 +41,9 @@ const RoomListViewWrapperImpl = ({
renderAvatar: renderAvatarProp,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
...rest
}: RoomListViewProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -52,6 +55,9 @@ const RoomListViewWrapperImpl = ({
updateVisibleRooms,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
});
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
};
@@ -105,6 +111,9 @@ const meta = {
toast: undefined,
closeToast: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
onSectionDragEnd: fn(),
},
parameters: {
design: {
@@ -10,10 +10,16 @@ import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { VirtuosoMockContext } from "react-virtuoso";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import * as stories from "./RoomListView.stories";
// Stable UUIDs so snapshots don't change between runs.
let uuidCounter = 0;
vi.spyOn(crypto, "randomUUID").mockImplementation(
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, "0")}` as ReturnType<typeof crypto.randomUUID>,
);
const {
Default,
Loading,
@@ -77,6 +77,12 @@ export interface RoomListViewActions {
closeToast: () => void;
/** Called to change the section of a room */
changeRoomSection: (roomId: string, tag: string) => void;
/** Called to change the order of sections */
changeSectionOrder: (sourceTag: string, targetTag: string) => void;
/** Called when a section drag starts — collapses all sections */
onSectionDragStart: () => void;
/** Called when a section drag ends (drop or cancel) — restores expansion states */
onSectionDragEnd: () => void;
}
/**
@@ -8428,42 +8428,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
aria-setsize="23"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -8483,6 +8488,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room General"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -8640,6 +8646,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -8770,6 +8777,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Engineering"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -8906,6 +8914,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Design"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9036,6 +9045,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Product"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9172,6 +9182,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Marketing with 5 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9325,6 +9336,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Sales"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9461,6 +9473,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Support"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9591,6 +9604,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Announcements"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -9727,6 +9741,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Off-topic"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -9857,6 +9872,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Alpha with 10 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10016,6 +10032,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Beta"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10146,6 +10163,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project X"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -10282,6 +10300,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project Y"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10412,6 +10431,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Water Cooler"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10548,6 +10568,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Feedback with 15 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -10701,6 +10722,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Ideas"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10837,6 +10859,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Bugs"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -10967,6 +10990,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Features"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11103,6 +11127,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Releases"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11233,6 +11258,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room General with 20 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11392,6 +11418,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11522,6 +11549,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Engineering"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11655,42 +11683,47 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
aria-setsize="29"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -11710,6 +11743,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Design"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -11840,6 +11874,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Product"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -11976,6 +12011,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Marketing with 25 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12129,6 +12165,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Sales"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12265,6 +12302,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Support"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -12395,6 +12433,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Announcements"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12531,6 +12570,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Off-topic"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12661,6 +12701,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Alpha with 30 unread mentions."
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -12820,6 +12861,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Team Beta"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -12950,6 +12992,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project X"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -13086,6 +13129,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Project Y"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -13216,6 +13260,7 @@ exports[`<RoomListView /> > renders LargeSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000006"
aria-haspopup="menu"
aria-label="Open room Water Cooler"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -13880,42 +13925,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
aria-setsize="2"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderView-module_firstHeader"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
<div
@@ -13935,6 +13985,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-haspopup="menu"
aria-label="Open room General"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView RoomListItemView-module_bold"
@@ -14092,6 +14143,7 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
role="gridcell"
>
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-haspopup="menu"
aria-label="Open room Random"
class="Flex-module_flex RoomListItemView-module_roomListItem mx_RoomListItemView"
@@ -14219,42 +14271,47 @@ exports[`<RoomListView /> > renders SmallSectionList story 1`] = `
aria-setsize="0"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-describedby="00000000-0000-0000-0000-000000000003"
aria-expanded="true"
aria-label="Toggle Chats section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Chats
</span>
</div>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,437 @@
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@test-utils";
import {
type A11yData,
type DragAnnouncementGetter,
RoomListAccessibilityPlugin,
type RoomListAccessibilityOptions,
useRoomListAccessibilityPlugin,
} from "./RoomListAccessibilityPlugin";
import { I18nContext } from "../../core/i18n/i18nContext";
import { I18nApi } from "../../core/i18n/I18nApi";
import type { RoomListViewModel } from "../RoomListView";
// ---------------------------------------------------------------------------
// Minimal mock manager compatible with the @dnd-kit/abstract Plugin base class
// ---------------------------------------------------------------------------
type EventHandler = (event: unknown) => void;
function createMockManager(): {
monitor: { addEventListener: ReturnType<typeof vi.fn> };
registry: {
draggables: { readonly value: IterableIterator<{ handle: HTMLElement | null; element: HTMLElement | null }> };
};
dispatch: (eventName: string, event: unknown) => void;
draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[];
} {
const listeners = new Map<string, EventHandler[]>();
const monitor = {
addEventListener: vi.fn((eventName: string, handler: EventHandler) => {
if (!listeners.has(eventName)) listeners.set(eventName, []);
listeners.get(eventName)!.push(handler);
return vi.fn(() => {
const fns = listeners.get(eventName);
if (fns) {
const idx = fns.indexOf(handler);
if (idx >= 0) fns.splice(idx, 1);
}
});
}),
};
// A list of fake draggable objects the effect iterates over.
const draggableElements: { handle: HTMLElement | null; element: HTMLElement | null }[] = [];
const registry = {
draggables: {
// Plain (non-reactive) getter the effect runs once on construction.
get value() {
return draggableElements.values();
},
},
};
/** Trigger a monitor event on all registered handlers. */
const dispatch = (eventName: string, event: unknown): void => {
listeners.get(eventName)?.forEach((fn) => fn(event));
};
return { monitor, registry, dispatch, draggableElements };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createPlugin(
manager: ReturnType<typeof createMockManager>,
options?: RoomListAccessibilityOptions,
): RoomListAccessibilityPlugin {
// RoomListAccessibilityPlugin extends Plugin<DragDropManager> from @dnd-kit/abstract.
// The base class only requires manager.monitor and manager.registry to exist, which our
// mock satisfies.
return new RoomListAccessibilityPlugin(manager as never, options);
}
function getLiveRegion(): HTMLElement | null {
return document.querySelector<HTMLElement>("[role='status'][aria-live='polite']");
}
function getAssertiveRegion(): HTMLElement | null {
return document.querySelector<HTMLElement>("[role='alert'][aria-live='assertive']");
}
function getInstructions(): HTMLElement | null {
return document.querySelector<HTMLElement>("[style*='display: none']");
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RoomListAccessibilityPlugin", () => {
let manager: ReturnType<typeof createMockManager>;
beforeEach(() => {
manager = createMockManager();
});
afterEach(() => {
// Clean up any DOM nodes left behind by tests that don't call destroy().
getLiveRegion()?.remove();
getAssertiveRegion()?.remove();
getInstructions()?.remove();
});
describe("construction", () => {
it("appends a polite aria-live region to the document body", () => {
const plugin = createPlugin(manager);
const region = getLiveRegion();
expect(region).not.toBeNull();
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("role", "status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("aria-atomic", "true");
plugin.destroy();
});
it("appends an assertive aria-live region to the document body", () => {
const plugin = createPlugin(manager);
const region = getAssertiveRegion();
expect(region).not.toBeNull();
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("role", "alert");
expect(region).toHaveAttribute("aria-live", "assertive");
expect(region).toHaveAttribute("aria-atomic", "true");
plugin.destroy();
});
it("appends a hidden instructions element when the instructions option is provided", () => {
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
const el = getInstructions();
expect(el).not.toBeNull();
expect(el).toBeInTheDocument();
expect(el?.textContent).toBe("Press Space to drag");
expect(el?.style.display).toBe("none");
plugin.destroy();
});
it("does not append an instructions element when the option is omitted", () => {
const plugin = createPlugin(manager);
expect(getInstructions()).toBeNull();
plugin.destroy();
});
it("adds aria-describedby pointing to the instructions element on existing draggables", () => {
const button = document.createElement("button");
button.setAttribute("aria-label", "Toggle Favourites section");
document.body.append(button);
manager.draggableElements.push({ handle: button, element: button });
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
// The ID is a generated UUID — verify the button points to the instructions element.
const instructionsId = button.getAttribute("aria-describedby");
expect(instructionsId).toBeTruthy();
expect(document.getElementById(instructionsId!)).toBe(getInstructions());
button.remove();
plugin.destroy();
});
it("does not overwrite an existing aria-describedby on a draggable", () => {
const button = document.createElement("button");
button.setAttribute("aria-describedby", "my-existing-id");
document.body.append(button);
manager.draggableElements.push({ handle: button, element: button });
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
expect(button).toHaveAttribute("aria-describedby", "my-existing-id");
button.remove();
plugin.destroy();
});
});
describe("dragstart and dragover announcements", () => {
it("writes a dragstart message to the live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragstart: () => "Dragging Favourites" },
});
manager.dispatch("dragstart", {});
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
plugin.destroy();
});
it("writes a dragover message to the live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragover: () => "Dragging Favourites over Low Priority" },
});
manager.dispatch("dragover", {});
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites over Low Priority");
plugin.destroy();
});
it("does not update the live region when the getter returns undefined", () => {
const plugin = createPlugin(manager, {
announcements: { dragstart: () => undefined },
});
manager.dispatch("dragstart", {});
expect(getLiveRegion()?.textContent).toBe("");
plugin.destroy();
});
it("does not update the live region when the message is the same as the current text", () => {
const get = vi.fn(() => "Dragging Favourites");
const plugin = createPlugin(manager, {
announcements: { dragstart: get },
});
manager.dispatch("dragstart", {});
manager.dispatch("dragstart", {});
// The getter was called twice but the live region text is set only once (dedup).
expect(get).toHaveBeenCalledTimes(2);
expect(getLiveRegion()?.textContent).toBe("Dragging Favourites");
plugin.destroy();
});
it("passes the raw dnd-kit event to the announcement getter", () => {
const getter = vi.fn(() => "Dragging Favourites");
const plugin = createPlugin(manager, {
announcements: { dragstart: getter },
});
const fakeEvent = { operation: { source: { id: "fav" } } };
manager.dispatch("dragstart", fakeEvent);
expect(getter).toHaveBeenCalledWith(fakeEvent);
plugin.destroy();
});
});
describe("dragend announcement", () => {
it("announces the drop message in the assertive live region", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => "Favourites was dropped on Low Priority" },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
// The drop is announced in the assertive region: focus stays on the source element so
// there is no focus change to read, and an assertive region reliably announces on Chrome.
expect(getAssertiveRegion()?.textContent).toBe("Favourites was dropped on Low Priority");
plugin.destroy();
});
it("re-announces an identical message by clearing the region first", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => "Dropped" },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
// The same text set twice must still end up in the region (clear-then-set forces a change).
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
expect(getAssertiveRegion()?.textContent).toBe("Dropped");
plugin.destroy();
});
it("does not announce when the getter returns undefined", () => {
const plugin = createPlugin(manager, {
announcements: { dragend: () => undefined },
});
manager.dispatch("dragend", { operation: { source: { id: "favourites" } } });
// Both live regions stay empty.
expect(getLiveRegion()?.textContent).toBe("");
expect(getAssertiveRegion()?.textContent).toBe("");
plugin.destroy();
});
});
describe("destroy", () => {
it("removes the live region from the document", () => {
const plugin = createPlugin(manager);
expect(getLiveRegion()).not.toBeNull();
plugin.destroy();
expect(getLiveRegion()).toBeNull();
});
it("removes the assertive live region from the document", () => {
const plugin = createPlugin(manager);
expect(getAssertiveRegion()).not.toBeNull();
plugin.destroy();
expect(getAssertiveRegion()).toBeNull();
});
it("removes the instructions element from the document", () => {
const plugin = createPlugin(manager, { instructions: "Press Space to drag" });
expect(getInstructions()).not.toBeNull();
plugin.destroy();
expect(getInstructions()).toBeNull();
});
it("calls the unsubscribe functions returned by monitor.addEventListener", () => {
// Capture the unsubscribe function spy that the mock returns.
let unsubscribeSpy: ReturnType<typeof vi.fn> | undefined;
manager.monitor.addEventListener.mockImplementation((_eventName: string, _handler: EventHandler) => {
unsubscribeSpy = vi.fn();
return unsubscribeSpy as unknown as ReturnType<typeof vi.fn<() => void>>;
});
const plugin = createPlugin(manager, {
announcements: { dragstart: () => "Dragging" },
});
plugin.destroy();
expect(unsubscribeSpy).toHaveBeenCalled();
});
});
describe("useRoomListAccessibilityPlugin announcements", () => {
const SECTION_TITLES: Record<string, string> = {
work: "Work",
fun: "Fun",
};
const ROOM_NAMES: Record<string, string> = {
"!room:server": "My Room",
};
function createMockVm(): RoomListViewModel {
return {
getSectionHeaderViewModel: (id: string) => ({
getSnapshot: () => ({ title: SECTION_TITLES[id] ?? id }),
}),
getRoomItemViewModel: (id: string) => ({
getSnapshot: () => ({ name: ROOM_NAMES[id] }),
}),
} as unknown as RoomListViewModel;
}
/** Render the hook and return the announcement getters it configures on the plugin. */
function getAnnouncements(
vm: RoomListViewModel,
): Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>> {
const wrapper = ({ children }: { children: React.ReactNode }): React.ReactNode =>
React.createElement(I18nContext.Provider, { value: new I18nApi() }, children);
const { result } = renderHook(() => useRoomListAccessibilityPlugin(vm), { wrapper });
const descriptor = result.current([]).find(
(
plugin,
): plugin is {
plugin: typeof RoomListAccessibilityPlugin;
options: RoomListAccessibilityOptions;
} => typeof plugin === "object" && plugin.plugin === RoomListAccessibilityPlugin,
);
return descriptor!.options.announcements!;
}
const sectionSource = (id: string, index: number): A11yData["operation"]["source"] =>
({ id, data: { type: "section", index } }) as A11yData["operation"]["source"];
const sectionTarget = (id: string, index: number): A11yData["operation"]["target"] =>
({ id, data: { type: "section", index } }) as A11yData["operation"]["target"];
it("announces a section will return to its original position when dragged over a non-droppable area", () => {
const { dragover } = getAnnouncements(createMockVm());
const message = dragover!({
operation: { source: sectionSource("work", 1), target: null },
canceled: false,
});
expect(message).toBe("Work will return to its original position");
});
it("announces a section returned to its original position when dropped on a non-droppable area", () => {
const { dragend } = getAnnouncements(createMockVm());
const message = dragend!({
operation: { source: sectionSource("work", 1), target: null },
canceled: false,
});
expect(message).toBe("Work returned to its original position");
});
it("still announces the before/after target when a section is dragged over another section", () => {
const { dragover, dragend } = getAnnouncements(createMockVm());
// Source index 2 dropped onto target index 1 → dropped before the target.
const event: A11yData = {
operation: { source: sectionSource("fun", 2), target: sectionTarget("work", 1) },
canceled: false,
};
expect(dragover!(event)).toBe("Fun will be dropped before Work");
expect(dragend!(event)).toBe("Fun was dropped before Work");
});
it("announces cancellation even when there is no target", () => {
const { dragend } = getAnnouncements(createMockVm());
const message = dragend!({
operation: { source: sectionSource("work", 1), target: null },
canceled: true,
});
expect(message).toBe("Dragging cancelled");
});
});
});
@@ -0,0 +1,276 @@
/*
* 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 { useCallback, useMemo } from "react";
import { configure, Plugin, type Plugins } from "@dnd-kit/abstract";
import { Accessibility, type Draggable, type DragDropManager, type Droppable } from "@dnd-kit/dom";
import { useI18n } from "../../core/i18n/i18nContext";
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
import type { RoomListViewModel } from "../RoomListView";
type Manager = DragDropManager;
/** Shape of the dnd-kit monitor event passed to the announcement getters. */
export type A11yData = {
operation: {
source: Draggable<RoomListDragData> | null;
target: Droppable<RoomListDragData> | null;
};
canceled: boolean;
};
/**
* Produces the screen-reader announcement for a drag lifecycle event, or `undefined`
* to stay silent. The event exposes `operation.source`, `operation.target` and (on
* `dragend`) `canceled`.
*/
export type DragAnnouncementGetter = (event: A11yData) => string | undefined;
/**
* Options for {@link RoomListAccessibilityPlugin}.
*
* All fields are optional so the options type stays assignable to dnd-kit's generic
* `PluginOptions`.
*/
export interface RoomListAccessibilityOptions {
/** Announcement to emit for each drag lifecycle event. */
announcements?: Partial<Record<"dragstart" | "dragover" | "dragend", DragAnnouncementGetter>>;
/**
* Keyboard drag instructions read out when a draggable receives focus, wired to each
* draggable via `aria-describedby`.
*/
instructions?: string;
}
/**
* Create the visually-hidden `aria-live` region used to announce drag progress.
*
* @param politeness - `"polite"` for progress (start/over) updates, `"assertive"` for the terminal
* drop/cancel confirmation so it interrupts any pending progress chatter and is announced reliably.
*/
function createLiveRegion(id: string, politeness: "polite" | "assertive" = "polite"): HTMLDivElement {
const element = document.createElement("div");
element.id = id;
element.setAttribute("role", politeness === "assertive" ? "alert" : "status");
element.setAttribute("aria-live", politeness);
element.setAttribute("aria-atomic", "true");
Object.assign(element.style, {
position: "fixed",
width: "1px",
height: "1px",
margin: "-1px",
border: "0",
padding: "0",
overflow: "hidden",
clip: "rect(0 0 0 0)",
clipPath: "inset(100%)",
whiteSpace: "nowrap",
});
return element;
}
/**
* Create the hidden element holding the keyboard drag instructions. Only referenced via
* `aria-describedby` (never announced), so `display: none` is enough to hide it.
*/
function createInstructions(id: string, text: string): HTMLDivElement {
const element = document.createElement("div");
element.id = id;
element.style.display = "none";
element.textContent = text;
return element;
}
/**
* A dnd-kit plugin that manages the room list's drag-and-drop accessibility:
* - announces drag progress to screen readers via an `aria-live` region, and
* - exposes the keyboard drag instructions, wiring them to every draggable through
* `aria-describedby`.
*
* This is a deliberately reduced replacement for dnd-kit's built-in `Accessibility`
* plugin. The built-in plugin also reflects `aria-pressed`/`aria-grabbed` onto the
* draggable `<button>`, which VoiceOver reads as "selected" the instant a keyboard drag
* starts. We filter the built-in plugin out (see VirtualizedRoomListView) and use this
* instead: it never mutates the draggable's pressed/grabbed ARIA state.
*/
export class RoomListAccessibilityPlugin extends Plugin<Manager, RoomListAccessibilityOptions> {
private liveRegion: HTMLDivElement;
private assertiveRegion: HTMLDivElement;
private instructions?: HTMLDivElement;
private readonly unsubscribers: Array<() => void> = [];
public constructor(manager: Manager, options?: RoomListAccessibilityOptions) {
super(manager, options);
const liveRegionId = crypto.randomUUID();
const assertiveRegionId = crypto.randomUUID();
const instructionsId = crypto.randomUUID();
// Create the live regions up front so they exist in the DOM before any text change,
// which assistive technologies require to reliably announce the first message.
this.liveRegion = createLiveRegion(liveRegionId);
this.assertiveRegion = createLiveRegion(assertiveRegionId, "assertive");
document.body.append(this.liveRegion, this.assertiveRegion);
const announcements = options?.announcements ?? {};
for (const [eventName, getAnnouncement] of Object.entries(announcements)) {
if (!getAnnouncement) continue;
let unsubscribe: () => void;
if (eventName === "dragend") {
unsubscribe = manager.monitor.addEventListener("dragend", (event) => {
const a11yEvent = event as unknown as A11yData;
const message = getAnnouncement(a11yEvent);
// On drop the source element stays focused (focus never actually moves), so we need to announce the drop confirmation in the assertive region to ensure it is read.
this.announceAssertive(message);
});
} else {
unsubscribe = manager.monitor.addEventListener(eventName as "dragstart", (event) => {
this.announce(getAnnouncement(event as unknown as A11yData));
});
}
this.unsubscribers.push(unsubscribe);
}
if (options?.instructions) {
this.instructions = createInstructions(instructionsId, options.instructions);
document.body.append(this.instructions);
// Point every draggable at the instructions via aria-describedby. The effect re-runs
// as draggables register/unregister (the list is virtualized), so newly mounted items
// get described too. We never touch aria-pressed/aria-grabbed here.
this.registerEffect(() => {
if (!this.instructions) return;
for (const draggable of this.manager.registry.draggables.value) {
const activator = draggable.handle ?? draggable.element;
if (activator && !activator.hasAttribute("aria-describedby")) {
activator.setAttribute("aria-describedby", instructionsId);
}
}
});
}
}
private announce(message: string | undefined): void {
if (!message || this.liveRegion.textContent === message) return;
this.liveRegion.textContent = message;
}
/**
* Announce a terminal message (drop confirmation / cancellation) in the assertive live region.
*
* Unlike the polite progress region, the drop happens with focus parked on the source element
* and no focus change to trigger a re-read, so an assertive region is required for Chrome to
* announce it. It also interrupts any still-pending "… will be dropped …" progress chatter.
*/
private announceAssertive(message: string | undefined): void {
if (!message) return;
// Always re-set (clearing first) so an identical message still re-announces, and so the
// assertive region reliably fires even right after a polite progress update.
this.assertiveRegion.textContent = "";
this.assertiveRegion.textContent = message;
}
public destroy(): void {
super.destroy();
for (const unsubscribe of this.unsubscribers) unsubscribe();
this.unsubscribers.length = 0;
this.liveRegion.remove();
this.assertiveRegion.remove();
this.instructions?.remove();
}
}
/**
* Configures {@link RoomListAccessibilityPlugin} for the room list and returns a `plugins`
* callback for `DragDropProvider`.
*
* It swaps dnd-kit's built-in Accessibility plugin (which adds the `aria-pressed` that
* VoiceOver reads as "selected") for {@link RoomListAccessibilityPlugin}, supplying it with
* localized announcements derived from the room list view model. The result is memoized so
* the plugin descriptor stays stable across renders and the plugin isn't torn down and
* recreated.
*/
export function useRoomListAccessibilityPlugin(
vm: RoomListViewModel,
): (defaults: Plugins<Manager>) => Plugins<Manager> {
const { translate: _t } = useI18n();
// Get the display name of a draggable source: the section title for a section, or the
// room name for a room. Returns undefined if the source can't be resolved.
const getDragSourceName = useCallback(
(source: Draggable<RoomListDragData>): string | undefined => {
if (isSectionDragData(source.data)) {
return vm.getSectionHeaderViewModel(source.id as string).getSnapshot().title;
}
return vm.getRoomItemViewModel(source.id as string)?.getSnapshot().name;
},
[vm],
);
const announcements = useMemo(
() => ({
dragstart: ({ operation: { source } }: A11yData) => {
if (!source) return;
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
return _t("room_list|a11y|drag_start", { source: sourceName });
},
dragover: ({ operation: { source, target } }: A11yData) => {
if (!source) return;
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
if (isSectionDragData(source.data) && !target) {
return _t("room_list|a11y|drag_over_original", { source: sourceName });
}
if (!target) return;
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
const droppedBefore = source.data.index > target.data.index;
return droppedBefore
? _t("room_list|a11y|drag_over_before", { source: sourceName, target: targetTitle })
: _t("room_list|a11y|drag_over_after", { source: sourceName, target: targetTitle });
}
return _t("room_list|a11y|drag_over", { source: sourceName, target: targetTitle });
},
dragend: ({ operation: { source, target }, canceled }: A11yData) => {
if (!source) return;
if (canceled) return _t("room_list|a11y|drag_cancelled");
const sourceName = getDragSourceName(source);
if (sourceName === undefined) return;
// A section dragged over a non-droppable area (favourites/low-priority or its own original slot) has no target and snaps back to where it started.
if (isSectionDragData(source.data) && !target) {
return _t("room_list|a11y|drag_end_original", { source: sourceName });
}
if (!target) return;
const targetTitle = vm.getSectionHeaderViewModel(target.id as string).getSnapshot().title;
if (isSectionDragData(source.data) && isSectionDragData(target.data)) {
const droppedBefore = source.data.index > target.data.index;
return droppedBefore
? _t("room_list|a11y|drag_end_before", { source: sourceName, target: targetTitle })
: _t("room_list|a11y|drag_end_after", { source: sourceName, target: targetTitle });
}
return _t("room_list|a11y|drag_end", { source: sourceName, target: targetTitle });
},
}),
[vm, _t, getDragSourceName],
);
const instructions = _t("room_list|a11y|drag_instructions");
return useCallback(
(defaults) => [
// remove the built-in Accessibility plugin
...defaults.filter((plugin) => plugin !== Accessibility),
configure(RoomListAccessibilityPlugin, { announcements, instructions }),
],
[announcements, instructions],
);
}
@@ -35,7 +35,11 @@ export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlay
renderAvatar,
}: RoomListItemDragOverlayViewProps): JSX.Element {
return (
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
// dragged room isn't duplicated (the real, still-focused item already exposes it, and drag
// feedback is narrated via the dnd live-region announcements).
<Flex
aria-hidden={true}
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
gap="var(--cpd-space-3x)"
align="stretch"
@@ -14,6 +14,7 @@ import { useMergeRefs } from "react-merge-refs";
import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
import { useViewModel } from "../../../core/viewmodel";
import { type RoomDragData } from "../dragAndDrop";
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
/** Index of this room in the list */
@@ -76,8 +77,9 @@ function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
ref: draggableRef,
handleRef,
isDragSource,
} = useDraggable({
} = useDraggable<RoomDragData>({
id: item.id,
data: { type: "room" },
// We clone the item in the dnd overlay to avoid putting a hole in the list
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
@@ -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,63 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
type RoomListSectionHeaderViewSnapshot,
type RoomListSectionHeaderActions,
} from "../RoomListSectionHeaderView";
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
import { useMockedViewModel } from "../../../core/viewmodel";
import { withViewDocs } from "../../../../.storybook/withViewDocs";
type RoomListSectionHeaderDragOverlayProps = RoomListSectionHeaderViewSnapshot & RoomListSectionHeaderActions;
const RoomListSectionHeaderDragOverlayWrapperImpl = ({
onClick,
editSection,
removeSection,
...rest
}: RoomListSectionHeaderDragOverlayProps): JSX.Element => {
const vm = useMockedViewModel(rest, { onClick, editSection, removeSection });
return <RoomListSectionHeaderDragOverlayView vm={vm} />;
};
const RoomListSectionHeaderDragOverlayWrapper = withViewDocs(
RoomListSectionHeaderDragOverlayWrapperImpl,
RoomListSectionHeaderDragOverlayView,
);
const meta = {
title: "Room List/RoomListSectionHeaderDragOverlayView",
component: RoomListSectionHeaderDragOverlayWrapper,
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ width: "320px", padding: "8px" }}>
<Story />
</div>
),
],
args: {
id: "element.io.section.abc123",
title: "Work",
isExpanded: true,
isUnread: false,
displaySectionMenu: true,
canBeReordered: true,
onClick: fn(),
editSection: fn(),
removeSection: fn(),
},
} satisfies Meta<typeof RoomListSectionHeaderDragOverlayWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,22 @@
/*
* 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 } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect } from "vitest";
import * as stories from "./RoomListSectionHeaderDragOverlayView.stories";
const { Default } = composeStories(stories);
describe("<RoomListSectionHeaderDragOverlayView /> stories", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
@@ -0,0 +1,41 @@
/*
* 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, { memo, type JSX } from "react";
import classNames from "classnames";
import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView";
import { RoomListSectionHeaderContent } from "../RoomListSectionHeaderView/RoomListSectionHeaderContent";
import headerStyles from "../RoomListSectionHeaderView/RoomListSectionHeaderView.module.css";
import styles from "./RoomListSectionHeaderDragOverlayView.module.css";
/**
* Props for {@link RoomListSectionHeaderDragOverlayView}.
*/
export interface RoomListSectionHeaderDragOverlayViewProps {
/** The section header view model — same one used by the real section header */
vm: RoomListSectionHeaderViewModel;
}
/**
* Visual clone of a section header rendered inside the dnd drag overlay.
*
* Reuses {@link RoomListSectionHeaderContent} for the inner layout so the
* floating clone matches a real section header.
*/
export const RoomListSectionHeaderDragOverlayView = memo(function RoomListSectionHeaderDragOverlayView({
vm,
}: RoomListSectionHeaderDragOverlayViewProps): JSX.Element {
return (
// Purely a visual clone that follows the drag. Hide it from the accessibility tree so the
// dragged section's title isn't duplicated (the real, still-focused header already exposes
// it, and drag feedback is narrated via the dnd live-region announcements).
<div aria-hidden={true} className={classNames(headerStyles.header, styles.dragOverlay)}>
<RoomListSectionHeaderContent vm={vm} isDragging={true} />
</div>
);
});
@@ -0,0 +1,42 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListSectionHeaderDragOverlayView /> stories > renders Default story 1`] = `
<div>
<div
style="width: 320px; padding: 8px;"
>
<div
aria-hidden="true"
class="RoomListSectionHeaderView-module_header RoomListSectionHeaderDragOverlayView-module_dragOverlay"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container RoomListSectionHeaderView-module_dragging"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Work
</span>
</div>
</div>
</div>
</div>
</div>
`;
@@ -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 { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
export type { RoomListSectionHeaderDragOverlayViewProps } from "./RoomListSectionHeaderDragOverlayView";
@@ -0,0 +1,117 @@
/*
* 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, { memo, type JSX, useState } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
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 { useViewModel } from "../../../core/viewmodel";
import { _t } from "../../../core/i18n/i18n";
import { Flex } from "../../../core/utils/Flex";
import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
import styles from "./RoomListSectionHeaderView.module.css";
import { NotificationDecoration } from "../RoomListItemWrapper/RoomListItemView";
/**
* Props for {@link RoomListSectionHeaderContent}.
*/
export interface RoomListSectionHeaderContentProps {
/** The section header view model */
vm: RoomListSectionHeaderViewModel;
/** Whether the section header is being dragged — hides the interactive menu when true */
isDragging?: boolean;
}
/**
* The inner content of a section header: chevron, title, and menu (or static menu icon when dragging).
* Used both inside the full {@link RoomListSectionHeaderView} and inside the drag overlay.
*/
export const RoomListSectionHeaderContent = memo(function RoomListSectionHeaderContent({
vm,
isDragging = false,
}: RoomListSectionHeaderContentProps): JSX.Element {
const { title, displaySectionMenu, notification, isExpanded } = useViewModel(vm);
return (
<Flex
className={classNames(styles.container, {
[styles.dragging]: isDragging,
})}
align="center"
justify="space-between"
gap="var(--cpd-space-2x)"
>
<Flex align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon
className={styles.chevron}
width="24px"
height="24px"
fill="var(--cpd-color-icon-secondary)"
/>
<span className={styles.title}>{title}</span>
</Flex>
{!isExpanded && notification && (
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...notification} />
</div>
)}
{displaySectionMenu && !isDragging && <MenuComponent vm={vm} />}
</Flex>
);
});
interface MenuComponentProps {
vm: RoomListSectionHeaderViewModel;
}
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|section_header|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
className={styles.menu}
tooltip={_t("room_list|section_header|more_options")}
aria-label={_t("room_list|section_header|more_options")}
size="24px"
style={{ padding: "2px" }}
color="var(--cpd-color-icon-primary)"
>
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
</IconButton>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
hideChevron={true}
Icon={EditIcon}
label={_t("room_list|section_header|edit_section")}
onSelect={() => vm.editSection()}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
hideChevron={true}
Icon={DeleteIcon}
label={_t("room_list|section_header|remove_section")}
onSelect={() => vm.removeSection()}
onClick={(evt) => evt.stopPropagation()}
/>
</div>
</Menu>
);
}
@@ -65,6 +65,7 @@
}
.container {
position: relative;
margin: 0 var(--cpd-space-3x);
padding: var(--cpd-space-1-5x) var(--cpd-space-2x) var(--cpd-space-1-5x) var(--cpd-space-1x);
border-radius: 8px;
@@ -99,10 +100,38 @@
padding-bottom: 0;
}
.dropTarget {
.dropTarget .container {
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
}
.dropTargetBottom .container::after,
.dropTargetTop .container::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background-color: var(--cpd-color-border-accent-primary);
}
.dropTargetBottom .container::after {
bottom: 0;
}
.dropTargetTop .container::before {
top: 0;
}
.menu {
display: none;
}
.dragging {
outline: 1px solid var(--cpd-color-border-interactive-hovered);
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 95%, transparent);
color: var(--cpd-color-text-primary);
}
.dragSource .container {
opacity: 0.6;
}
@@ -7,6 +7,8 @@
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { DragDropProvider } from "@dnd-kit/react";
import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
@@ -71,6 +73,7 @@ const meta = {
muted: false,
},
displaySectionMenu: true,
canBeReordered: true,
onClick: fn(),
onFocus: fn(),
editSection: fn(),
@@ -82,9 +85,17 @@ const meta = {
},
decorators: [
(Story) => (
<div role="treegrid" style={{ width: "320px" }}>
<Story />
</div>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
}),
]}
>
<div role="treegrid" style={{ width: "320px" }}>
<Story />
</div>
</DragDropProvider>
),
],
parameters: {
@@ -33,14 +33,14 @@ describe("<RoomListSectionHeaderView /> stories", () => {
const user = userEvent.setup();
render(<Default />);
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
const button = screen.getByRole("button", { name: HEADER_NAME });
await user.click(button);
expect(Default.args.onClick).toHaveBeenCalled();
});
it("focuses the button when isFocused is true", () => {
render(<Default isFocused={true} />);
const button = screen.getByRole("gridcell", { name: HEADER_NAME });
const button = screen.getByRole("button", { name: HEADER_NAME });
expect(document.activeElement).toBe(button);
});
@@ -5,24 +5,20 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { memo, type JSX, type FocusEvent, useEffect, useRef, useState } from "react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-right";
import React, { memo, type JSX, type FocusEvent, useEffect, useRef } from "react";
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 { useDraggable, useDragOperation, useDroppable } from "@dnd-kit/react";
import { useMergeRefs } from "react-merge-refs";
import { Feedback } from "@dnd-kit/dom";
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
import styles from "./RoomListSectionHeaderView.module.css";
import { Flex } from "../../../core/utils/Flex";
import { useI18n } from "../../../core/i18n/i18nContext";
import { getGroupHeaderAccessibleProps } from "../../../core/VirtualizedList";
import { _t } from "../../../core/i18n/i18n";
import {
NotificationDecoration,
type NotificationDecorationData,
} from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
import { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
import { isSectionDragData, type RoomListDragData, type SectionDragData } from "../dragAndDrop";
import { type NotificationDecorationData } from "../RoomListItemWrapper/RoomListItemView/NotificationDecoration";
/**
* The observable state snapshot for a room list section header.
@@ -40,6 +36,8 @@ export interface RoomListSectionHeaderViewSnapshot {
notification?: NotificationDecorationData;
/** Wether to display the section menu */
displaySectionMenu: boolean;
/** Whether the section can be reordered via drag-and-drop */
canBeReordered: boolean;
}
/**
@@ -108,14 +106,61 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
roomCountInSection,
}: Readonly<RoomListSectionHeaderViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { id, title, isExpanded, isUnread, notification, displaySectionMenu } = useViewModel(vm);
const { id, title, isExpanded, isUnread, canBeReordered } = useViewModel(vm);
const isLastSection = sectionIndex === sectionCount - 1;
const { ref: droppableRef, isDropTarget } = useDroppable({
const {
ref: draggableRef,
handleRef,
isDragSource,
} = useDraggable<SectionDragData>({
id,
data: { type: "section", index: sectionIndex },
plugins: [Feedback.configure({ feedback: "clone" })],
modifiers: [RestrictToVerticalAxis],
disabled: !canBeReordered,
});
const { source } = useDragOperation<RoomListDragData>();
const draggedData = source?.data;
const isDraggingSectionSource = isSectionDragData(draggedData);
// Keep the droppable enabled so rooms can still be dropped on default sections
// (Favourite / Low Priority). Only disable it for section drags on non-reorderable
// headers so they can't be used as reorder targets.
const { ref: droppableRef, isDropTarget } = useDroppable<SectionDragData>({
id,
data: { type: "section", index: sectionIndex },
disabled: isDragSource || (isDraggingSectionSource && !canBeReordered),
});
const isDraggingRoom = isDropTarget && draggedData?.type === "room";
const isDraggingSection = isDropTarget && isDraggingSectionSource;
const sourceSectionIndex = isSectionDragData(draggedData) ? draggedData.index : -1;
const isSourceAbove = isDraggingSection && sourceSectionIndex > sectionIndex;
const hasBottomBorder = isDraggingSection && !isSourceAbove;
const hasTopBorder = isDraggingSection && isSourceAbove;
// Keep the last expanded state we rendered while NOT dragging.
const lastExpandedRef = useRef(isExpanded);
if (!isDragSource) {
lastExpandedRef.current = isExpanded;
}
// While this header is the drag source, freeze aria-expanded at its pre-drag value. Section
// drag start collapses every section, flipping this focused header's aria-expanded true→false;
// announcing that state change is a second, redundant screen-reader announcement on top of the
// dnd "Dragging X" live-region announcement. The collapse is still reflected visually.
const ariaExpanded = isDragSource ? lastExpandedRef.current : isExpanded;
const internalRef = useRef<HTMLButtonElement>(null);
const mergedRef = useMergeRefs<HTMLButtonElement>([droppableRef, internalRef]);
// Only wire up draggable refs when the section can be dragged. Otherwise dndkit will put incorrect and misleading a11y attributes
// on the default section (aka aria-disabled=true and aria-draggable=false)
const buttonRef = useMergeRefs([
...(canBeReordered ? [draggableRef, handleRef] : []),
droppableRef,
internalRef,
]) as React.Ref<HTMLButtonElement>;
useEffect(() => {
if (isFocused) {
@@ -125,128 +170,54 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
return (
<div
aria-expanded={isExpanded}
aria-expanded={ariaExpanded}
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
>
<button
ref={mergedRef}
type="button"
role="gridcell"
className={classNames(styles.header, {
[styles.firstHeader]: sectionIndex === 0,
// If the section is collapsed and it's the last one
[styles.lastHeader]: !isExpanded && isLastSection,
[styles.unread]: isUnread,
})}
onClick={vm.onClick}
onKeyDown={(e) => {
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
e.preventDefault();
e.stopPropagation();
vm.onClick();
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
// Move focus to the first room in the section
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowDown",
key: "ArrowDown",
bubbles: true,
}),
);
}
}}
aria-expanded={isExpanded}
onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1}
aria-label={
isUnread
? _t("room_list|section_header|toggle_unread", { section: title })
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex
className={classNames(styles.container, {
[styles.dropTarget]: isDropTarget,
<div role="gridcell" aria-expanded={ariaExpanded}>
<button
ref={buttonRef}
type="button"
className={classNames(styles.header, {
[styles.firstHeader]: sectionIndex === 0,
// If the section is collapsed and it's the last one
[styles.lastHeader]: !isExpanded && isLastSection,
[styles.unread]: isUnread,
[styles.dragSource]: isDragSource,
[styles.dropTarget]: isDraggingRoom,
[styles.dropTargetBottom]: hasBottomBorder,
[styles.dropTargetTop]: hasTopBorder,
})}
align="center"
justify="space-between"
gap="var(--cpd-space-2x)"
onClick={() => !isDragSource && vm.onClick()}
onKeyDown={(e) => {
if ((e.code === "ArrowRight" && !isExpanded) || (e.code === "ArrowLeft" && isExpanded)) {
e.preventDefault();
e.stopPropagation();
vm.onClick();
} else if (e.code === "ArrowRight" && isExpanded && roomCountInSection > 0) {
// Move focus to the first room in the section
e.preventDefault();
e.stopPropagation();
e.currentTarget.dispatchEvent(
new KeyboardEvent("keydown", {
code: "ArrowDown",
key: "ArrowDown",
bubbles: true,
}),
);
}
}}
aria-expanded={ariaExpanded}
onFocus={(e) => onFocus(id, e)}
tabIndex={isFocused ? 0 : -1}
aria-label={
isUnread
? _t("room_list|section_header|toggle_unread", { section: title })
: _t("room_list|section_header|toggle", { section: title })
}
>
<Flex align="center" gap="var(--cpd-space-0-5x)">
<ChevronRightIcon
className={styles.chevron}
width="24px"
height="24px"
fill="var(--cpd-color-icon-secondary)"
/>
<span className={styles.title}>{title}</span>
</Flex>
{!isExpanded && notification && (
<div className={styles.notificationDecoration} aria-hidden={true}>
<NotificationDecoration {...notification} />
</div>
)}
{displaySectionMenu && <MenuComponent vm={vm} />}
</Flex>
</button>
<RoomListSectionHeaderContent vm={vm} />
</button>
</div>
</div>
);
});
interface MenuComponentProps {
vm: RoomListSectionHeaderViewModel;
}
/**
*
* Menu component for the section header.
*/
function MenuComponent({ vm }: MenuComponentProps): JSX.Element {
const [open, setOpen] = useState(false);
return (
<Menu
open={open}
onOpenChange={setOpen}
title={_t("room_list|section_header|more_options")}
showTitle={false}
align="start"
trigger={
<IconButton
className={styles.menu}
tooltip={_t("room_list|section_header|more_options")}
aria-label={_t("room_list|section_header|more_options")}
size="24px"
style={{ padding: "2px" }}
color="var(--cpd-color-icon-primary)"
>
<OverflowHorizontalIcon fill="var(--cpd-color-icon-primary)" />
</IconButton>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
// We don't want keyboard navigation events to bubble up to the ListView changing the focused item
onKeyDown={(e) => e.stopPropagation()}
>
<MenuItem
hideChevron={true}
Icon={EditIcon}
label={_t("room_list|section_header|edit_section")}
onSelect={() => vm.editSection()}
onClick={(evt) => evt.stopPropagation()}
/>
<MenuItem
hideChevron={true}
Icon={DeleteIcon}
label={_t("room_list|section_header|remove_section")}
onSelect={() => vm.removeSection()}
onClick={(evt) => evt.stopPropagation()}
/>
</div>
</Menu>
);
}
@@ -14,75 +14,79 @@ exports[`<RoomListSectionHeaderView /> stories > renders Default story 1`] = `
aria-setsize="5"
role="row"
>
<button
<div
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header"
role="gridcell"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
<button
aria-expanded="true"
aria-label="Toggle Favourites section"
class="RoomListSectionHeaderView-module_header"
tabindex="-1"
type="button"
>
<div
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More options"
aria-labelledby="react-use-id-1"
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
color="var(--cpd-color-icon-primary)"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-2"
role="button"
style="--cpd-icon-button-size: 24px; padding: 2px;"
tabindex="0"
type="button"
class="Flex-module_flex RoomListSectionHeaderView-module_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
class="Flex-module_flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-0-5x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-primary)"
height="1em"
class="RoomListSectionHeaderView-module_chevron"
fill="var(--cpd-color-icon-secondary)"
height="24px"
viewBox="0 0 24 24"
width="1em"
width="24px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
<span
class="RoomListSectionHeaderView-module_title"
>
Favourites
</span>
</div>
</button>
</div>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="More options"
aria-labelledby="react-use-id-1"
class="_icon-button_1215g_8 RoomListSectionHeaderView-module_menu"
color="var(--cpd-color-icon-primary)"
data-kind="primary"
data-state="closed"
id="radix-react-use-id-2"
role="button"
style="--cpd-icon-button-size: 24px; padding: 2px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="var(--cpd-color-icon-primary)"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
</button>
</div>
</div>
</div>
</div>
@@ -6,6 +6,8 @@
*/
export { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
export { RoomListSectionHeaderContent } from "./RoomListSectionHeaderContent";
export type { RoomListSectionHeaderContentProps } from "./RoomListSectionHeaderContent";
export type {
RoomListSectionHeaderViewModel,
RoomListSectionHeaderViewSnapshot,
@@ -37,6 +37,9 @@ const RoomListWrapperImpl = ({
closeToast,
renderAvatar: renderAvatarProp,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
...rest
}: RoomListStoryProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
@@ -48,6 +51,9 @@ const RoomListWrapperImpl = ({
updateVisibleRooms,
closeToast,
changeRoomSection,
changeSectionOrder,
onSectionDragStart,
onSectionDragEnd,
});
return (
@@ -88,6 +94,9 @@ const meta = {
isFlatList: true,
closeToast: fn(),
changeRoomSection: fn(),
changeSectionOrder: fn(),
onSectionDragStart: fn(),
onSectionDragEnd: fn(),
},
parameters: {
design: {
@@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import * as stories from "./VirtualizedRoomListView.stories";
import { KEYBOARD_DRAG_OFFSET } from "./VirtualizedRoomListView";
const { Default, Sections } = composeStories(stories);
@@ -69,15 +70,19 @@ describe("<VirtualizedRoomListView />", () => {
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.
// reach them, so explicitly reset call history for the spies under test.
(Sections.args.changeRoomSection as any).mockClear?.();
(Sections.args.changeSectionOrder as any).mockClear?.();
(Sections.args.onSectionDragStart as any).mockClear?.();
(Sections.args.onSectionDragEnd 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.
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~150px down from "General"
// (room 0) so the drag position enters the target section header's droppable area;
// derive the keypress count from the offset so this stays correct if the offset changes.
const presses = Math.round(150 / KEYBOARD_DRAG_OFFSET);
const user = userEvent.setup();
renderWithMockContext(<Sections />);
@@ -86,8 +91,8 @@ describe("<VirtualizedRoomListView />", () => {
await user.keyboard(" "); // start drag
for (let i = 0; i < 15; i++) {
await user.keyboard("{ArrowDown}"); // move down 10px per press
for (let i = 0; i < presses; i++) {
await user.keyboard("{ArrowDown}");
}
await user.keyboard(" "); // drop onto current target
@@ -96,6 +101,79 @@ describe("<VirtualizedRoomListView />", () => {
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
});
});
it("does not reflect aria-pressed onto draggable room items or section headers", async () => {
// dnd-kit's built-in Accessibility plugin reflects aria-pressed onto the draggable
// <button>, which VoiceOver reads as "selected" when a keyboard drag starts. We drop
// that plugin, so the attribute must never appear (before or during a drag).
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const roomButton = await screen.findByRole("button", { name: "Open room General" });
const sectionHeader = await screen.findByLabelText("Toggle Favourites section");
expect(roomButton).not.toHaveAttribute("aria-pressed");
expect(sectionHeader).not.toHaveAttribute("aria-pressed");
roomButton.focus();
await user.keyboard(" "); // start drag
expect(roomButton).not.toHaveAttribute("aria-pressed");
await user.keyboard("{Escape}"); // cancel drag
});
it("announces drag progress in a live region", async () => {
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const status = screen.getByRole("status");
expect(status).toHaveTextContent("");
const roomButton = await screen.findByRole("button", { name: "Open room General" });
roomButton.focus();
await user.keyboard(" "); // start drag
await waitFor(() => expect(status).toHaveTextContent("Dragging General"));
await user.keyboard("{Escape}"); // cancel
});
it("exposes keyboard drag instructions referenced by draggable items", async () => {
renderWithMockContext(<Sections />);
// The plugin creates a hidden instructions element and wires draggables to it.
const instructions = screen.getByText(
"Press space to start or to stop dragging, arrow keys to move, and escape to cancel.",
);
const roomButton = await screen.findByRole("button", { name: "Open room General" });
await waitFor(() => expect(roomButton).toHaveAttribute("aria-describedby", instructions.id));
});
it("should reorder sections via keyboard", async () => {
// KeyboardSensor: Space=start, each ArrowDown moves the drag position by
// KEYBOARD_DRAG_OFFSET px, Space=drop. We need to travel ~200px down from the
// "Favourites" section header to land on the "low-priority" section header — a valid
// section reorder; derive the keypress count from the offset so this stays correct
// if the offset changes.
const presses = Math.round(200 / KEYBOARD_DRAG_OFFSET);
const user = userEvent.setup();
renderWithMockContext(<Sections />);
const favouritesHeader = await screen.findByLabelText("Toggle Favourites section");
favouritesHeader.focus();
await user.keyboard(" "); // start drag
for (let i = 0; i < presses; i++) {
await user.keyboard("{ArrowDown}");
}
await user.keyboard(" "); // drop
await waitFor(() => {
expect(Sections.args.changeSectionOrder).toHaveBeenCalledWith("favourites", "low-priority");
});
expect(Sections.args.onSectionDragStart).toHaveBeenCalled();
expect(Sections.args.onSectionDragEnd).toHaveBeenCalled();
});
});
describe("scrollToSectionTag", () => {
@@ -13,7 +13,6 @@ import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dn
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
import { useViewModel } from "../../core/viewmodel";
import { _t } from "../../core/i18n/i18n";
import {
FlatVirtualizedList,
getContainerAccessibleProps,
@@ -22,9 +21,13 @@ import {
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
import { RoomListSectionHeaderDragOverlayView } from "./RoomListSectionHeaderDragOverlayView";
import { RoomListItemWrapper } from "./RoomListItemWrapper";
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
import { isSectionDragData, type RoomListDragData } from "./dragAndDrop";
import { useRoomListAccessibilityPlugin } from "./RoomListAccessibilityPlugin";
import styles from "./VirtualizedRoomListView.module.css";
import { useI18n } from "../../core/i18n/i18nContext";
/**
* Filter key type - opaque string type for filter identifiers
@@ -69,6 +72,11 @@ export interface VirtualizedRoomListViewProps {
/** Height of a single room list item in pixels (44px item + 8px padding bottom) */
const ROOM_LIST_ITEM_HEIGHT = 52;
/**
* Number of pixels the keyboard sensor moves the dragged element per arrow keypress.
*/
export const KEYBOARD_DRAG_OFFSET = 17;
/**
* Type for context used in ListView
*/
@@ -112,6 +120,7 @@ const EXTENDED_VIEWPORT_HEIGHT = 25 * ROOM_LIST_ITEM_HEIGHT;
* ```
*/
export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: VirtualizedRoomListViewProps): JSX.Element {
const { translate: _t } = useI18n();
const snapshot = useViewModel(vm);
const { roomListState, sections, isFlatList } = snapshot;
const activeRoomIndex = roomListState.activeRoomIndex;
@@ -147,6 +156,10 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
[vm],
);
// Builds the accessibility plugin (live-region announcements) for keyboard/pointer drags,
// replacing dnd-kit's built-in Accessibility plugin.
const a11yPlugins = useRoomListAccessibilityPlugin(vm);
/**
* Get the item component for a specific index
* Gets the room's view model and passes it to RoomListItemView
@@ -388,13 +401,25 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
}
return (
<DragDropProvider
<DragDropProvider<RoomListDragData>
onDragStart={(event) => {
const { source } = event.operation;
// Changing the state of sections (collapsed/expanded) while dragging a section header causes a double readback for the a11y announcement.
if (isSectionDragData(source?.data)) {
vm.onSectionDragStart();
}
}}
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);
const { source, target } = event.operation;
if (isSectionDragData(source?.data)) {
vm.onSectionDragEnd();
}
if (event.canceled || !source || !target) return;
if (isSectionDragData(source.data)) {
vm.changeSectionOrder(String(source.id), String(target.id));
} else {
vm.changeRoomSection(String(source.id), String(target.id));
}
}}
sensors={[
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
@@ -404,6 +429,8 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
}),
// 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({
// The default 10px-per-keypress offset makes keyboard dragging feel sluggish.
offset: KEYBOARD_DRAG_OFFSET,
keyboardCodes: {
start: ["Space"],
cancel: ["Escape"],
@@ -415,6 +442,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
},
}),
]}
plugins={a11yPlugins}
>
<DragOverlay dropAnimation={null}>
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
@@ -439,7 +467,7 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
*/
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
const { source } = useDragOperation();
const { source } = useDragOperation<RoomListDragData>();
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
}
@@ -455,10 +483,15 @@ interface DragOverlayContentProps {
* 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();
const { source } = useDragOperation<RoomListDragData>();
if (!source) return null;
const itemVm = vm.getRoomItemViewModel(source.id as string);
if (isSectionDragData(source.data)) {
const sectionHeaderVM = vm.getSectionHeaderViewModel(String(source.id));
return <RoomListSectionHeaderDragOverlayView vm={sectionHeaderVM} />;
}
const itemVm = vm.getRoomItemViewModel(String(source.id));
if (!itemVm) return null;
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
@@ -0,0 +1,20 @@
/*
* 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.
*/
/** Drag payload for a section header. */
export type SectionDragData = { type: "section"; index: number };
/** Drag payload for a room item. */
export type RoomDragData = { type: "room" };
/** Discriminated union of all drag payloads in the room list. */
export type RoomListDragData = SectionDragData | RoomDragData;
/**
* Type guard: true when the drag source is a section header. Narrows to {@link SectionDragData}.
*/
export function isSectionDragData(data: RoomListDragData | undefined): data is SectionDragData {
return data?.type === "section";
}
@@ -151,6 +151,9 @@ export const createGetSectionHeaderViewModel = (
id: sectionId,
title: sectionId[0].toUpperCase() + sectionId.slice(1),
isExpanded: true,
isUnread: false,
displaySectionMenu: false,
canBeReordered: true,
};
const vm = new MockViewModel(snapshot) as unknown as RoomListSectionHeaderViewModel;
Object.assign(vm, {
@@ -117,6 +117,7 @@ export default defineConfig({
"vite-plugin-node-polyfills/shims/process",
"@vector-im/compound-design-tokens/assets/web/icons",
"storybook/preview-api",
"@dnd-kit/abstract",
],
},
resolve: {