diff --git a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss index ac85782bbd0ac2f0e5f06ad1356dd880640716cc..42b2fd42caf80547fe9630b0cd992da6c437daeb 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss @@ -6,7 +6,39 @@ */ .mx_RoomListPrimaryFilters { - margin: unset; - list-style-type: none; - padding: var(--cpd-space-2x) var(--cpd-space-3x); + padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x); + min-height: 46px; + max-height: 46px; + overflow: hidden; + box-sizing: border-box; + + &[data-expanded="true"] { + min-height: unset; + max-height: unset; + overflow: unset; + } + + ul { + margin: unset; + padding: unset; + list-style-type: none; + /** + * The InteractionObserver needs the height to be set for to work properly. + */ + height: 100%; + } + + .mx_RoomListPrimaryFilters_IconButton { + background-color: var(--cpd-color-bg-subtle-secondary); + + svg { + transition: transform 0.1s linear; + } + } + + .mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] { + svg { + transform: rotate(180deg); + } + } } diff --git a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx index ebf972d36137f55f7c7c66a4267b5f13671c0f82..70b278d4e952144961268c6314fe40f7aa623b4d 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters.tsx @@ -5,12 +5,14 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; -import { ChatFilter } from "@vector-im/compound-web"; +import React, { type JSX, useEffect, 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 type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; import { Flex } from "../../../utils/Flex"; import { _t } from "../../../../languageHandler"; +import { useIsNodeVisible } from "../../../../hooks/useIsNodeVisible"; interface RoomListPrimaryFiltersProps { /** @@ -23,23 +25,105 @@ interface RoomListPrimaryFiltersProps { * The primary filters for the room list */ export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element { + const id = useId(); + const [isExpanded, setIsExpanded] = useState(false); + + // threshold: 0.5 means that the filter is considered visible if at least 50% of it is visible + // this value is arbitrary, we want we to have a bit of flexibility + const { isVisible, rootRef, nodeRef } = useIsNodeVisible<HTMLLIElement, HTMLUListElement>({ threshold: 0.5 }); + const { filters, onFilterChange } = useFilters(vm.primaryFilters, isExpanded, isVisible); + return ( - <Flex - as="ul" - role="listbox" - aria-label={_t("room_list|primary_filters")} - className="mx_RoomListPrimaryFilters" - align="center" - gap="var(--cpd-space-2x)" - wrap="wrap" - > - {vm.primaryFilters.map((filter) => ( - <li role="option" aria-selected={filter.active} key={filter.name}> - <ChatFilter selected={filter.active} onClick={filter.toggle}> - {filter.name} - </ChatFilter> - </li> - ))} + <Flex id={id} className="mx_RoomListPrimaryFilters" gap="var(--cpd-space-3x)" data-expanded={isExpanded}> + <Flex + as="ul" + role="listbox" + aria-label={_t("room_list|primary_filters")} + align="center" + gap="var(--cpd-space-2x)" + wrap="wrap" + ref={rootRef} + > + {filters.map((filter) => ( + <li + ref={filter.active ? nodeRef : undefined} + role="option" + aria-selected={filter.active} + key={filter.name} + > + <ChatFilter + selected={filter.active} + onClick={() => { + onFilterChange(); + filter.toggle(); + }} + > + {filter.name} + </ChatFilter> + </li> + ))} + </Flex> + <IconButton + aria-expanded={isExpanded} + aria-controls={id} + className="mx_RoomListPrimaryFilters_IconButton" + aria-label={_t("room_list|room_options")} + size="28px" + onClick={() => setIsExpanded((_expanded) => !_expanded)} + > + <ChevronDownIcon color="var(--cpd-color-icon-secondary)" /> + </IconButton> </Flex> ); } + +/** + * A hook to sort the filters by active state. + * The list is sorted if the current filter is not visible when the list is unexpanded. + * + * @param filters - the list of filters to sort. + * @param isExpanded - the filter is expanded or not (fully visible). + * @param isVisible - `null` if there is not selected filter. `true` or `false` if the filter is visible or not. + */ +function useFilters( + filters: RoomListViewState["primaryFilters"], + isExpanded: boolean, + isVisible: boolean | null, +): { + /** + * The new list of filters. + */ + filters: RoomListViewState["primaryFilters"]; + /** + * Reset the filter sorting when called. + */ + onFilterChange: () => void; +} { + // By default, the filters are not sorted + const [filterState, setFilterState] = useState({ filters, isSorted: false }); + + useEffect(() => { + // If there is no current filter (isVisible is null) + // or if the filter list is fully visible (isExpanded is true) + // or if the current filter is visible and the list isn't sorted + // then we don't need to sort the filters + if (isVisible === null || isExpanded || (isVisible && !filterState.isSorted)) { + setFilterState({ filters, isSorted: false }); + return; + } + + // Sort the filters with the current filter at first position + setFilterState({ + filters: filters + .slice() + .sort((filterA, filterB) => (filterA.active === filterB.active ? 0 : filterA.active ? -1 : 1)), + isSorted: true, + }); + }, [filters, isVisible, filterState.isSorted, isExpanded]); + + const onFilterChange = (): void => { + // Reset the filter sorting + setFilterState({ filters, isSorted: false }); + }; + return { filters: filterState.filters, onFilterChange }; +}