src/data-table/filter-menu.tsx (300 lines of code) (raw):
/*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import { Button, SHAPE, SIZE } from '../button';
import { Filter as FilterIcon } from '../icon';
import { Input, SIZE as INPUT_SIZE } from '../input';
import { Popover, PLACEMENT } from '../popover';
import { useStyletron } from '../styles';
import { useUIDSeed } from 'react-uid';
import { COLUMNS } from './constants';
import { matchesQuery } from './text-search';
import type { ColumnOptions } from './types';
import { LocaleContext } from '../locale';
import { isFocusVisible } from '../utils/focusVisible';
import type { SyntheticEvent } from 'react';
const ColumnIcon: React.FC<{ column: ColumnOptions }> = (props) => {
if (props.column.kind === COLUMNS.BOOLEAN) {
return <>01</>;
}
if (props.column.kind === COLUMNS.CATEGORICAL) {
return <>abc</>;
}
if (props.column.kind === COLUMNS.DATETIME) {
return <>dt</>;
}
if (props.column.kind === COLUMNS.NUMERICAL) {
return <>#</>;
}
return <FilterIcon />;
};
type OptionsProps = {
columns: ColumnOptions[];
highlightIndex: number;
onClick: (a: ColumnOptions) => void;
onKeyDown: (a: React.KeyboardEvent) => void;
onMouseEnter: (a: number) => void;
onQueryChange: (a: string) => void;
query: string;
searchable: boolean;
};
function Options(props: OptionsProps) {
const [css, theme] = useStyletron();
const locale = React.useContext(LocaleContext);
const inputRef = React.useRef(null);
React.useEffect(() => {
if (inputRef.current) {
// @ts-ignore
inputRef.current.focus();
}
}, [inputRef.current]);
const [focusVisible, setFocusVisible] = React.useState(false);
const seed = useUIDSeed();
const buiRef = React.useRef(props.columns.map((col) => seed(col)));
const activeDescendant = buiRef.current[props.highlightIndex]
? `bui-${buiRef.current[props.highlightIndex]}`
: undefined;
const optionsLabelId = seed('options-label');
const handleFocus = (event: SyntheticEvent) => {
if (isFocusVisible(event)) {
setFocusVisible(true);
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleBlur = (event: SyntheticEvent) => {
if (focusVisible !== false) {
setFocusVisible(false);
}
};
return (
<div
className={css({
backgroundColor: theme.colors.menuFill,
minWidth: '320px',
outline: focusVisible ? `3px solid ${theme.colors.borderAccent}` : 'none',
paddingTop: theme.sizing.scale600,
paddingBottom: theme.sizing.scale600,
})}
>
<p
id={optionsLabelId}
className={css({
...theme.typography.font100,
marginTop: 'unset',
paddingRight: theme.sizing.scale600,
paddingLeft: theme.sizing.scale600,
})}
>
{locale.datatable.optionsLabel}
</p>
{props.searchable && (
<div
className={css({
marginBottom: theme.sizing.scale500,
marginRight: theme.sizing.scale600,
marginLeft: theme.sizing.scale600,
})}
>
<Input
inputRef={inputRef}
value={props.query}
onChange={(event) => props.onQueryChange(event.target.value)}
placeholder={locale.datatable.optionsSearch}
size={INPUT_SIZE.compact}
clearable
/>
</div>
)}
{!props.columns.length && (
<div
className={css({
...theme.typography.font100,
paddingRight: theme.sizing.scale600,
paddingLeft: theme.sizing.scale600,
})}
>
{locale.datatable.optionsEmpty}
</div>
)}
<ul
onKeyDown={props.onKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={0}
role="listbox"
aria-activedescendant={activeDescendant}
aria-labelledby={optionsLabelId}
className={css({
listStyleType: 'none',
marginBlockStart: 'unset',
marginBlockEnd: 'unset',
maxHeight: '256px',
paddingInlineStart: 'unset',
outline: 'none',
overflowY: 'auto',
})}
>
{props.columns.map((column, index) => {
const isHighlighted = index === props.highlightIndex;
return (
// handled on the wrapper element
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
id={`bui-${buiRef.current[index]}`}
role="option"
aria-selected={isHighlighted}
onMouseEnter={() => props.onMouseEnter(index)}
onClick={() => props.onClick(column)}
key={column.title}
className={css({
...theme.typography.font100,
alignItems: 'center',
// @ts-ignore
backgroundColor: isHighlighted ? theme.colors.menuFillHover : null,
cursor: 'pointer',
display: 'flex',
paddingTop: theme.sizing.scale100,
paddingRight: theme.sizing.scale600,
paddingBottom: theme.sizing.scale100,
paddingLeft: theme.sizing.scale600,
})}
>
<div
className={css({
...theme.typography.font150,
fontSize: '8px',
alignItems: 'center',
backgroundColor: theme.colors.backgroundTertiary,
borderRadius: theme.borders.radius200,
display: 'flex',
height: theme.sizing.scale800,
justifyContent: 'center',
marginRight: theme.sizing.scale300,
width: theme.sizing.scale800,
})}
>
<ColumnIcon column={column} />
</div>
{column.title}
</li>
);
})}
</ul>
</div>
);
}
type Props = {
columns: ColumnOptions[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filters: Map<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rows: any[];
onSetFilter: (
columnTitle: string,
filterParams: {
description: string;
}
) => void;
};
function FilterMenu(props: Props) {
const [, theme] = useStyletron();
const locale = React.useContext(LocaleContext);
const [isOpen, setIsOpen] = React.useState(false);
const [highlightIndex, setHighlightIndex] = React.useState(-1);
const [query, setQuery] = React.useState('');
const [activeColumn, setActiveColumn] = React.useState(null);
const handleOptionClick = React.useCallback(setActiveColumn, []);
const handleClose = React.useCallback(() => {
setIsOpen(false);
setActiveColumn(null);
setHighlightIndex(-1);
setQuery('');
}, []);
const filterableColumns = React.useMemo(() => {
return props.columns.filter((column) => {
return column.filterable && !props.filters.has(column.title);
});
}, [props.columns, props.filters]);
const columns = React.useMemo(() => {
return filterableColumns.filter((column) => matchesQuery(column.title, query));
}, [filterableColumns, query]);
const Filter = React.useMemo(() => {
if (!activeColumn) return null;
// @ts-ignore
return activeColumn.renderFilter;
}, [activeColumn]);
const activeColumnData = React.useMemo(() => {
const columnIndex = props.columns.findIndex((c) => c === activeColumn);
if (columnIndex < 0) return [];
return props.rows.map((row) => props.columns[columnIndex].mapDataToValue(row.data));
}, [props.columns, props.rows, activeColumn]);
// @ts-ignore
function handleKeyDown(event) {
if (event.keyCode === 13) {
event.preventDefault();
// @ts-ignore
setActiveColumn(columns[highlightIndex]);
}
if (event.keyCode === 38) {
event.preventDefault();
setHighlightIndex(Math.max(0, highlightIndex - 1));
}
if (event.keyCode === 40) {
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightIndex(Math.min(columns.length - 1, highlightIndex + 1));
}
}
}
return (
<Popover
focusLock
returnFocus={true}
placement={PLACEMENT.bottomLeft}
content={() => {
if (Filter && activeColumn) {
return (
<Filter
data={activeColumnData}
close={handleClose}
// @ts-ignore
setFilter={(filterParams) => props.onSetFilter(activeColumn.title, filterParams)}
/>
);
}
return (
<Options
columns={columns}
highlightIndex={highlightIndex}
// @ts-ignore
onClick={handleOptionClick}
onKeyDown={handleKeyDown}
onMouseEnter={setHighlightIndex}
onQueryChange={setQuery}
query={query}
searchable={filterableColumns.length >= 10}
/>
);
}}
onClick={() => {
if (isOpen) {
handleClose();
} else {
setIsOpen(true);
}
}}
onClickOutside={handleClose}
onEsc={handleClose}
isOpen={isOpen}
ignoreBoundary
>
<Button
shape={SHAPE.pill}
size={SIZE.compact}
overrides={{
BaseButton: {
style: {
marginLeft: theme.sizing.scale500,
marginBottom: theme.sizing.scale500,
},
},
}}
>
{locale.datatable.filterAdd}
</Button>
</Popover>
);
}
export default FilterMenu;