Skip to content

Commit

Permalink
feat: custom single option select component
Browse files Browse the repository at this point in the history
Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Sep 19, 2024
1 parent 9a7a2c4 commit d644827
Show file tree
Hide file tree
Showing 9 changed files with 499 additions and 84 deletions.
150 changes: 150 additions & 0 deletions src/components/select/Select.tsx
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;
178 changes: 178 additions & 0 deletions src/components/select/SelectDropdown.tsx
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;
Loading

0 comments on commit d644827

Please sign in to comment.