diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx index 049902be2e..30f1bd293c 100644 --- a/app/components/FilterOptions.tsx +++ b/app/components/FilterOptions.tsx @@ -1,16 +1,26 @@ import { deburr } from "es-toolkit/compat"; +import { CheckmarkIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { s } from "@shared/styles"; import type { FetchPageParams } from "~/stores/base/Store"; import Button, { Inner } from "~/components/Button"; +import Scrollable from "~/components/Scrollable"; import Text from "~/components/Text"; +import useMobile from "~/hooks/useMobile"; import Input, { NativeInput, Outline } from "./Input"; import type { PaginatedItem } from "./PaginatedList"; import PaginatedList from "./PaginatedList"; +import { + Drawer, + DrawerContent, + DrawerTitle, + DrawerTrigger, +} from "./primitives/Drawer"; import { MenuProvider } from "./primitives/Menu/MenuContext"; import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu"; +import * as MenuComponents from "./primitives/components/Menu"; import { MenuIconWrapper } from "./primitives/components/Menu"; interface TFilterOption extends PaginatedItem { @@ -45,6 +55,7 @@ const FilterOptions = ({ ...rest }: Props) => { const { t } = useTranslation(); + const isMobile = useMobile(); const searchInputRef = React.useRef(null); const listRef = React.useRef(null); const [open, setOpen] = React.useState(false); @@ -58,23 +69,45 @@ const FilterOptions = ({ : ""; const renderItem = React.useCallback( - (option) => ( - {option.icon} - ) : undefined - } - label={option.label} - onClick={() => { - onSelect(option.key); - setOpen(false); - }} - selected={selectedKeys.includes(option.key)} - /> - ), - [onSelect, showIcons, selectedKeys] + (option) => { + const handleClick = () => { + onSelect(option.key); + setOpen(false); + }; + + const icon = + option.icon && showIcons ? ( + {option.icon} + ) : undefined; + + // On mobile the options render inside a Drawer (bottom sheet) rather than + // a Radix dropdown menu, so use the raw menu components directly instead + // of the dropdown-bound MenuButton which expects a menu root context. + if (isMobile) { + return ( + + {icon} + {option.label} + + {selectedKeys.includes(option.key) ? ( + + ) : null} + + + ); + } + + return ( + + ); + }, + [onSelect, showIcons, selectedKeys, isMobile] ); const handleFilter = React.useCallback( @@ -169,39 +202,73 @@ const FilterOptions = ({ React.useEffect(() => { if (open) { - searchInputRef.current?.focus(); + // Avoid auto-focusing on mobile as it immediately pops the on-screen + // keyboard over the drawer. + if (!isMobile) { + searchInputRef.current?.focus(); + } } else { setQuery(""); } - }, [open]); + }, [open, isMobile]); const showFilterInput = showFilter || options.length > 10; const defaultLabel = rest.defaultLabel || t("Filter options"); + const trigger = ( + + {selectedItems.length ? selectedLabel : defaultLabel} + + ); + + const list = ( + + listRef={listRef} + options={{ query, ...fetchQueryOptions }} + items={filteredOptions} + fetch={fetchQuery} + renderItem={renderItem} + onEscape={handleEscapeFromList} + heading={showFilterInput && !isMobile ? : undefined} + empty={} + /> + ); + + // On mobile render the options inside a Drawer (bottom sheet) to match the + // popover style used by context menus across the app. + if (isMobile) { + return ( + + {trigger} + + {defaultLabel} + {showFilterInput && ( + + )} + {list} + + + ); + } + return ( - - - {selectedItems.length ? selectedLabel : defaultLabel} - - + {trigger} - - listRef={listRef} - options={{ query, ...fetchQueryOptions }} - items={filteredOptions} - fetch={fetchQuery} - renderItem={renderItem} - onEscape={handleEscapeFromList} - heading={showFilterInput ? : undefined} - empty={} - /> + {list} {showFilterInput && (