-
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
9 changed files
with
499 additions
and
84 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,150 @@ | ||
import classNames from "classnames"; | ||
import { useEffect, useId, useState } from "react"; | ||
import type { FC, ReactNode } from "react"; | ||
|
||
import { ClassName, Field, ContextualMenu } from "@canonical/react-components"; | ||
import SelectDropdown, { CustomOption } from "./SelectDropdown"; | ||
|
||
export interface Props { | ||
// Selected option value | ||
value: string; | ||
// Array of options that the select can choose from. | ||
options: CustomOption[]; | ||
// 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 the form field is required. | ||
required?: boolean; | ||
// Whether if the select is disabled | ||
disabled?: boolean; | ||
// The content for success validation. | ||
success?: ReactNode; | ||
// The content for caution validation. | ||
caution?: ReactNode; | ||
// The content for error validation. | ||
error?: ReactNode; | ||
// Help text to show below the field. | ||
help?: ReactNode; | ||
// The label for the form field. | ||
label?: ReactNode; | ||
// Styling for the wrapping Field component | ||
wrapperClassName?: ClassName; | ||
// The styling for the select toggle button | ||
toggleClassName?: ClassName; | ||
// The styling for the form field label | ||
labelClassName?: string | null; | ||
// Whether the select is searchable | ||
searchable?: boolean; | ||
// Whether the form field should have a stacked appearance. | ||
stacked?: boolean; | ||
// Whether to focus on the element on initial render. | ||
takeFocus?: boolean; | ||
} | ||
|
||
const Select: FC<Props> = ({ | ||
value, | ||
options, | ||
onChange, | ||
id, | ||
name, | ||
required, | ||
disabled, | ||
success, | ||
caution, | ||
error, | ||
help, | ||
label, | ||
wrapperClassName, | ||
toggleClassName, | ||
labelClassName, | ||
searchable, | ||
stacked, | ||
takeFocus, | ||
}) => { | ||
const [isOpen, setIsOpen] = useState(false); | ||
const validationId = useId(); | ||
const defaultSelectId = useId(); | ||
const selectId = id || defaultSelectId; | ||
const helpId = useId(); | ||
const hasError = !!error; | ||
|
||
useEffect(() => { | ||
if (takeFocus) { | ||
const toggleButton = document.getElementById(selectId); | ||
toggleButton?.focus(); | ||
} | ||
}, [takeFocus]); | ||
|
||
const selectedOption = options?.find((option) => option.value === value); | ||
|
||
const toggleLabel = ( | ||
<span className="p-select__label u-truncate"> | ||
{selectedOption?.text || "Select an option"} | ||
</span> | ||
); | ||
|
||
const handleSelect = (value: string) => { | ||
setIsOpen(false); | ||
if (value !== "") { | ||
onChange(value); | ||
} | ||
}; | ||
|
||
return ( | ||
<Field | ||
caution={caution} | ||
className={classNames("p-select", wrapperClassName)} | ||
error={error} | ||
forId={selectId} | ||
help={help} | ||
helpId={helpId} | ||
isSelect={true} | ||
label={label} | ||
labelClassName={labelClassName} | ||
required={required} | ||
stacked={stacked} | ||
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-select__toggle", | ||
"p-form-validation__input", | ||
toggleClassName, | ||
)} | ||
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-select__wrapper" | ||
> | ||
<SelectDropdown | ||
searchable={searchable} | ||
name={name || ""} | ||
options={options || []} | ||
onSelect={handleSelect} | ||
/> | ||
</ContextualMenu> | ||
</Field> | ||
); | ||
}; | ||
|
||
export default Select; |
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,178 @@ | ||
import { SearchBox } from "@canonical/react-components"; | ||
import { | ||
FC, | ||
KeyboardEvent, | ||
LiHTMLAttributes, | ||
ReactNode, | ||
useEffect, | ||
useRef, | ||
useState, | ||
} from "react"; | ||
import classnames from "classnames"; | ||
|
||
export type CustomOption = LiHTMLAttributes<HTMLLIElement> & { | ||
value: string; | ||
label: ReactNode; | ||
// text used for search, sort and display in toggle button | ||
text: string; | ||
title?: string; | ||
disabled?: boolean; | ||
}; | ||
|
||
interface Props { | ||
searchable?: boolean; | ||
name: string; | ||
options: CustomOption[]; | ||
onSelect: (value: string) => void; | ||
} | ||
|
||
const SelectDropdown: FC<Props> = ({ searchable, name, options, onSelect }) => { | ||
const [search, setSearch] = useState(""); | ||
// track selected option index for keyboard actions | ||
const [selectedIndex, setSelectedIndex] = useState(-1); | ||
const searchRef = useRef<HTMLInputElement>(null); | ||
// 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[]>([]); | ||
|
||
useEffect(() => { | ||
if (searchable) { | ||
setTimeout(() => { | ||
searchRef.current?.focus(); | ||
}, 100); | ||
} | ||
}, []); | ||
|
||
// 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") { | ||
onSelect(""); | ||
} | ||
}; | ||
|
||
// filter options based on search text | ||
if (search) { | ||
options = options?.filter((option) => { | ||
let searchText = option.text; | ||
if (!searchText) { | ||
if (typeof option.label === "string") { | ||
searchText = option.label; | ||
} else { | ||
searchText = option.value; | ||
} | ||
} | ||
|
||
return searchText.toLowerCase().includes(search.toLowerCase()); | ||
}); | ||
} | ||
|
||
const handleSearch = (value: string) => { | ||
setSearch(value); | ||
// reset selected index when search text changes | ||
setSelectedIndex(-1); | ||
}; | ||
|
||
const optionItems = options?.map((option, idx) => { | ||
return ( | ||
<li | ||
key={option.value} | ||
onClick={() => onSelect(option.value)} | ||
title={option.title} | ||
className={classnames( | ||
"p-list__item", | ||
"p-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-select__dropdown" | ||
role="combobox" | ||
onKeyDownCapture={handleKeyDown} | ||
> | ||
{searchable && ( | ||
<div className="p-select__search"> | ||
<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"> | ||
{optionItems} | ||
</ul> | ||
</div> | ||
); | ||
}; | ||
|
||
export default SelectDropdown; |
Oops, something went wrong.