55 * Please see LICENSE files in the repository root for full details.
66 */
77
8- import React , { type JSX } from "react" ;
9- import { ChatFilter } from "@vector-im/compound-web" ;
8+ import React , { type JSX , useEffect , useId , useState } from "react" ;
9+ import { ChatFilter , IconButton } from "@vector-im/compound-web" ;
10+ import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down" ;
1011
1112import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel" ;
1213import { Flex } from "../../../utils/Flex" ;
1314import { _t } from "../../../../languageHandler" ;
15+ import { useIsNodeVisible } from "../../../../hooks/useIsNodeVisible" ;
1416
1517interface RoomListPrimaryFiltersProps {
1618 /**
@@ -23,23 +25,105 @@ interface RoomListPrimaryFiltersProps {
2325 * The primary filters for the room list
2426 */
2527export function RoomListPrimaryFilters ( { vm } : RoomListPrimaryFiltersProps ) : JSX . Element {
28+ const id = useId ( ) ;
29+ const [ isExpanded , setIsExpanded ] = useState ( false ) ;
30+
31+ // threshold: 0.5 means that the filter is considered visible if at least 50% of it is visible
32+ // this value is arbitrary, we want we to have a bit of flexibility
33+ const { isVisible, rootRef, nodeRef } = useIsNodeVisible < HTMLLIElement , HTMLUListElement > ( { threshold : 0.5 } ) ;
34+ const { filters, onFilterChange } = useFilters ( vm . primaryFilters , isExpanded , isVisible ) ;
35+
2636 return (
27- < Flex
28- as = "ul"
29- role = "listbox"
30- aria-label = { _t ( "room_list|primary_filters" ) }
31- className = "mx_RoomListPrimaryFilters"
32- align = "center"
33- gap = "var(--cpd-space-2x)"
34- wrap = "wrap"
35- >
36- { vm . primaryFilters . map ( ( filter ) => (
37- < li role = "option" aria-selected = { filter . active } key = { filter . name } >
38- < ChatFilter selected = { filter . active } onClick = { filter . toggle } >
39- { filter . name }
40- </ ChatFilter >
41- </ li >
42- ) ) }
37+ < Flex id = { id } className = "mx_RoomListPrimaryFilters" gap = "var(--cpd-space-3x)" data-expanded = { isExpanded } >
38+ < Flex
39+ as = "ul"
40+ role = "listbox"
41+ aria-label = { _t ( "room_list|primary_filters" ) }
42+ align = "center"
43+ gap = "var(--cpd-space-2x)"
44+ wrap = "wrap"
45+ ref = { rootRef }
46+ >
47+ { filters . map ( ( filter ) => (
48+ < li
49+ ref = { filter . active ? nodeRef : undefined }
50+ role = "option"
51+ aria-selected = { filter . active }
52+ key = { filter . name }
53+ >
54+ < ChatFilter
55+ selected = { filter . active }
56+ onClick = { ( ) => {
57+ onFilterChange ( ) ;
58+ filter . toggle ( ) ;
59+ } }
60+ >
61+ { filter . name }
62+ </ ChatFilter >
63+ </ li >
64+ ) ) }
65+ </ Flex >
66+ < IconButton
67+ aria-expanded = { isExpanded }
68+ aria-controls = { id }
69+ className = "mx_RoomListPrimaryFilters_IconButton"
70+ aria-label = { _t ( "room_list|room_options" ) }
71+ size = "28px"
72+ onClick = { ( ) => setIsExpanded ( ( _expanded ) => ! _expanded ) }
73+ >
74+ < ChevronDownIcon color = "var(--cpd-color-icon-secondary)" />
75+ </ IconButton >
4376 </ Flex >
4477 ) ;
4578}
79+
80+ /**
81+ * A hook to sort the filters by active state.
82+ * The list is sorted if the current filter is not visible when the list is unexpanded.
83+ *
84+ * @param filters - the list of filters to sort.
85+ * @param isExpanded - the filter is expanded or not (fully visible).
86+ * @param isVisible - `null` if there is not selected filter. `true` or `false` if the filter is visible or not.
87+ */
88+ function useFilters (
89+ filters : RoomListViewState [ "primaryFilters" ] ,
90+ isExpanded : boolean ,
91+ isVisible : boolean | null ,
92+ ) : {
93+ /**
94+ * The new list of filters.
95+ */
96+ filters : RoomListViewState [ "primaryFilters" ] ;
97+ /**
98+ * Reset the filter sorting when called.
99+ */
100+ onFilterChange : ( ) => void ;
101+ } {
102+ // By default, the filters are not sorted
103+ const [ filterState , setFilterState ] = useState ( { filters, isSorted : false } ) ;
104+
105+ useEffect ( ( ) => {
106+ // If there is no current filter (isVisible is null)
107+ // or if the filter list is fully visible (isExpanded is true)
108+ // or if the current filter is visible and the list isn't sorted
109+ // then we don't need to sort the filters
110+ if ( isVisible === null || isExpanded || ( isVisible && ! filterState . isSorted ) ) {
111+ setFilterState ( { filters, isSorted : false } ) ;
112+ return ;
113+ }
114+
115+ // Sort the filters with the current filter at first position
116+ setFilterState ( {
117+ filters : filters
118+ . slice ( )
119+ . sort ( ( filterA , filterB ) => ( filterA . active === filterB . active ? 0 : filterA . active ? - 1 : 1 ) ) ,
120+ isSorted : true ,
121+ } ) ;
122+ } , [ filters , isVisible , filterState . isSorted , isExpanded ] ) ;
123+
124+ const onFilterChange = ( ) : void => {
125+ // Reset the filter sorting
126+ setFilterState ( { filters, isSorted : false } ) ;
127+ } ;
128+ return { filters : filterState . filters , onFilterChange } ;
129+ }
0 commit comments