Skip to content

Commit

Permalink
👻 Convert filter toolbar controls to pf-5 select menu style
Browse files Browse the repository at this point in the history
Signed-off-by: ibolton336 <[email protected]>
  • Loading branch information
ibolton336 committed Jan 24, 2024
1 parent 8f83736 commit 3d68556
Show file tree
Hide file tree
Showing 2 changed files with 309 additions and 100 deletions.
311 changes: 247 additions & 64 deletions client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import * as React from "react";
import { ToolbarChip, ToolbarFilter, Tooltip } from "@patternfly/react-core";
import {
Button,
MenuToggle,
MenuToggleElement,
Select,
SelectOption,
SelectOptionObject,
SelectVariant,
SelectProps,
SelectGroup,
} from "@patternfly/react-core/deprecated";
SelectList,
SelectOption,
SelectOptionProps,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
ToolbarChip,
ToolbarFilter,
Tooltip,
} from "@patternfly/react-core";
import { IFilterControlProps } from "./FilterControl";
import {
IMultiselectFilterCategory,
FilterSelectOptionProps,
} from "./FilterToolbar";
import { css } from "@patternfly/react-styles";
import { TimesIcon } from "@patternfly/react-icons";

import "./select-overrides.css";

Expand All @@ -37,46 +45,44 @@ export const MultiselectFilterControl = <TItem,>({
>): JSX.Element | null => {
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false);

const { selectOptions } = category;
const [selectOptions, setSelectOptions] = React.useState<
FilterSelectOptionProps[]
>(Array.isArray(category.selectOptions) ? category.selectOptions : []);
const hasGroupings = !Array.isArray(selectOptions);
const flatOptions = !hasGroupings
const flatOptions: FilterSelectOptionProps[] = !hasGroupings
? selectOptions
: Object.values(selectOptions).flatMap((i) => i);
: (Object.values(selectOptions).flatMap(
(i) => i
) as FilterSelectOptionProps[]);

const getOptionKeyFromOptionValue = (
optionValue: string | SelectOptionObject
) => flatOptions.find(({ value }) => value === optionValue)?.key;
React.useEffect(() => {
if (Array.isArray(category.selectOptions)) {
setSelectOptions(category.selectOptions);
}
}, [category.selectOptions]);

const getOptionKeyFromChip = (chip: string) =>
flatOptions.find(({ value }) => value.toString() === chip)?.key;
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(
null
);

const [activeItem, setActiveItem] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();
const [inputValue, setInputValue] = React.useState<string>("");

const getOptionKeyFromOptionValue = (
optionValue: string | SelectOptionProps
) => flatOptions.find((option) => option?.value === optionValue)?.key;

const getOptionValueFromOptionKey = (optionKey: string) =>
flatOptions.find(({ key }) => key === optionKey)?.value;

const onFilterSelect = (value: string | SelectOptionObject) => {
const optionKey = getOptionKeyFromOptionValue(value);
if (optionKey && filterValue?.includes(optionKey)) {
const updatedValues = filterValue.filter(
(item: string) => item !== optionKey
);
setFilterValue(updatedValues);
} else {
if (filterValue) {
const updatedValues = [...filterValue, optionKey];
setFilterValue(updatedValues as string[]);
} else {
setFilterValue([optionKey || ""]);
}
}
};

const onFilterClear = (chip: string | ToolbarChip) => {
const chipKey = typeof chip === "string" ? chip : chip.key;
const optionKey = getOptionKeyFromChip(chipKey);
const newValue = filterValue
? filterValue.filter((val) => val !== optionKey)
: [];
setFilterValue(newValue.length > 0 ? newValue : null);
const newFilterValue = filterValue
? filterValue.filter((selection) => selection !== chipKey)
: filterValue;

setFilterValue(newFilterValue);
};

// Select expects "selections" to be an array of the "value" props from the relevant optionProps
Expand Down Expand Up @@ -110,7 +116,9 @@ export const MultiselectFilterControl = <TItem,>({
filter: (option: FilterSelectOptionProps, groupName?: string) => boolean
) =>
hasGroupings
? Object.entries(selectOptions)
? Object.entries(
selectOptions as Record<string, FilterSelectOptionProps[]>
)
.sort(([groupA], [groupB]) => groupA.localeCompare(groupB))
.map(([group, options], index) => {
const groupFiltered =
Expand All @@ -126,26 +134,204 @@ export const MultiselectFilterControl = <TItem,>({
.filter(Boolean)
: flatOptions
.filter((o) => filter(o))
.map((optionProps) => (
<SelectOption {...optionProps} key={optionProps.key} />
.map((optionProps, index) => (
<SelectOption
{...optionProps}
{...(!optionProps.isDisabled && { hasCheckbox: true })}
key={optionProps.value || optionProps.children}
isFocused={focusedItemIndex === index}
id={`select-multi-typeahead-${optionProps.value.replace(
" ",
"-"
)}`}
ref={null}
isSelected={filterValue?.includes(optionProps.value)}
>
{optionProps.value}
</SelectOption>
));

/**
* Render options (with categories if available) where the option value OR key includes
* the filterInput.
*/
const onOptionsFilter: SelectProps["onFilter"] = (_event, textInput) => {
const input = textInput?.toLowerCase();

return renderSelectOptions((optionProps, groupName) => {
// TODO: Checking for a filter match against the key or the value may not be desirable.
return (
groupName?.toLowerCase().includes(input) ||
optionProps?.key?.toLowerCase().includes(input) ||
optionProps?.value?.toString().toLowerCase().includes(input)
);
});
const onSelect = (value: string | undefined) => {
if (value && value !== "No results") {
const newFilterValue = filterValue ? [...filterValue, value] : [value];
setFilterValue(newFilterValue);
}

textInputRef.current?.focus();
};

const handleMenuArrowKeys = (key: string) => {
if (isFilterDropdownOpen && Array.isArray(selectOptions)) {
let indexToFocus: number = focusedItemIndex ?? -1;

if (key === "ArrowUp") {
indexToFocus =
indexToFocus <= 0 ? selectOptions.length - 1 : indexToFocus - 1;
} else if (key === "ArrowDown") {
indexToFocus =
indexToFocus >= selectOptions.length - 1 ? 0 : indexToFocus + 1;
}

while (selectOptions[indexToFocus].isDisabled) {
indexToFocus = key === "ArrowUp" ? indexToFocus - 1 : indexToFocus + 1;
if (indexToFocus < 0) {
indexToFocus = selectOptions.length - 1;
} else if (indexToFocus >= selectOptions.length) {
indexToFocus = 0;
}
}

setFocusedItemIndex(indexToFocus);
const focusedItem = selectOptions[indexToFocus];
setActiveItem(`select-typeahead-${focusedItem.value.replace(" ", "-")}`);
}
};
React.useEffect(() => {
let newSelectOptions = Array.isArray(category.selectOptions)
? category.selectOptions
: [];

if (inputValue) {
newSelectOptions = Array.isArray(category.selectOptions)
? category.selectOptions?.filter((menuItem) =>
String(menuItem.value)
.toLowerCase()
.includes(inputValue.toLowerCase())
)
: [];

if (!newSelectOptions.length) {
newSelectOptions = [
{
key: "no-results",
isDisabled: true,
children: `No results found for "${inputValue}"`,
value: "No results",
},
];
}

if (!isFilterDropdownOpen) {
setIsFilterDropdownOpen(true);
}
}

setSelectOptions(newSelectOptions);
setFocusedItemIndex(null);
setActiveItem(null);
}, [inputValue, isFilterDropdownOpen, category.selectOptions]);

const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const enabledMenuItems = Array.isArray(selectOptions)
? selectOptions.filter((option) => !option.isDisabled)
: [];
const [firstMenuItem] = enabledMenuItems;
const focusedItem = focusedItemIndex
? enabledMenuItems[focusedItemIndex]
: firstMenuItem;

const newSelectOptions = flatOptions.filter((menuItem) =>
menuItem.value.toLowerCase().includes(inputValue.toLowerCase())
);
const selectedItem =
newSelectOptions.find(
(option) => option.value.toLowerCase() === inputValue.toLowerCase()
) || focusedItem;

switch (event.key) {
case "Enter":
event.preventDefault();
setSelectOptions(newSelectOptions);
setIsFilterDropdownOpen(true);

if (
isFilterDropdownOpen &&
selectedItem &&
selectedItem.value !== "no results"
) {
setInputValue("");

const newFilterValue = [...(filterValue || [])];
const optionValue = getOptionValueFromOptionKey(selectedItem.value);

if (newFilterValue.includes(optionValue)) {
const indexToRemove = newFilterValue.indexOf(optionValue);
newFilterValue.splice(indexToRemove, 1);
} else {
newFilterValue.push(optionValue);
}

setFilterValue(newFilterValue);
setIsFilterDropdownOpen(false);
}

break;
case "Tab":
case "Escape":
setIsFilterDropdownOpen(false);
setActiveItem(null);
break;
case "ArrowUp":
case "ArrowDown":
event.preventDefault();
handleMenuArrowKeys(event.key);
break;
}
};

const onTextInputChange = (
_event: React.FormEvent<HTMLInputElement>,
value: string
) => {
setInputValue(value);
};

const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="typeahead"
onClick={() => {
setIsFilterDropdownOpen(!isFilterDropdownOpen);
}}
isExpanded={isFilterDropdownOpen}
isFullWidth
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={() => {
setIsFilterDropdownOpen(!isFilterDropdownOpen);
}}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
id="typeahead-select-input"
autoComplete="off"
innerRef={textInputRef}
placeholder={category.placeholderText}
{...(activeItem && { "aria-activedescendant": activeItem })}
role="combobox"
isExpanded={isFilterDropdownOpen}
aria-controls="select-typeahead-listbox"
/>

<TextInputGroupUtilities>
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
setInputValue("");
setFilterValue(null);
textInputRef?.current?.focus();
}}
aria-label="Clear input value"
>
<TimesIcon aria-hidden />
</Button>
)}
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);

return (
<ToolbarFilter
Expand All @@ -158,18 +344,15 @@ export const MultiselectFilterControl = <TItem,>({
<Select
className={css(isScrollable && "isScrollable")}
aria-label={category.title}
toggleId={`${category.key}-filter-value-select`}
onToggle={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
selections={selections || []}
onSelect={(_, value) => onFilterSelect(value)}
toggle={toggle}
selected={filterValue}
onOpenChange={(isOpen) => setIsFilterDropdownOpen(isOpen)}
onSelect={(_, selection) => onSelect(selection as string)}
isOpen={isFilterDropdownOpen}
placeholderText={category.placeholderText}
isDisabled={isDisabled || category.selectOptions.length === 0}
variant={SelectVariant.checkbox}
hasInlineFilter
onFilter={onOptionsFilter}
>
{renderSelectOptions(() => true)}
<SelectList id="select-multi-typeahead-checkbox-listbox">
{renderSelectOptions(() => true)}
</SelectList>
</Select>
</ToolbarFilter>
);
Expand Down
Loading

0 comments on commit 3d68556

Please sign in to comment.