Skip to content

Commit e5ab8b8

Browse files
committed
refactor: move hooks to dedicated files
1 parent 5b8622a commit e5ab8b8

5 files changed

Lines changed: 190 additions & 149 deletions

File tree

src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx

Lines changed: 6 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

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

1212
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
1313
import { Flex } from "../../../utils/Flex";
1414
import { _t } from "../../../../languageHandler";
1515
import { useIsNodeVisible } from "../../../../hooks/useIsNodeVisible";
16+
import { useFilterHeight } from "../../../../hooks/left-panel/room-list/useFilterHeight";
17+
import { useIsFilterOverflowing } from "../../../../hooks/left-panel/room-list/useIsFilterOverflowing";
18+
import { useAnimateFilter } from "../../../../hooks/left-panel/room-list/useAnimateFilter";
19+
import { useVisibleFilters } from "../../../../hooks/left-panel/room-list/useVisibleFilters";
1620

1721
interface RoomListPrimaryFiltersProps {
1822
/**
@@ -31,7 +35,7 @@ export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX
3135
// threshold: 0.5 means that the filter is considered visible if at least 50% of it is visible
3236
// this value is arbitrary, we want we to have a bit of flexibility
3337
const { isVisible, rootRef, nodeRef } = useIsNodeVisible<HTMLLIElement, HTMLUListElement>({ threshold: 0.5 });
34-
const { filters, onFilterChange } = useFilters(vm.primaryFilters, isExpanded, isVisible);
38+
const { filters, onFilterChange } = useVisibleFilters(vm.primaryFilters, isExpanded, isVisible);
3539

3640
const { filterHeight, filterRef } = useFilterHeight<HTMLButtonElement>();
3741
const { ref: containerRef, isExpanded: isSafeExpanded } = useAnimateFilter<HTMLDivElement>(
@@ -102,150 +106,3 @@ export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX
102106
</div>
103107
);
104108
}
105-
106-
/**
107-
* A hook to sort the filters by active state.
108-
* The list is sorted if the current filter is not visible when the list is unexpanded.
109-
*
110-
* @param filters - the list of filters to sort.
111-
* @param isExpanded - the filter is expanded or not (fully visible).
112-
* @param isVisible - `null` if there is not selected filter. `true` or `false` if the filter is visible or not.
113-
*/
114-
function useFilters(
115-
filters: RoomListViewState["primaryFilters"],
116-
isExpanded: boolean,
117-
isVisible: boolean | null,
118-
): {
119-
/**
120-
* The new list of filters.
121-
*/
122-
filters: RoomListViewState["primaryFilters"];
123-
/**
124-
* Reset the filter sorting when called.
125-
*/
126-
onFilterChange: () => void;
127-
} {
128-
// By default, the filters are not sorted
129-
const [filterState, setFilterState] = useState({ filters, isSorted: false });
130-
131-
useEffect(() => {
132-
// If there is no current filter (isVisible is null)
133-
// or if the filter list is fully visible (isExpanded is true)
134-
// or if the current filter is visible and the list isn't sorted
135-
// then we don't need to sort the filters
136-
if (isVisible === null || isExpanded || (isVisible && !filterState.isSorted)) {
137-
setFilterState({ filters, isSorted: false });
138-
return;
139-
}
140-
141-
// Sort the filters with the current filter at first position
142-
setFilterState({
143-
filters: filters.slice().sort((filterA, filterB) => {
144-
// If the filter is active, it should be at the top of the list
145-
if (filterA.active && !filterB.active) return -1;
146-
if (!filterA.active && filterB.active) return 1;
147-
// If both filters are active or not, keep their original order
148-
return 0;
149-
}),
150-
isSorted: true,
151-
});
152-
}, [filters, isVisible, filterState.isSorted, isExpanded]);
153-
154-
const onFilterChange = (): void => {
155-
// Reset the filter sorting
156-
setFilterState({ filters, isSorted: false });
157-
};
158-
return { filters: filterState.filters, onFilterChange };
159-
}
160-
161-
/**
162-
* A hook to animate the filter list when it is expanded or not.
163-
* @param areFiltersExpanded
164-
* @param filterHeight
165-
*/
166-
function useAnimateFilter<T extends HTMLElement>(
167-
areFiltersExpanded: boolean,
168-
filterHeight: number,
169-
): { ref: RefObject<T | null>; isExpanded: boolean } {
170-
const ref = useRef<T | null>(null);
171-
useEffect(() => {
172-
if (!ref.current) return;
173-
174-
// Round to 2 decimal places and convert to integer to avoid floating point precision issues
175-
const floor = (a: number): number => Math.floor(a * 100) / 100 || 0;
176-
// For the animation to work, we need `grid-template-rows` to have the same unit at the beginning and the end
177-
// If px is used at the beginning, we need to use px at the end.
178-
// 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
179-
const setRowHeight = (): void =>
180-
ref.current?.style.setProperty("--row-height", `${floor(filterHeight / ref?.current.scrollHeight)}fr`);
181-
setRowHeight();
182-
183-
const observer = new ResizeObserver(() => {
184-
// Remove transition to avoid the animation to run when the new --row-height is not set yet
185-
// If the animation runs at this moment, the first row will jump
186-
ref.current?.style.setProperty("transition", "unset");
187-
setRowHeight();
188-
});
189-
observer.observe(ref.current);
190-
return () => observer.disconnect();
191-
}, [ref, filterHeight]);
192-
193-
// Put back the transition to the element when the expanded state changes
194-
// because we want to animate it
195-
const [isExpanded, setExpanded] = useState(areFiltersExpanded);
196-
useEffect(() => {
197-
ref.current?.style.setProperty("transition", "0.1s ease-in-out");
198-
setExpanded(areFiltersExpanded);
199-
}, [areFiltersExpanded, ref]);
200-
201-
return { ref, isExpanded };
202-
}
203-
204-
/**
205-
* A hook to check if the filter list is overflowing.
206-
* The list is overflowing if the scrollHeight is greater than `FILTER_HEIGHT`.
207-
*/
208-
function useIsFilterOverflowing<T extends HTMLElement>(
209-
filterHeight: number,
210-
): { ref: RefObject<T | undefined>; isOverflowing: boolean } {
211-
const ref = useRef<T>(undefined);
212-
const [isOverflowing, setIsOverflowing] = useState(false);
213-
214-
useEffect(() => {
215-
if (!ref.current) return;
216-
217-
const node = ref.current;
218-
const observer = new ResizeObserver(() => setIsOverflowing(node.scrollHeight > filterHeight));
219-
observer.observe(node);
220-
return () => observer.disconnect();
221-
}, [ref, filterHeight]);
222-
223-
return { ref, isOverflowing };
224-
}
225-
226-
/**
227-
* A hook to get the height of the filter list.
228-
* @returns a ref that should be put on the filter button and its height.
229-
*/
230-
function useFilterHeight<T extends HTMLElement>(): { filterHeight: number; filterRef: RefObject<T | null> } {
231-
const [filterHeight, setFilterHeight] = useState(0);
232-
const filterRef = useRef<T>(null);
233-
234-
useEffect(() => {
235-
if (!filterRef.current) return;
236-
237-
const setHeight = () => {
238-
const height = filterRef.current?.offsetHeight;
239-
if (height) setFilterHeight(height);
240-
};
241-
242-
setHeight();
243-
const observer = new ResizeObserver(() => {
244-
setHeight();
245-
});
246-
observer.observe(filterRef.current);
247-
return () => observer.disconnect();
248-
}, [filterRef]);
249-
250-
return { filterHeight, filterRef };
251-
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useEffect, useRef, useState, type RefObject } from "react";
9+
10+
/**
11+
* A hook to animate the filter list when it is expanded or not.
12+
* @param areFiltersExpanded
13+
* @param filterHeight
14+
*/
15+
export function useAnimateFilter<T extends HTMLElement>(
16+
areFiltersExpanded: boolean,
17+
filterHeight: number,
18+
): { ref: RefObject<T | null>; isExpanded: boolean } {
19+
const ref = useRef<T | null>(null);
20+
useEffect(() => {
21+
if (!ref.current) return;
22+
23+
// Round to 2 decimal places to avoid floating point precision issues
24+
const floor = (a: number): number => Math.floor(a * 100) / 100;
25+
// For the animation to work, we need `grid-template-rows` to have the same unit at the beginning and the end
26+
// If px is used at the beginning, we need to use px at the end.
27+
// 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
28+
const setRowHeight = (): void =>
29+
ref.current?.style.setProperty(
30+
"--row-height",
31+
`${floor(filterHeight / (ref?.current.scrollHeight || 1))}fr`,
32+
);
33+
setRowHeight();
34+
35+
const observer = new ResizeObserver(() => {
36+
// Remove transition to avoid the animation to run when the new --row-height is not set yet
37+
// If the animation runs at this moment, the first row will jump
38+
ref.current?.style.setProperty("transition", "unset");
39+
setRowHeight();
40+
});
41+
observer.observe(ref.current);
42+
return () => observer.disconnect();
43+
}, [ref, filterHeight]);
44+
45+
// Put back the transition to the element when the expanded state changes
46+
// because we want to animate it
47+
const [isExpanded, setExpanded] = useState(areFiltersExpanded);
48+
useEffect(() => {
49+
ref.current?.style.setProperty("transition", "0.1s ease-in-out");
50+
setExpanded(areFiltersExpanded);
51+
}, [areFiltersExpanded, ref]);
52+
53+
return { ref, isExpanded };
54+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useEffect, useRef, useState, type RefObject } from "react";
9+
10+
/**
11+
* A hook to get the height of the filter list.
12+
* @returns a ref that should be put on the filter button and its height.
13+
*/
14+
export function useFilterHeight<T extends HTMLElement>(): { filterHeight: number; filterRef: RefObject<T | null> } {
15+
const [filterHeight, setFilterHeight] = useState(0);
16+
const filterRef = useRef<T>(null);
17+
18+
useEffect(() => {
19+
if (!filterRef.current) return;
20+
21+
const setHeight = () => {
22+
const height = filterRef.current?.offsetHeight;
23+
if (height) setFilterHeight(height);
24+
};
25+
26+
setHeight();
27+
const observer = new ResizeObserver(() => {
28+
setHeight();
29+
});
30+
observer.observe(filterRef.current);
31+
return () => observer.disconnect();
32+
}, [filterRef]);
33+
34+
return { filterHeight, filterRef };
35+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useEffect, useRef, type RefObject, useState } from "react";
9+
10+
/**
11+
* A hook to check if the filter list is overflowing.
12+
* The list is overflowing if the scrollHeight is greater than `FILTER_HEIGHT`.
13+
*/
14+
export function useIsFilterOverflowing<T extends HTMLElement>(
15+
filterHeight: number,
16+
): { ref: RefObject<T | undefined>; isOverflowing: boolean } {
17+
const ref = useRef<T>(undefined);
18+
const [isOverflowing, setIsOverflowing] = useState(false);
19+
20+
useEffect(() => {
21+
if (!ref.current) return;
22+
23+
const node = ref.current;
24+
const observer = new ResizeObserver(() => setIsOverflowing(node.scrollHeight > filterHeight));
25+
observer.observe(node);
26+
return () => observer.disconnect();
27+
}, [ref, filterHeight]);
28+
29+
return { ref, isOverflowing };
30+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useEffect, useState } from "react";
9+
10+
import { type RoomListViewState } from "../../../components/viewmodels/roomlist/RoomListViewModel";
11+
12+
/**
13+
* A hook to sort the filters by active state.
14+
* The list is sorted if the current filter is not visible when the list is unexpanded.
15+
*
16+
* @param filters - the list of filters to sort.
17+
* @param isExpanded - the filter is expanded or not (fully visible).
18+
* @param isVisible - `null` if there is not selected filter. `true` or `false` if the filter is visible or not.
19+
*/
20+
export function useVisibleFilters(
21+
filters: RoomListViewState["primaryFilters"],
22+
isExpanded: boolean,
23+
isVisible: boolean | null,
24+
): {
25+
/**
26+
* The new list of filters.
27+
*/
28+
filters: RoomListViewState["primaryFilters"];
29+
/**
30+
* Reset the filter sorting when called.
31+
*/
32+
onFilterChange: () => void;
33+
} {
34+
// By default, the filters are not sorted
35+
const [filterState, setFilterState] = useState({ filters, isSorted: false });
36+
37+
useEffect(() => {
38+
// If there is no current filter (isVisible is null)
39+
// or if the filter list is fully visible (isExpanded is true)
40+
// or if the current filter is visible and the list isn't sorted
41+
// then we don't need to sort the filters
42+
if (isVisible === null || isExpanded || (isVisible && !filterState.isSorted)) {
43+
setFilterState({ filters, isSorted: false });
44+
return;
45+
}
46+
47+
// Sort the filters with the current filter at first position
48+
setFilterState({
49+
filters: filters.slice().sort((filterA, filterB) => {
50+
// If the filter is active, it should be at the top of the list
51+
if (filterA.active && !filterB.active) return -1;
52+
if (!filterA.active && filterB.active) return 1;
53+
// If both filters are active or not, keep their original order
54+
return 0;
55+
}),
56+
isSorted: true,
57+
});
58+
}, [filters, isVisible, filterState.isSorted, isExpanded]);
59+
60+
const onFilterChange = (): void => {
61+
// Reset the filter sorting
62+
setFilterState({ filters, isSorted: false });
63+
};
64+
return { filters: filterState.filters, onFilterChange };
65+
}

0 commit comments

Comments
 (0)