-
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
599 additions
and
91 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,259 @@ | ||
import classNames from "classnames"; | ||
import { useEffect, useId, useLayoutEffect, useState } from "react"; | ||
import type { FC, ReactNode } from "react"; | ||
import { ClassName, Field, ContextualMenu } from "@canonical/react-components"; | ||
import CustomSelectDropdown, { | ||
CustomSelectOption, | ||
getOptionText, | ||
} from "./CustomSelectDropdown"; | ||
import useEventListener from "@use-it/event-listener"; | ||
|
||
const DROPDOWN_MAX_HEIGHT = 16 * 30; // 30rem with base 16px | ||
|
||
const adjustDropdownHeightBelow = (dropdown: HTMLElement) => { | ||
const dropdownRect = dropdown.getBoundingClientRect(); | ||
const bottomToViewportBottom = window.innerHeight - dropdownRect.bottom; | ||
const dropdownHeight = dropdown?.offsetHeight || 0; | ||
|
||
// If the dropdown is cut off at the bottom of the viewport | ||
// adjust the height to fit within the viewport minus fixed margin. | ||
// This usually becomes an issue when the dropdown is at the bottom of the viewport or screen getting smaller. | ||
if (bottomToViewportBottom < 0) { | ||
const adjustedHeight = dropdownHeight + bottomToViewportBottom - 20; | ||
dropdown.style.height = `${adjustedHeight}px`; | ||
dropdown.style.maxHeight = `${adjustedHeight}px`; | ||
return; | ||
} | ||
|
||
// If the dropdown is taller than the max height, do not adjust. | ||
if (dropdownHeight >= DROPDOWN_MAX_HEIGHT) { | ||
return; | ||
} | ||
|
||
// If the dropdown does not have overflow, do not adjust. | ||
const hasOverflow = dropdown.scrollHeight > dropdown.clientHeight; | ||
if (!hasOverflow) { | ||
return; | ||
} | ||
|
||
// If the dropdown is not cut off at the bottom of the viewport | ||
// adjust the height of the dropdown so that its bottom edge is 20px from the bottom of the viewport. | ||
const topToViewportBottom = window.innerHeight - dropdownRect.top; | ||
const adjustedHeight = topToViewportBottom - 20; | ||
dropdown.style.height = `${adjustedHeight}px`; | ||
dropdown.style.maxHeight = `${adjustedHeight}px`; | ||
}; | ||
|
||
const adjustDropdownHeightAbove = (dropdown: HTMLElement) => { | ||
// The search height is subtracted (if necessary) so that no options will be hidden behind the search input. | ||
const search = document.querySelector( | ||
".p-custom-select__search", | ||
) as HTMLElement; | ||
const searchRect = search?.getBoundingClientRect(); | ||
const searchHeight = searchRect?.height || 0; | ||
const dropdownRect = dropdown.getBoundingClientRect(); | ||
const bottomToViewportTop = dropdownRect.bottom; | ||
const dropdownHeight = dropdown?.offsetHeight || 0; | ||
|
||
// If the dropdown is taller than the top edge of the viewport | ||
// adjust the height to fit within the viewport minus fixed margin. | ||
if (dropdownHeight > bottomToViewportTop) { | ||
const adjustedHeight = bottomToViewportTop - searchHeight - 20; | ||
dropdown.style.height = `${adjustedHeight}px`; | ||
dropdown.style.maxHeight = `${adjustedHeight}px`; | ||
return; | ||
} | ||
|
||
// If the dropdown is taller than the max height, do not adjust. | ||
if (dropdownHeight >= DROPDOWN_MAX_HEIGHT) { | ||
return; | ||
} | ||
|
||
// If the dropdown does not have overflow, do not adjust. | ||
const hasOverflow = dropdown.scrollHeight > dropdown.clientHeight; | ||
if (!hasOverflow) { | ||
return; | ||
} | ||
|
||
// If the dropdown is not cut off at the top of the viewport | ||
// adjust the height of the dropdown so that its top edge is 20px from the top of the viewport. | ||
const adjustedHeight = bottomToViewportTop - searchHeight - 20; | ||
dropdown.style.height = `${adjustedHeight}px`; | ||
dropdown.style.maxHeight = `${adjustedHeight}px`; | ||
}; | ||
|
||
const adjustDropdownHeight = () => { | ||
const dropdown = document.querySelector( | ||
".p-custom-select__dropdown .p-list", | ||
) as HTMLElement; | ||
const dropdownRect = dropdown.getBoundingClientRect(); | ||
|
||
const toggle = document.querySelector( | ||
".p-custom-select__toggle", | ||
) as HTMLElement; | ||
const toggleRect = toggle.getBoundingClientRect(); | ||
|
||
const dropdownIsAbove = toggleRect.top > dropdownRect.bottom; | ||
if (!dropdownIsAbove) { | ||
adjustDropdownHeightBelow(dropdown); | ||
return; | ||
} | ||
|
||
adjustDropdownHeightAbove(dropdown); | ||
}; | ||
|
||
export interface Props { | ||
// 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 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. If "auto" is passed, the select will be searchable if it has 5 or more options. | ||
searchable?: "auto" | "always"; | ||
// Whether the form field should have a stacked appearance. | ||
stacked?: boolean; | ||
// Whether to focus on the element on initial render. | ||
takeFocus?: boolean; | ||
} | ||
|
||
const CustomSelect: 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]); | ||
|
||
useLayoutEffect(() => { | ||
if (isOpen) { | ||
adjustDropdownHeight(); | ||
} | ||
}, [isOpen]); | ||
|
||
useEventListener("resize", adjustDropdownHeight); | ||
|
||
const selectedOption = options.find((option) => option.value === value); | ||
|
||
const toggleLabel = ( | ||
<span className="p-custom-select__label u-truncate"> | ||
{selectedOption ? getOptionText(selectedOption) : "Select an option"} | ||
</span> | ||
); | ||
|
||
const handleSelect = (value: string) => { | ||
setIsOpen(false); | ||
onChange(value); | ||
}; | ||
|
||
return ( | ||
<Field | ||
caution={caution} | ||
className={classNames("p-custom-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-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} | ||
/> | ||
)} | ||
</ContextualMenu> | ||
</Field> | ||
); | ||
}; | ||
|
||
export default CustomSelect; |
Oops, something went wrong.