Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom single option select component [WD-10960] #912

Merged
merged 1 commit into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/components/select/CustomSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import classNames from "classnames";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { FC } from "react";
import {
ClassName,
Field,
ContextualMenu,
PropsWithSpread,
FieldProps,
} from "@canonical/react-components";
import CustomSelectDropdown, {
CustomSelectOption,
getOptionText,
} from "./CustomSelectDropdown";
import useEventListener from "@use-it/event-listener";
import { adjustDropdownHeight } from "util/customSelect";

export type Props = PropsWithSpread<
FieldProps,
{
// Selected option value
value: string;
// Array of options that the select can choose from.
options: CustomSelectOption[];
// Function to run when select value changes.
onChange: (value: string) => void;
// id for the select component
id?: string | null;
// Name for the select element
name?: string;
// Whether if the select is disabled
disabled?: boolean;
// Styling for the wrapping Field component
wrapperClassName?: ClassName;
// The styling for the select toggle button
toggleClassName?: ClassName;
// Whether the select is searchable. Option "auto" is the default, the select will be searchable if it has 5 or more options.
searchable?: "auto" | "always" | "never";
// Whether to focus on the element on initial render.
takeFocus?: boolean;
}
>;

const CustomSelect: FC<Props> = ({
value,
options,
onChange,
id,
name,
disabled,
success,
error,
help,
wrapperClassName,
toggleClassName,
searchable = "auto",
takeFocus,
...fieldProps
}) => {
const [isOpen, setIsOpen] = useState(false);
const validationId = useId();
const defaultSelectId = useId();
const selectId = id || defaultSelectId;
const helpId = useId();
const hasError = !!error;
const searchRef = useRef<HTMLInputElement>(null);
const dropdownListRef = useRef<HTMLUListElement>(null);

useEffect(() => {
if (takeFocus) {
const toggleButton = document.getElementById(selectId);
toggleButton?.focus();
}
}, [takeFocus]);
edlerd marked this conversation as resolved.
Show resolved Hide resolved

useLayoutEffect(() => {
if (isOpen) {
adjustDropdownHeight(dropdownListRef.current, searchRef.current);
}
}, [isOpen]);

useEventListener("resize", () =>
adjustDropdownHeight(dropdownListRef.current, searchRef.current),
);

const selectedOption = options.find((option) => option.value === value);

const toggleLabel = (
<span className="toggle-label u-truncate">
{selectedOption ? getOptionText(selectedOption) : "Select an option"}
</span>
);

const handleSelect = (value: string) => {
setIsOpen(false);
onChange(value);
};

return (
<Field
{...fieldProps}
className={classNames("p-custom-select", wrapperClassName)}
error={error}
forId={selectId}
help={help}
helpId={helpId}
isSelect
success={success}
validationId={validationId}
>
<ContextualMenu
aria-describedby={[help ? helpId : null, success ? validationId : null]
.filter(Boolean)
.join(" ")}
aria-errormessage={hasError ? validationId : undefined}
aria-invalid={hasError}
toggleClassName={classNames(
"p-custom-select__toggle",
"p-form-validation__input",
toggleClassName,
{
active: isOpen,
},
)}
toggleLabel={toggleLabel}
visible={isOpen}
position="left"
toggleDisabled={disabled}
onToggleMenu={(open) => {
// Handle syncing the state when toggling the menu from within the
// contextual menu component e.g. when clicking outside.
if (open !== isOpen) {
setIsOpen(open);
}
}}
toggleProps={{
id: selectId,
}}
className="p-custom-select__wrapper"
>
{(close: () => void) => (
<CustomSelectDropdown
searchable={searchable}
name={name || ""}
options={options || []}
onSelect={handleSelect}
onClose={close}
searchRef={searchRef}
dropdownListRef={dropdownListRef}
/>
)}
</ContextualMenu>
</Field>
);
};

export default CustomSelect;
219 changes: 219 additions & 0 deletions src/components/select/CustomSelectDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { SearchBox } from "@canonical/react-components";
import {
FC,
KeyboardEvent,
LiHTMLAttributes,
ReactNode,
RefObject,
useEffect,
useRef,
useState,
} from "react";
import classnames from "classnames";

export type CustomSelectOption = LiHTMLAttributes<HTMLLIElement> & {
mas-who marked this conversation as resolved.
Show resolved Hide resolved
value: string;
label: ReactNode;
// text used for search, sort and display in toggle button
// text must be provided if label is not a string
text?: string;
disabled?: boolean;
};

interface Props {
searchable?: "auto" | "always" | "never";
name: string;
options: CustomSelectOption[];
onSelect: (value: string) => void;
onClose: () => void;
searchRef: RefObject<HTMLInputElement>;
dropdownListRef: RefObject<HTMLUListElement>;
}

export const getOptionText = (option: CustomSelectOption): string => {
if (option.text) {
return option.text;
}

if (typeof option.label === "string") {
return option.label;
}

throw new Error(
"CustomSelect: options must have a string label or a text property",
);
};

export const sortOptions = (
a: CustomSelectOption,
b: CustomSelectOption,
): number => {
// sort options alphabetically
const textA = getOptionText(a) || a.value;
const textB = getOptionText(b) || b.value;
return textA.localeCompare(textB);
};

const CustomSelectDropdown: FC<Props> = ({
searchable,
name,
options,
onSelect,
onClose,
searchRef,
dropdownListRef,
}) => {
const [search, setSearch] = useState("");
// track selected option index for keyboard actions
const [selectedIndex, setSelectedIndex] = useState(-1);
// use ref to keep a reference to all option HTML elements so we do not need to make DOM calls later for scrolling
const optionsRef = useRef<HTMLLIElement[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const isSearchable =
searchable !== "never" &&
(searchable === "always" || (searchable === "auto" && options.length >= 5));

useEffect(() => {
setTimeout(() => {
if (isSearchable) {
searchRef.current?.focus();
return;
}

dropdownRef.current?.focus();
}, 100);
}, [isSearchable]);

// track selected index from key board action and scroll into view if needed
useEffect(() => {
if (selectedIndex !== -1 && optionsRef.current[selectedIndex]) {
optionsRef.current[selectedIndex].scrollIntoView({
block: "nearest",
inline: "nearest",
});
}
}, [selectedIndex]);

// handle keyboard actions for navigating the select dropdown
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const upDownKeys = ["ArrowUp", "ArrowDown"];

// prevent default browser actions for up, down, enter and escape keys
// also prevent any other event listeners from being called up the DOM tree
if ([...upDownKeys, "Enter", "Escape"].includes(event.key)) {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
}

if (upDownKeys.includes(event.key)) {
setSelectedIndex((prevIndex) => {
const goingUp = event.key === "ArrowUp";
const increment = goingUp ? -1 : 1;
let currIndex = prevIndex + increment;
// skip disabled options for key board action
while (options[currIndex] && options[currIndex]?.disabled) {
currIndex += increment;
}

// consider upper bound for navigating down the list
if (increment > 0) {
return currIndex < options.length ? currIndex : prevIndex;
}

// consider lower bound for navigating up the list
return currIndex >= 0 ? currIndex : prevIndex;
});
}

if (event.key === "Enter" && selectedIndex !== -1) {
onSelect(options[selectedIndex].value);
}

if (event.key === "Escape") {
onClose();
}
};

const handleSearch = (value: string) => {
setSearch(value.toLowerCase());
// reset selected index when search text changes
setSelectedIndex(-1);
optionsRef.current = [];
};

const optionItems = options
// filter options based on search text
?.filter((option) => {
if (!search) return true;
const searchText = getOptionText(option) || option.value;
return searchText.toLowerCase().includes(search);
})
.map((option, idx) => {
return (
<li
{...option}
key={option.value}
onClick={() => onSelect(option.value)}
className={classnames(
"p-list__item",
"p-custom-select__option",
"u-truncate",
{
disabled: option.disabled,
highlight: idx === selectedIndex,
},
)}
// adding option elements to a ref array makes it easier to scroll the element later
// else we'd have to make a DOM call to find the element based on some identifier
ref={(el) => {
if (!el) return;
optionsRef.current[idx] = el;
}}
role="option"
>
<span
className={classnames({
"u-text--muted": option.disabled,
})}
>
{option.label}
</span>
</li>
);
});

return (
<div
className="p-custom-select__dropdown u-no-padding"
role="combobox"
onKeyDownCapture={handleKeyDown}
// allow focus on the dropdown so that keyboard actions can be captured
tabIndex={-1}
ref={dropdownRef}
>
{isSearchable && (
<div className="p-custom-select__search u-no-padding--bottom">
<SearchBox
ref={searchRef}
id={`select-search-${name}`}
name={`select-search-${name}`}
type="text"
aria-label={`Search for ${name}`}
className="u-no-margin--bottom"
onChange={handleSearch}
value={search}
/>
</div>
)}
<ul
className="p-list u-no-margin--bottom"
role="listbox"
ref={dropdownListRef}
>
{optionItems}
</ul>
</div>
);
};

export default CustomSelectDropdown;
Loading
Loading