-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: custom single option select component
Signed-off-by: Mason Hu <[email protected]>
- Loading branch information
Showing
12 changed files
with
754 additions
and
90 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | ||
|
||
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> & { | ||
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; |
Oops, something went wrong.