Add mobile drawer UI for FilterOptions component (#12576)

* Render filter options as drawer popover on mobile

Filter options on the search page (and other FilterOptions consumers)
previously rendered as a Radix dropdown on all viewports. On mobile this
now renders as a bottom-sheet Drawer, matching the popover style already
used by context menus.

https://claude.ai/code/session_01MSjTD67PWfGbwgNA5FFoSH

* Fix filter drawer search input overlapping first option on mobile

The Input wrapper uses flex: 0 (a 0% basis), which collapsed the search
input inside the drawer's flex column so its content painted over the
first list item. Use flex: none to retain the input's natural height.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-06-05 07:42:15 -04:00
committed by GitHub
parent bfddf4bb4c
commit c808bed712
+122 -39
View File
@@ -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<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const [open, setOpen] = React.useState(false);
@@ -58,23 +69,45 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option) => (
<MenuButton
key={option.key}
icon={
option.icon && showIcons ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : 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 ? (
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
) : 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 (
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
{icon}
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
<MenuComponents.SelectedIconWrapper aria-hidden>
{selectedKeys.includes(option.key) ? (
<CheckmarkIcon size={18} />
) : null}
</MenuComponents.SelectedIconWrapper>
</MenuComponents.MenuButton>
);
}
return (
<MenuButton
key={option.key}
icon={icon}
label={option.label}
onClick={handleClick}
selected={selectedKeys.includes(option.key)}
/>
);
},
[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 = (
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
);
const list = (
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
empty={<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 (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
<DrawerTitle>{defaultLabel}</DrawerTitle>
{showFilterInput && (
<MobileSearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
margin={0}
/>
)}
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
return (
<MenuProvider variant="dropdown">
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger>
<StyledButton
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
disclosure={disclosure}
neutral
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
</MenuTrigger>
<MenuTrigger>{trigger}</MenuTrigger>
<MenuContent aria-label={defaultLabel} align="start">
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{list}
{showFilterInput && (
<SearchInput
ref={searchInputRef}
@@ -260,6 +327,22 @@ const SearchInput = styled(Input)`
}
`;
const MobileSearchInput = styled(Input)`
/* "none" keeps an auto basis so the input retains its natural height; a
flexible/0% basis would collapse it and overlap the list below. */
flex: none;
margin: 0 6px 6px;
${NativeInput} {
/* 16px avoids iOS zooming the viewport when the input is focused. */
font-size: 16px;
}
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
export const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;