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 26, 2024
1 parent 54023a5 commit 4c139d7
Show file tree
Hide file tree
Showing 9 changed files with 599 additions and 91 deletions.
259 changes: 259 additions & 0 deletions src/components/select/CustomSelect.tsx
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;
Loading

0 comments on commit 4c139d7

Please sign in to comment.