Skip to content

New room list: filter list can be collapsed #29992

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,21 @@ test.describe("Room list filters and sort", () => {
});

function getPrimaryFilters(page: Page): Locator {
return page.getByRole("listbox", { name: "Room list filters" });
return page.getByTestId("primary-filters");
}

function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}

function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}

function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}

/**
* Get the room list
* @param page
Expand Down Expand Up @@ -223,10 +231,6 @@ test.describe("Room list filters and sort", () => {
expect(await roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");

await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);

await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
Expand All @@ -240,13 +244,22 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5);

await getFilterExpandButton(page).click();

await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);

await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);

await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);

await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
});

test(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 55 additions & 3 deletions res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,59 @@
*/

.mx_RoomListPrimaryFilters {
margin: unset;
list-style-type: none;
padding: var(--cpd-space-2x) var(--cpd-space-3x);
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);

.mx_RoomListPrimaryFilters_container {
/**
* Set (in fr unit) at every resize of this container.
*/
--row-height: 30px;

overflow: hidden;
/**
* Using grid to animate the height of the container.
*/
display: grid;
grid-template-rows: var(--row-height);
transition: 0.1s ease-in-out;

&[data-expanded="true"] {
grid-template-rows: 1fr;
}
}

.mx_RoomListPrimaryFilters_animated {
/**
* Required to make the collapse work
*/
min-height: 0;
}

ul {
margin: unset;
padding: unset;
list-style-type: none;
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;

li {
height: 30px;
}
}

.mx_RoomListPrimaryFilters_IconButton {
background-color: var(--cpd-color-bg-subtle-secondary);

svg {
transition: transform 0.1s linear;
}
}

.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
svg {
transform: rotate(180deg);
}
}
}
204 changes: 185 additions & 19 deletions src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX } from "react";
import { ChatFilter } from "@vector-im/compound-web";
import React, { type JSX, useEffect, useId, useRef, useState, type RefObject } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";

import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { useIsNodeVisible } from "../../../../hooks/useIsNodeVisible";

const FILTER_HEIGHT = 30;

interface RoomListPrimaryFiltersProps {
/**
Expand All @@ -23,23 +27,185 @@ interface RoomListPrimaryFiltersProps {
* The primary filters for the room list
*/
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);

// threshold: 0.5 means that the filter is considered visible if at least 50% of it is visible
// this value is arbitrary, we want we to have a bit of flexibility
const { isVisible, rootRef, nodeRef } = useIsNodeVisible<HTMLLIElement, HTMLUListElement>({ threshold: 0.5 });
const { filters, onFilterChange } = useFilters(vm.primaryFilters, isExpanded, isVisible);

const { ref: containerRef, isExpanded: isSafeExpanded } = useAnimateFilter<HTMLDivElement>(isExpanded);
const { ref, isOverflowing: displayChevron } = useIsFilterOverflowing<HTMLUListElement>();

return (
<Flex
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
className="mx_RoomListPrimaryFilters"
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
>
{vm.primaryFilters.map((filter) => (
<li role="option" aria-selected={filter.active} key={filter.name}>
<ChatFilter selected={filter.active} onClick={filter.toggle}>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
<div className="mx_RoomListPrimaryFilters" data-testid="primary-filters">
<div
ref={containerRef}
className="mx_RoomListPrimaryFilters_container"
data-expanded={isSafeExpanded}
data-testid="filter-container"
>
<Flex id={id} className="mx_RoomListPrimaryFilters_animated" gap="var(--cpd-space-3x)">
<Flex
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
ref={(node: HTMLUListElement) => {
rootRef(node);
// due to https://github.com/facebook/react/issues/29196
// eslint-disable-next-line react-compiler/react-compiler
ref.current = node;
}}
>
{filters.map((filter) => (
<li
ref={filter.active ? nodeRef : undefined}
role="option"
aria-selected={filter.active}
key={filter.name}
>
<ChatFilter
selected={filter.active}
onClick={() => {
onFilterChange();
filter.toggle();
}}
>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
{displayChevron && (
<IconButton
aria-expanded={isSafeExpanded}
aria-controls={id}
className="mx_RoomListPrimaryFilters_IconButton"
aria-label={
isSafeExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")
}
size="28px"
onClick={() => setIsExpanded((_expanded) => !_expanded)}
>
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
)}
</Flex>
</div>
</div>
);
}

/**
* A hook to sort the filters by active state.
* The list is sorted if the current filter is not visible when the list is unexpanded.
*
* @param filters - the list of filters to sort.
* @param isExpanded - the filter is expanded or not (fully visible).
* @param isVisible - `null` if there is not selected filter. `true` or `false` if the filter is visible or not.
*/
function useFilters(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this in the view and not the vm?

Copy link
Member Author

@florianduros florianduros May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put it in the view because it depends of two view attributes: isExpanded and isVisible where isVisible is the result of computation on the view

filters: RoomListViewState["primaryFilters"],
isExpanded: boolean,
isVisible: boolean | null,
): {
/**
* The new list of filters.
*/
filters: RoomListViewState["primaryFilters"];
/**
* Reset the filter sorting when called.
*/
onFilterChange: () => void;
} {
// By default, the filters are not sorted
const [filterState, setFilterState] = useState({ filters, isSorted: false });

useEffect(() => {
// If there is no current filter (isVisible is null)
// or if the filter list is fully visible (isExpanded is true)
// or if the current filter is visible and the list isn't sorted
// then we don't need to sort the filters
if (isVisible === null || isExpanded || (isVisible && !filterState.isSorted)) {
setFilterState({ filters, isSorted: false });
return;
}

// Sort the filters with the current filter at first position
setFilterState({
filters: filters.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA.active && !filterB.active) return -1;
if (!filterA.active && filterB.active) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
isSorted: true,
});
}, [filters, isVisible, filterState.isSorted, isExpanded]);

const onFilterChange = (): void => {
// Reset the filter sorting
setFilterState({ filters, isSorted: false });
};
return { filters: filterState.filters, onFilterChange };
}

/**
* A hook to animate the filter list when it is expanded or not.
* @param areFiltersExpanded
*/
function useAnimateFilter<T extends HTMLElement>(
areFiltersExpanded: boolean,
): { ref: RefObject<T | null>; isExpanded: boolean } {
const ref = useRef<T | null>(null);
useEffect(() => {
if (!ref.current) return;

const observer = new ResizeObserver(() => {
// Remove transition to avoid the animation to run when the new --row-height is not set yet
// If the animation runs at this moment, the first row will jump
ref.current?.style.setProperty("transition", "unset");
// For the animation to work, we need `grid-template-rows` to have the same unit at the beginning and the end
// If px is used at the beginning, we need to use px at the end.
// In our case, we use fr unit to fully grow when expanded (1fr) so we need to compute the value in fr when the filters are not expanded
ref.current?.style.setProperty("--row-height", `${FILTER_HEIGHT / ref?.current.scrollHeight}fr`);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref]);

// Put back the transition to the element when the expanded state changes
// because we want to animate it
const [isExpanded, setExpanded] = useState(areFiltersExpanded);
useEffect(() => {
ref.current?.style.setProperty("transition", "0.1s ease-in-out");
setExpanded(areFiltersExpanded);
}, [areFiltersExpanded, ref]);

return { ref, isExpanded };
}

/**
* A hook to check if the filter list is overflowing.
* The list is overflowing if the scrollHeight is greater than `FILTER_HEIGHT`.
*/
function useIsFilterOverflowing<T extends HTMLElement>(): { ref: RefObject<T | undefined>; isOverflowing: boolean } {
const ref = useRef<T>(undefined);
const [isOverflowing, setIsOverflowing] = useState(false);

useEffect(() => {
if (!ref.current) return;

const node = ref.current;
const observer = new ResizeObserver(() => setIsOverflowing(node.scrollHeight > FILTER_HEIGHT));
observer.observe(node);
return () => observer.disconnect();
}, [ref]);

return { ref, isOverflowing };
}
Loading
Loading