55 * Please see LICENSE files in the repository root for full details.
66 */
77
8- import React , { type JSX , useId , useState } from "react" ;
8+ import React , { type JSX , useEffect , useId , useRef , useState , type RefObject } from "react" ;
99import { ChatFilter , IconButton } from "@vector-im/compound-web" ;
1010import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down" ;
1111
1212import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel" ;
1313import { Flex } from "../../../utils/Flex" ;
1414import { _t } from "../../../../languageHandler" ;
15- 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" ;
2015
2116interface RoomListPrimaryFiltersProps {
2217 /**
@@ -32,77 +27,138 @@ export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX
3227 const id = useId ( ) ;
3328 const [ isExpanded , setIsExpanded ] = useState ( false ) ;
3429
35- // threshold: 0.5 means that the filter is considered visible if at least 50% of it is visible
36- // this value is arbitrary, we want we to have a bit of flexibility
37- const { isVisible, rootRef, nodeRef } = useIsNodeVisible < HTMLLIElement , HTMLUListElement > ( { threshold : 0.5 } ) ;
38- const { filters, onFilterChange } = useVisibleFilters ( vm . primaryFilters , isExpanded , isVisible ) ;
39-
40- const { filterHeight, filterRef } = useFilterHeight < HTMLButtonElement > ( ) ;
41- const { ref : containerRef , isExpanded : isSafeExpanded } = useAnimateFilter < HTMLDivElement > (
42- isExpanded ,
43- filterHeight ,
44- ) ;
45- const { ref, isOverflowing : displayChevron } = useIsFilterOverflowing < HTMLUListElement > ( filterHeight ) ;
30+ const { ref, isWrapping : displayChevron , wrappingIndex } = useCollapseFilters < HTMLUListElement > ( isExpanded ) ;
31+ const filters = useVisibleFilters ( vm . primaryFilters , wrappingIndex ) ;
4632
4733 return (
48- < div className = "mx_RoomListPrimaryFilters" data-testid = "primary-filters" >
49- < div
50- ref = { containerRef }
51- className = "mx_RoomListPrimaryFilters_container"
52- data-expanded = { isSafeExpanded }
53- data-testid = "filter-container"
34+ < Flex className = "mx_RoomListPrimaryFilters" data-testid = "primary-filters" gap = "var(--cpd-space-3x)" >
35+ < Flex
36+ id = { id }
37+ as = "ul"
38+ role = "listbox"
39+ aria-label = { _t ( "room_list|primary_filters" ) }
40+ align = "center"
41+ gap = "var(--cpd-space-2x)"
42+ wrap = "wrap"
43+ ref = { ref }
5444 >
55- < Flex id = { id } className = "mx_RoomListPrimaryFilters_animated" gap = "var(--cpd-space-3x)" >
56- < Flex
57- as = "ul"
58- role = "listbox"
59- aria-label = { _t ( "room_list|primary_filters" ) }
60- align = "center"
61- gap = "var(--cpd-space-2x)"
62- wrap = "wrap"
63- ref = { ( node : HTMLUListElement ) => {
64- rootRef ( node ) ;
65- // due to https://github.com/facebook/react/issues/29196
66- // eslint-disable-next-line react-compiler/react-compiler
67- ref . current = node ;
68- } }
69- >
70- { filters . map ( ( filter , i ) => (
71- < li
72- ref = { filter . active ? nodeRef : undefined }
73- role = "option"
74- aria-selected = { filter . active }
75- key = { filter . name }
76- >
77- < ChatFilter
78- ref = { i === 0 ? filterRef : undefined }
79- selected = { filter . active }
80- onClick = { ( ) => {
81- onFilterChange ( ) ;
82- filter . toggle ( ) ;
83- } }
84- >
85- { filter . name }
86- </ ChatFilter >
87- </ li >
88- ) ) }
89- </ Flex >
90- { displayChevron && (
91- < IconButton
92- aria-expanded = { isSafeExpanded }
93- aria-controls = { id }
94- className = "mx_RoomListPrimaryFilters_IconButton"
95- aria-label = {
96- isSafeExpanded ? _t ( "room_list|collapse_filters" ) : _t ( "room_list|expand_filters" )
97- }
98- size = "28px"
99- onClick = { ( ) => setIsExpanded ( ( _expanded ) => ! _expanded ) }
100- >
101- < ChevronDownIcon color = "var(--cpd-color-icon-secondary)" />
102- </ IconButton >
103- ) }
104- </ Flex >
105- </ div >
106- </ div >
45+ { filters . map ( ( filter , i ) => (
46+ < li role = "option" aria-selected = { filter . active } key = { i } >
47+ < ChatFilter selected = { filter . active } onClick = { ( ) => filter . toggle ( ) } >
48+ { filter . name }
49+ </ ChatFilter >
50+ </ li >
51+ ) ) }
52+ </ Flex >
53+ { displayChevron && (
54+ < IconButton
55+ subtleBackground = { true }
56+ aria-expanded = { isExpanded }
57+ aria-controls = { id }
58+ className = "mx_RoomListPrimaryFilters_IconButton"
59+ aria-label = { isExpanded ? _t ( "room_list|collapse_filters" ) : _t ( "room_list|expand_filters" ) }
60+ size = "28px"
61+ onClick = { ( ) => setIsExpanded ( ( _expanded ) => ! _expanded ) }
62+ >
63+ < ChevronDownIcon color = "var(--cpd-color-icon-secondary)" />
64+ </ IconButton >
65+ ) }
66+ </ Flex >
10767 ) ;
10868}
69+
70+ /**
71+ * A hook to manage the wrapping of filters in the room list.
72+ * It observes the filter list and hides filters that are wrapping when the list is not expanded.
73+ * @param isExpanded
74+ * @returns an object containing:
75+ * - `ref`: a ref to put on the filter list element
76+ * - `isWrapping`: a boolean indicating if the filters are wrapping
77+ * - `wrappingIndex`: the index of the first filter that is wrapping
78+ */
79+ function useCollapseFilters < T extends HTMLElement > (
80+ isExpanded : boolean ,
81+ ) : { ref : RefObject < T | null > ; isWrapping : boolean ; wrappingIndex : number } {
82+ const ref = useRef < T > ( null ) ;
83+ const [ isWrapping , setIsWrapping ] = useState ( false ) ;
84+ const [ wrappingIndex , setWrappingIndex ] = useState ( - 1 ) ;
85+
86+ useEffect ( ( ) => {
87+ if ( ! ref . current ) return ;
88+
89+ const hideFilters = ( list : Element ) : void => {
90+ let isWrapping = false ;
91+ Array . from ( list . children ) . forEach ( ( node , i ) : void => {
92+ const child = node as HTMLElement ;
93+ const wrappingClass = "mx_RoomListPrimaryFilters_wrapping" ;
94+ child . setAttribute ( "aria-hidden" , "false" ) ;
95+ child . classList . remove ( wrappingClass ) ;
96+
97+ // If the filter list is expanded, all filters are visible
98+ if ( isExpanded ) return ;
99+
100+ // If the previous element is on the left element of the current one, it means that the filter is wrapping
101+ const previousSibling = child . previousElementSibling as HTMLElement | null ;
102+ if ( previousSibling && child . offsetLeft < previousSibling . offsetLeft ) {
103+ if ( ! isWrapping ) setWrappingIndex ( i ) ;
104+ isWrapping = true ;
105+ }
106+
107+ // If the filter is wrapping, we hide it
108+ child . classList . toggle ( wrappingClass , isWrapping ) ;
109+ child . setAttribute ( "aria-hidden" , isWrapping . toString ( ) ) ;
110+ } ) ;
111+
112+ if ( ! isWrapping ) setWrappingIndex ( - 1 ) ;
113+ setIsWrapping ( isExpanded || isWrapping ) ;
114+ } ;
115+
116+ hideFilters ( ref . current ) ;
117+ const observer = new ResizeObserver ( ( entries ) => entries . forEach ( ( entry ) => hideFilters ( entry . target ) ) ) ;
118+
119+ observer . observe ( ref . current ) ;
120+ return ( ) => {
121+ observer . disconnect ( ) ;
122+ } ;
123+ } , [ isExpanded ] ) ;
124+
125+ return { ref, isWrapping, wrappingIndex } ;
126+ }
127+
128+ /**
129+ * A hook to sort the filters by active state.
130+ * The list is sorted if the current filter index is greater than or equal to the wrapping index.
131+ * If the wrapping index is -1, the filters are not sorted.
132+ *
133+ * @param filters - the list of filters to sort.
134+ * @param wrappingIndex - the index of the first filter that is wrapping.
135+ */
136+ export function useVisibleFilters (
137+ filters : RoomListViewState [ "primaryFilters" ] ,
138+ wrappingIndex : number ,
139+ ) : RoomListViewState [ "primaryFilters" ] {
140+ // By default, the filters are not sorted
141+ const [ sortedFilters , setSortedFilters ] = useState ( filters ) ;
142+
143+ useEffect ( ( ) => {
144+ const isActiveFilterWrapping = filters . findIndex ( ( f ) => f . active ) >= wrappingIndex ;
145+ // If the active filter is not wrapping, we don't need to sort the filters
146+ if ( ! isActiveFilterWrapping || wrappingIndex === - 1 ) {
147+ setSortedFilters ( filters ) ;
148+ return ;
149+ }
150+
151+ // Sort the filters with the current filter at first position
152+ setSortedFilters (
153+ filters . slice ( ) . sort ( ( filterA , filterB ) => {
154+ // If the filter is active, it should be at the top of the list
155+ if ( filterA . active && ! filterB . active ) return - 1 ;
156+ if ( ! filterA . active && filterB . active ) return 1 ;
157+ // If both filters are active or not, keep their original order
158+ return 0 ;
159+ } ) ,
160+ ) ;
161+ } , [ filters , wrappingIndex ] ) ;
162+
163+ return sortedFilters ;
164+ }
0 commit comments