Add RoomListPrimaryFilters component

Add filter chips component for filtering the room list by
unread, people, rooms, favourites, mentions, invites, and low priority.
This commit is contained in:
David Langley 2026-01-30 09:43:03 +00:00
parent 1ae6478d2b
commit 003debb97e
13 changed files with 899 additions and 0 deletions

View File

@ -0,0 +1,32 @@
/*
* Copyright 2025 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.
*/
.roomListPrimaryFilters {
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
}
/* Hide filters that are wrapping when collapsed */
.roomListPrimaryFilters :global(.wrapping) {
display: none;
}
.list {
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;
flex: 1;
}
/* IconButton styles for chevron */
.iconButton svg {
transition: transform 0.1s linear;
}
.iconButton[aria-expanded="true"] svg {
transform: rotate(180deg);
}

View File

@ -0,0 +1,85 @@
/*
* 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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
import type { FilterId } from "./useVisibleFilters";
const meta: Meta<typeof RoomListPrimaryFilters> = {
title: "Room List/RoomListPrimaryFilters",
component: RoomListPrimaryFilters,
tags: ["autodocs"],
args: {
onToggleFilter: fn(),
},
};
export default meta;
type Story = StoryObj<typeof RoomListPrimaryFilters>;
// All available filter IDs
const allFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite", "mentions", "invites", "low_priority"];
// Subset of filters for narrow container tests
const fewFilterIds: FilterId[] = ["people", "rooms", "unread"];
export const Default: Story = {
args: {
filterIds: allFilterIds,
},
};
export const PeopleSelected: Story = {
args: {
filterIds: allFilterIds,
activeFilterId: "people",
},
};
export const NoFilters: Story = {
args: {
filterIds: [],
},
};
/**
* Narrow container that causes filters to wrap.
* The chevron button should appear to expand/collapse the filter list.
*/
export const NarrowContainer: Story = {
args: {
filterIds: fewFilterIds,
},
decorators: [
(Story) => (
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
<Story />
</div>
),
],
};
/**
* Narrow container with active filter that would wrap.
* When collapsed, the active filter should move to the front.
*/
export const NarrowWithActiveWrappingFilter: Story = {
args: {
filterIds: fewFilterIds,
activeFilterId: "unread",
},
decorators: [
(Story) => (
<div style={{ width: "180px", border: "1px dashed var(--cpd-color-border-interactive-secondary)" }}>
<Story />
</div>
),
],
};

View File

@ -0,0 +1,140 @@
/*
* 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, { act } from "react";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { composeStories } from "@storybook/react-vite";
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as stories from "./RoomListPrimaryFilters.stories";
const { Default, PeopleSelected, NoFilters, NarrowContainer, NarrowWithActiveWrappingFilter } = composeStories(stories);
describe("<RoomListPrimaryFilters /> stories", () => {
describe("snapshots", () => {
it("renders Default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders PeopleSelected story", () => {
const { container } = render(<PeopleSelected />);
expect(container).toMatchSnapshot();
});
it("renders NoFilters story", () => {
const { container } = render(<NoFilters />);
expect(container).toMatchSnapshot();
});
it("renders NarrowContainer story", () => {
const { container } = render(<NarrowContainer />);
expect(container).toMatchSnapshot();
});
it("renders NarrowWithActiveWrappingFilter story", () => {
const { container } = render(<NarrowWithActiveWrappingFilter />);
expect(container).toMatchSnapshot();
});
});
describe("behavior", () => {
it("should call onToggleFilter when a filter is clicked", async () => {
const user = userEvent.setup();
render(<Default />);
await user.click(screen.getByRole("option", { name: "People" }));
expect(Default.args.onToggleFilter).toHaveBeenCalled();
});
});
describe("resize behavior", () => {
let resizeCallback: ResizeObserverCallback;
beforeEach(() => {
globalThis.ResizeObserver = class MockResizeObserver {
public constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
public observe = vi.fn();
public unobserve = vi.fn();
public disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
});
function mockFiltersNotWrapping(): void {
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(60);
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
}
function mockUnreadWrapping(): void {
vi.spyOn(screen.getByText("People"), "offsetLeft", "get").mockReturnValue(0);
vi.spyOn(screen.getByText("Rooms"), "offsetLeft", "get").mockReturnValue(30);
vi.spyOn(screen.getByText("Unreads"), "offsetLeft", "get").mockReturnValue(0);
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
act(() => resizeCallback([{ target: listbox } as any], {} as ResizeObserver));
}
it("should hide wrapping filters and show chevron", () => {
render(<NarrowContainer />);
mockUnreadWrapping();
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
});
it("should expand and collapse filter list with chevron button", async () => {
const user = userEvent.setup();
render(<NarrowContainer />);
mockUnreadWrapping();
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
expect(screen.getByRole("option", { name: "Unreads" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Collapse filter list" }));
expect(screen.queryByRole("option", { name: "Unreads" })).toBeNull();
});
it("should move active filter to front when collapsed and wrapping", () => {
render(<NarrowWithActiveWrappingFilter />);
mockUnreadWrapping();
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "Unreads" }));
});
it("should restore original filter order when expanded", async () => {
const user = userEvent.setup();
render(<NarrowWithActiveWrappingFilter />);
mockUnreadWrapping();
await user.click(screen.getByRole("button", { name: "Expand filter list" }));
const listbox = screen.getByRole("listbox", { name: "Room list filters" });
expect(listbox.children[0]).toBe(screen.getByRole("option", { name: "People" }));
});
it("should handle resize from non-wrapping to wrapping", () => {
render(<NarrowContainer />);
mockFiltersNotWrapping();
expect(screen.queryByRole("button", { name: "Expand filter list" })).toBeNull();
mockUnreadWrapping();
expect(screen.getByRole("button", { name: "Expand filter list" })).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,116 @@
/*
* 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, useId, useState } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import { Flex } from "../../utils/Flex";
import { _t } from "../../utils/i18n";
import { useCollapseFilters } from "./useCollapseFilters";
import { useVisibleFilters, type FilterId } from "./useVisibleFilters";
import styles from "./RoomListPrimaryFilters.module.css";
/**
* Maps filter IDs to translated labels
*/
const filterIdToLabel = (filterId: FilterId): string => {
switch (filterId) {
case "unread":
return _t("room_list|filters|unread");
case "people":
return _t("room_list|filters|people");
case "rooms":
return _t("room_list|filters|rooms");
case "favourite":
return _t("room_list|filters|favourite");
case "mentions":
return _t("room_list|filters|mentions");
case "invites":
return _t("room_list|filters|invites");
case "low_priority":
return _t("room_list|filters|low_priority");
}
};
/**
* Props for RoomListPrimaryFilters component
*/
export interface RoomListPrimaryFiltersProps {
/** Array of filter IDs to display */
filterIds: FilterId[];
/** Currently active filter ID (if any) */
activeFilterId?: FilterId;
/** Callback when a filter is toggled */
onToggleFilter: (filterId: FilterId) => void;
}
/**
* The primary filters component for the room list.
* Displays a collapsible list of filters with expand/collapse functionality.
*/
export const RoomListPrimaryFilters: React.FC<RoomListPrimaryFiltersProps> = ({
filterIds,
activeFilterId,
onToggleFilter,
}): JSX.Element | null => {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const {
ref,
isWrapping: displayChevron,
wrappingIndex,
} = useCollapseFilters<HTMLUListElement>(isExpanded, "wrapping");
const visibleFilterIds = useVisibleFilters(filterIds, activeFilterId, wrappingIndex);
return (
<Flex
className={styles.roomListPrimaryFilters}
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
justify="space-between"
>
{displayChevron && (
<IconButton
kind="secondary"
aria-expanded={isExpanded}
aria-controls={id}
className={styles.iconButton}
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
size="28px"
onClick={() => setIsExpanded((expanded) => !expanded)}
>
<ChevronDownIcon />
</IconButton>
)}
<Flex
id={id}
as="div"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
className={styles.list}
ref={ref}
>
{visibleFilterIds.map((filterId, index) => (
<ChatFilter
key={`${filterId}-${index}`}
role="option"
selected={filterId === activeFilterId}
onClick={() => onToggleFilter(filterId)}
>
{filterIdToLabel(filterId)}
</ChatFilter>
))}
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,388 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders Default story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_0_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_0_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Favourites
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Mentions
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Invites
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Low priority
</button>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowContainer story 1`] = `
<div>
<div
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_3_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_3_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Unreads
</button>
</div>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NarrowWithActiveWrappingFilter story 1`] = `
<div>
<div
style="width: 180px; border: 1px dashed var(--cpd-color-border-interactive-secondary);"
>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_4_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_4_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
</div>
</div>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders NoFilters story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
aria-label="Room list filters"
class="flex list"
id="_r_2_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
/>
</div>
</div>
`;
exports[`<RoomListPrimaryFilters /> stories > snapshots > renders PeopleSelected story 1`] = `
<div>
<div
class="flex roomListPrimaryFilters"
data-testid="primary-filters"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-controls="_r_1_"
aria-expanded="false"
aria-label="Expand filter list"
class="_icon-button_1215g_8 iconButton"
data-kind="secondary"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</button>
<div
aria-label="Room list filters"
class="flex list"
id="_r_1_"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Unreads
</button>
<button
aria-hidden="false"
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
People
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Rooms
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Favourites
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Mentions
</button>
<button
aria-hidden="false"
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="option"
tabindex="0"
>
Invites
</button>
<button
aria-hidden="true"
aria-selected="false"
class="_chat-filter_5qdp0_8 wrapping"
role="option"
tabindex="0"
>
Low priority
</button>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,12 @@
/*
* 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 { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
export type { RoomListPrimaryFiltersProps } from "./RoomListPrimaryFilters";
export { useCollapseFilters } from "./useCollapseFilters";
export { useVisibleFilters } from "./useVisibleFilters";
export type { FilterId } from "./useVisibleFilters";

View File

@ -0,0 +1,71 @@
/*
* 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 { useEffect, useRef, useState, type RefObject } from "react";
/**
* A hook to manage the wrapping of filters in the room list.
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
* @param isExpanded
* @param wrappingClassName - the CSS class to apply to wrapping filters
* @returns an object containing:
* - `ref`: a ref to put on the filter list element
* - `isWrapping`: a boolean indicating if the filters are wrapping
* - `wrappingIndex`: the index of the first filter that is wrapping
*/
export function useCollapseFilters<T extends HTMLElement>(
isExpanded: boolean,
wrappingClassName: string,
): {
ref: RefObject<T | null>;
isWrapping: boolean;
wrappingIndex: number;
} {
const ref = useRef<T>(null);
const [isWrapping, setIsWrapping] = useState(false);
const [wrappingIndex, setWrappingIndex] = useState(-1);
useEffect(() => {
if (!ref.current) return;
const hideFilters = (list: Element): void => {
let isWrapping = false;
Array.from(list.children).forEach((node, i): void => {
const child = node as HTMLElement;
child.setAttribute("aria-hidden", "false");
child.classList.remove(wrappingClassName);
// If the filter list is expanded, all filters are visible
if (isExpanded) return;
// If the previous element is on the left element of the current one, it means that the filter is wrapping
const previousSibling = child.previousElementSibling as HTMLElement | null;
if (previousSibling && child.offsetLeft <= previousSibling.offsetLeft) {
if (!isWrapping) setWrappingIndex(i);
isWrapping = true;
}
// If the filter is wrapping, we hide it
child.classList.toggle(wrappingClassName, isWrapping);
child.setAttribute("aria-hidden", isWrapping.toString());
});
if (!isWrapping) setWrappingIndex(-1);
setIsWrapping(isExpanded || isWrapping);
};
hideFilters(ref.current);
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [isExpanded, wrappingClassName]);
return { ref, isWrapping, wrappingIndex };
}

View File

@ -0,0 +1,55 @@
/*
* 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 { useEffect, useState } from "react";
/**
* Standard filter identifiers that can be used across implementations.
* These are stable keys - the view layer maps them to translated labels.
*/
export type FilterId = "unread" | "people" | "rooms" | "favourite" | "mentions" | "invites" | "low_priority";
/**
* A hook to sort the filter IDs by active state.
* The list is sorted if the active filter index is greater than or equal to the wrapping index.
* If the wrapping index is -1, the filters are not sorted.
*
* @param filterIds - the list of filter IDs to sort.
* @param activeFilterId - the currently active filter ID (if any).
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(
filterIds: FilterId[],
activeFilterId: FilterId | undefined,
wrappingIndex: number,
): FilterId[] {
// By default, the filters are not sorted
const [sortedFilterIds, setSortedFilterIds] = useState(filterIds);
useEffect(() => {
const activeIndex = activeFilterId ? filterIds.indexOf(activeFilterId) : -1;
const isActiveFilterWrapping = activeIndex >= wrappingIndex;
// If the active filter is not wrapping, we don't need to sort the filters
if (!isActiveFilterWrapping || wrappingIndex === -1) {
setSortedFilterIds(filterIds);
return;
}
// Sort the filters with the active filter at first position
setSortedFilterIds(
filterIds.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA === activeFilterId && filterB !== activeFilterId) return -1;
if (filterA !== activeFilterId && filterB === activeFilterId) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
);
}, [filterIds, activeFilterId, wrappingIndex]);
return sortedFilterIds;
}