mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-07 05:06:38 +02:00
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:
parent
1ae6478d2b
commit
003debb97e
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user