mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
9b8acf3efb
* Fix remaining no-useless-default-assignment lint warnings * Promote no-useless-default-assignment lint rule to error
363 lines
9.5 KiB
TypeScript
363 lines
9.5 KiB
TypeScript
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 {
|
|
key: string;
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
type Props = {
|
|
options: TFilterOption[];
|
|
selectedKeys: (string | null | undefined)[];
|
|
defaultLabel?: string;
|
|
className?: string;
|
|
onSelect: (key: string | null | undefined) => void;
|
|
showFilter?: boolean;
|
|
showIcons?: boolean;
|
|
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
|
|
fetchQueryOptions?: Record<string, string>;
|
|
disclosure?: boolean;
|
|
};
|
|
|
|
const FilterOptions = ({
|
|
options,
|
|
selectedKeys,
|
|
className,
|
|
onSelect,
|
|
showFilter,
|
|
showIcons = true,
|
|
fetchQuery,
|
|
fetchQueryOptions,
|
|
disclosure = true,
|
|
...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);
|
|
const selectedItems = options.filter((option) =>
|
|
selectedKeys.includes(option.key)
|
|
);
|
|
const [query, setQuery] = React.useState("");
|
|
|
|
const selectedLabel = selectedItems.length
|
|
? selectedItems.map((selected) => selected.label).join(", ")
|
|
: "";
|
|
|
|
const renderItem = React.useCallback(
|
|
(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(
|
|
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
setQuery(ev.target.value);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const filteredOptions = React.useMemo(() => {
|
|
const normalizedQuery = deburr(query.toLowerCase());
|
|
|
|
const filtered = query
|
|
? options.filter((option) =>
|
|
deburr(option.label).toLowerCase().includes(normalizedQuery)
|
|
)
|
|
: options;
|
|
|
|
return filtered.sort((a, b) => {
|
|
const aSelected = selectedKeys.includes(a.key);
|
|
const bSelected = selectedKeys.includes(b.key);
|
|
|
|
// Selected items come first
|
|
if (aSelected && !bSelected) {
|
|
return -1;
|
|
}
|
|
if (!aSelected && bSelected) {
|
|
return 1;
|
|
}
|
|
|
|
// If both have the same selection state and there's a query,
|
|
// sort options starting with query first
|
|
if (query) {
|
|
const aStartsWith = deburr(a.label)
|
|
.toLowerCase()
|
|
.startsWith(normalizedQuery);
|
|
const bStartsWith = deburr(b.label)
|
|
.toLowerCase()
|
|
.startsWith(normalizedQuery);
|
|
|
|
if (aStartsWith && !bStartsWith) {
|
|
return -1;
|
|
}
|
|
if (!aStartsWith && bStartsWith) {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
}, [options, query, selectedKeys]);
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(ev: React.KeyboardEvent) => {
|
|
if (ev.nativeEvent.isComposing || ev.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
// Stop all keyboard events from propagating to prevent Radix UI menu
|
|
// from handling them and potentially moving focus
|
|
ev.stopPropagation();
|
|
|
|
switch (ev.key) {
|
|
case "Escape":
|
|
setOpen(false);
|
|
break;
|
|
case "Enter":
|
|
if (filteredOptions.length === 1) {
|
|
ev.preventDefault();
|
|
onSelect(filteredOptions[0].key);
|
|
setOpen(false);
|
|
}
|
|
break;
|
|
case "ArrowDown":
|
|
ev.preventDefault();
|
|
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
[filteredOptions, onSelect]
|
|
);
|
|
|
|
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
|
|
searchInputRef.current?.focus();
|
|
|
|
if (ev.key === "Backspace") {
|
|
setQuery((prev) => prev.slice(0, -1));
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
// Avoid auto-focusing on mobile as it immediately pops the on-screen
|
|
// keyboard over the drawer.
|
|
if (!isMobile) {
|
|
searchInputRef.current?.focus();
|
|
}
|
|
} else {
|
|
setQuery("");
|
|
}
|
|
}, [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>{trigger}</MenuTrigger>
|
|
<MenuContent aria-label={defaultLabel} align="start">
|
|
{list}
|
|
{showFilterInput && (
|
|
<SearchInput
|
|
ref={searchInputRef}
|
|
value={query}
|
|
onChange={handleFilter}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={`${t("Filter")}…`}
|
|
autoFocus
|
|
/>
|
|
)}
|
|
</MenuContent>
|
|
</Menu>
|
|
</MenuProvider>
|
|
);
|
|
};
|
|
|
|
const Empty = () => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<>
|
|
<Spacer />
|
|
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
|
|
{t("No results")}
|
|
</Text>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const Spacer = styled.div`
|
|
height: 30px;
|
|
`;
|
|
|
|
const SearchInput = styled(Input)`
|
|
position: absolute;
|
|
width: 100%;
|
|
border: none;
|
|
border-top-left-radius: 6px;
|
|
border-top-right-radius: 6px;
|
|
overflow: hidden;
|
|
margin: 0;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
|
|
${Outline} {
|
|
border: none;
|
|
border-radius: 0;
|
|
border-bottom: 1px solid ${s("divider")};
|
|
background: ${s("menuBackground")};
|
|
margin: 0;
|
|
}
|
|
|
|
${NativeInput} {
|
|
font-size: 14px;
|
|
}
|
|
`;
|
|
|
|
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;
|
|
border-color: transparent;
|
|
height: auto;
|
|
|
|
&:hover {
|
|
background: transparent;
|
|
}
|
|
|
|
${Inner} {
|
|
line-height: 28px;
|
|
min-height: auto;
|
|
}
|
|
`;
|
|
|
|
export default FilterOptions;
|