Skip to content

Commit

Permalink
feat: highlight search results in single-category filter menus (#546)
Browse files Browse the repository at this point in the history
  • Loading branch information
hunterckx committed Jan 10, 2024
1 parent a73e533 commit 1556701
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 103 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CategoryKey,
SelectCategoryValueView,
} from "../../../../common/entities";
import { FilterMenuSearchMatch } from "../../../../components/Filter/common/entities";
import { OnFilterFn } from "../../../../hooks/useCategoryFilter";
import { SouthIcon } from "../../../common/CustomIcon/components/SouthIcon/southIcon";
import { MAX_DISPLAYABLE_LIST_ITEMS } from "../../common/constants";
Expand Down Expand Up @@ -37,9 +38,7 @@ export const FilterMenu = ({
const [searchTerm, setSearchTerm] = useState<string>("");
const isSearchable =
isFilterDrawer || values.length > MAX_DISPLAYABLE_LIST_ITEMS;
const filteredValues = isSearchable
? applyMenuFilter(values, searchTerm)
: values;
const matchedItems = applyMenuFilter(values, isSearchable ? searchTerm : "");
return (
<FilterView menuWidth={menuWidth}>
<FilterViewTools>
Expand All @@ -56,13 +55,13 @@ export const FilterMenu = ({
/>
)}
</FilterViewTools>
{filteredValues.length > 0 ? (
{matchedItems.length > 0 ? (
<VariableSizeList
categorySection={categorySection}
categoryKey={categoryKey}
isFilterDrawer={isFilterDrawer}
onFilter={onFilter}
values={filteredValues}
matchedItems={matchedItems}
/>
) : (
<List>
Expand All @@ -78,6 +77,6 @@ export const FilterMenu = ({
export function applyMenuFilter(
values: SelectCategoryValueView[],
searchTerm: string
): SelectCategoryValueView[] {
return getSortMatchesFn(searchTerm)(values).map(({ value }) => value);
): FilterMenuSearchMatch[] {
return getSortMatchesFn(searchTerm)(values);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";

interface MatchHighlightProps {
leftOpen: boolean;
rightOpen: boolean;
}

export const MatchHighlight = styled.mark<MatchHighlightProps>`
background: ${({ theme }) => theme.palette.warning.light};
color: inherit;
padding: 2px 0;
${({ leftOpen }) =>
leftOpen &&
css`
padding-left: 2px;
margin-left: -2px;
`}
${({ rightOpen }) =>
rightOpen &&
css`
padding-right: 2px;
margin-right: -2px;
`}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react";
import { FilterMenuSearchMatchRange } from "../../../../components/Filter/common/entities";
import { MatchHighlight } from "./highlightedLabel.styles";

interface HighlightedLabelProps {
label: string;
ranges?: FilterMenuSearchMatchRange[];
}

export const HighlightedLabel = ({
label,
ranges,
}: HighlightedLabelProps): JSX.Element => {
const items = [];
if (ranges) {
ranges = ranges.slice().sort(({ start: a }, { start: b }) => a - b);
let prevIndex = 0;
for (let i = 0; i < ranges.length; i++) {
const { start } = ranges[i];
let { end } = ranges[i];
// Consolidate overlapping ranges
while (i + 1 < ranges.length && ranges[i + 1].start <= end) {
i++;
end = Math.max(end, ranges[i].end);
}
const leftChar = label[start - 1];
const rightChar = label[end];
const leftOpen = !leftChar || /\s/.test(leftChar);
const rightOpen = !rightChar || /\s/.test(rightChar);
const matchItems = [
label.substring(prevIndex, start),
<MatchHighlight key={start} leftOpen={leftOpen} rightOpen={rightOpen}>
{label.substring(start, end)}
</MatchHighlight>,
];
prevIndex = end;
items.push(matchItems);
}
items.push(label.substring(prevIndex));
} else {
items.push(label);
}
return <span>{items}</span>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import { OnFilterFn } from "../../../../../../hooks/useCategoryFilter";
import { TEXT_BODY_SMALL_400 } from "../../../../../../theme/common/typography";
import { CheckedIcon } from "../../../../../common/CustomIcon/components/CheckedIcon/checkedIcon";
import { UncheckedIcon } from "../../../../../common/CustomIcon/components/UncheckedIcon/uncheckedIcon";
import { FilterMenuSearchMatchRange } from "../../../../common/entities";
import { FilterNoResultsFound } from "../../../FilterNoResultsFound/filterNoResultsFound";
import { HighlightedLabel } from "../../../HighlightedLabel/highlightedLabel";
import { ITEM_TYPE, SearchAllFiltersDynamicItem } from "../../common/entites";
import { MatchHighlight } from "../../searchAllFilters.styles";

interface Props {
item: SearchAllFiltersDynamicItem;
Expand Down Expand Up @@ -66,9 +65,10 @@ export default function VariableSizeListItem({
<ListItemText
disableTypography
primary={
<span>
{matchRanges?.length ? markSearchTerm(label, matchRanges) : label}
</span>
<HighlightedLabel
label={label}
ranges={matchRanges}
></HighlightedLabel>
}
secondary={
<Typography color="ink.light" variant={TEXT_BODY_SMALL_400}>
Expand All @@ -88,35 +88,3 @@ export default function VariableSizeListItem({
return <FilterNoResultsFound ref={setRef} />;
}
}

function markSearchTerm(
label: string,
ranges: FilterMenuSearchMatchRange[]
): React.ReactNode {
ranges = ranges.slice().sort(({ start: a }, { start: b }) => a - b);
let prevIndex = 0;
const items = [];
for (let i = 0; i < ranges.length; i++) {
const { start } = ranges[i];
let { end } = ranges[i];
// Consolidate overlapping ranges
while (i + 1 < ranges.length && ranges[i + 1].start <= end) {
i++;
end = Math.max(end, ranges[i].end);
}
const leftChar = label[start - 1];
const rightChar = label[end];
const leftOpen = !leftChar || /\s/.test(leftChar);
const rightOpen = !rightChar || /\s/.test(rightChar);
const matchItems = [
label.substring(prevIndex, start),
<MatchHighlight key={start} leftOpen={leftOpen} rightOpen={rightOpen}>
{label.substring(start, end)}
</MatchHighlight>,
];
prevIndex = end;
items.push(matchItems);
}
items.push(label.substring(prevIndex));
return items;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { Autocomplete as MAutocomplete } from "@mui/material";
import { inkMain } from "../../../../styles/common/mixins/colors";

interface MatchHighlightProps {
leftOpen: boolean;
rightOpen: boolean;
}

export const Autocomplete = styled(MAutocomplete)`
&.Mui-expanded {
.MuiOutlinedInput-root {
Expand All @@ -19,23 +13,3 @@ export const Autocomplete = styled(MAutocomplete)`
}
}
` as typeof MAutocomplete;

export const MatchHighlight = styled.mark<MatchHighlightProps>`
background: ${({ theme }) => theme.palette.warning.light};
color: inherit;
padding: 2px 0;
${({ leftOpen }) =>
leftOpen &&
css`
padding-left: 2px;
margin-left: -2px;
`}
${({ rightOpen }) =>
rightOpen &&
css`
padding-right: 2px;
margin-right: -2px;
`}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import {
VariableSizeList as List,
VariableSizeListProps as ListProps,
} from "react-window";
import {
CategoryKey,
SelectCategoryValueView,
} from "../../../../common/entities";
import { CategoryKey } from "../../../../common/entities";
import { FilterMenuSearchMatch } from "../../../../components/Filter/common/entities";
import { OnFilterFn } from "../../../../hooks/useCategoryFilter";
import { useWindowResize } from "../../../../hooks/useWindowResize";
import {
Expand All @@ -27,9 +25,9 @@ export interface VariableSizeListProps {
height?: number; // Height of list; vertical list must be a number.
isFilterDrawer: boolean;
itemSize?: number; // Default item size.
matchedItems: FilterMenuSearchMatch[];
onFilter: OnFilterFn;
overscanCount?: ListProps["overscanCount"];
values: SelectCategoryValueView[];
width?: ListProps["width"]; // Width of list; default to 100% width of parent element.
}

Expand All @@ -43,15 +41,15 @@ function renderListItem(props: ListChildComponentProps): JSX.Element {
const {
categoryKey,
categorySection,
matchedItems,
onFilter,
onUpdateItemSizeByItemKey,
values,
} = data;
return (
<VariableSizeListItem
categorySection={categorySection}
categoryKey={categoryKey}
listItem={values[index]}
matchedItem={matchedItems[index]}
onFilter={onFilter}
onUpdateItemSizeByItemKey={onUpdateItemSizeByItemKey}
style={style}
Expand All @@ -67,7 +65,7 @@ export const VariableSizeList = ({
itemSize = LIST_ITEM_HEIGHT,
onFilter,
overscanCount = MAX_DISPLAYABLE_LIST_ITEMS * 2,
values,
matchedItems,
width = "100%",
}: VariableSizeListProps): JSX.Element => {
const { height: windowHeight } = useWindowResize();
Expand All @@ -87,36 +85,36 @@ export const VariableSizeList = ({
setHeight(
calculateListHeight(
initHeight,
values,
matchedItems,
itemSizeByItemKeyRef.current,
isFilterDrawer,
windowHeight,
outerRef.current
)
);
}, [initHeight, isFilterDrawer, values, windowHeight]);
}, [initHeight, isFilterDrawer, matchedItems, windowHeight]);

// Clears VariableSizeList cache (offsets and measurements) when values are updated (filtered).
// Facilitates correct positioning of list items when list is updated.
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [values]);
}, [matchedItems]);

return (
<List
height={height}
outerRef={outerRef}
innerElementType={FilterList}
itemCount={values.length}
itemCount={matchedItems.length}
itemData={{
categoryKey,
categorySection,
matchedItems,
onFilter,
onUpdateItemSizeByItemKey,
values,
}}
itemSize={(index): number =>
itemSizeByItemKey.get(values[index].key) || itemSize
itemSizeByItemKey.get(matchedItems[index].value.key) || itemSize
}
onItemsRendered={(): void => listRef.current?.resetAfterIndex(0)} // Facilitates correct positioning of list items when list scrolls.
overscanCount={overscanCount}
Expand All @@ -132,7 +130,7 @@ export const VariableSizeList = ({
* Returns given height of list if number of items is greater than max displayable list items, otherwise the minimum
* height of either the sum of the heights of the filtered list items or the given height of the list.
* @param height - Specified height of list.
* @param values - Set of category value view models for the given category.
* @param matchedItems - Set of search results for category value view models in the given category.
* @param itemSizeByItemKey - Map of item size by item key.
* @param isFilterDrawer - True if filter is displayed in filter drawer.
* @param windowHeight - Window height.
Expand All @@ -141,7 +139,7 @@ export const VariableSizeList = ({
*/
function calculateListHeight(
height: number,
values: SelectCategoryValueView[],
matchedItems: FilterMenuSearchMatch[],
itemSizeByItemKey: ItemSizeByItemKey,
isFilterDrawer: boolean,
windowHeight: number,
Expand All @@ -150,11 +148,11 @@ function calculateListHeight(
if (isFilterDrawer && outerListElem) {
return windowHeight - outerListElem.getBoundingClientRect().top;
}
if (values.length > MAX_DISPLAYABLE_LIST_ITEMS) {
if (matchedItems.length > MAX_DISPLAYABLE_LIST_ITEMS) {
return height;
}
return Math.min(
values.reduce((acc, { key }) => {
matchedItems.reduce((acc, { value: { key } }) => {
acc += itemSizeByItemKey.get(key) || LIST_ITEM_HEIGHT;
return acc;
}, LIST_MARGIN * 2),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import {
Typography,
} from "@mui/material";
import React, { CSSProperties, useEffect, useRef } from "react";
import {
CategoryKey,
SelectCategoryValueView,
} from "../../../../common/entities";
import { CategoryKey } from "../../../../common/entities";
import { FilterMenuSearchMatch } from "../../../../components/Filter/common/entities";
import { OnFilterFn } from "../../../../hooks/useCategoryFilter";
import { TEXT_BODY_SMALL_400 } from "../../../../theme/common/typography";
import { CheckedIcon } from "../../../common/CustomIcon/components/CheckedIcon/checkedIcon";
import { UncheckedIcon } from "../../../common/CustomIcon/components/UncheckedIcon/uncheckedIcon";
import { HighlightedLabel } from "../HighlightedLabel/highlightedLabel";

interface Props {
categoryKey: CategoryKey;
categorySection?: string;
listItem: SelectCategoryValueView;
matchedItem: FilterMenuSearchMatch;
onFilter: OnFilterFn;
onUpdateItemSizeByItemKey: (itemKey: string, itemSize: number) => void;
style: CSSProperties;
Expand All @@ -26,13 +25,16 @@ interface Props {
export default function VariableSizeListItem({
categoryKey,
categorySection,
listItem,
matchedItem,
onFilter,
onUpdateItemSizeByItemKey,
style,
}: Props): JSX.Element {
const listItemRef = useRef<HTMLDivElement>(null);
const { count, key, label, selected } = listItem;
const {
labelRanges,
value: { count, key, label, selected },
} = matchedItem;
delete style.height; // Remove height style to allow variable size list to set item height.

// Sets map of list item key to its height.
Expand All @@ -58,7 +60,12 @@ export default function VariableSizeListItem({
/>
<ListItemText
disableTypography
primary={<span>{label}</span>}
primary={
<HighlightedLabel
label={label}
ranges={labelRanges}
></HighlightedLabel>
}
secondary={
<Typography color="ink.light" variant={TEXT_BODY_SMALL_400}>
{count}
Expand Down
Loading

0 comments on commit 1556701

Please sign in to comment.