Start time grid #62
225
frontend/src/components/Dropdown/Dropdown.tsx
Normal file
225
frontend/src/components/Dropdown/Dropdown.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useId, type SelectHTMLAttributes } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ChevronDownIcon } from "../Icon/Icon";
|
||||||
|
|
||||||
|
export type DropdownSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface DropdownProps<T>
|
||||||
|
extends Omit<
|
||||||
|
SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
|
"size" | "onChange" | "value" | "defaultValue"
|
||||||
|
> {
|
||||||
|
options: T[];
|
||||||
|
getOptionValue: (option: T) => string;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
size?: DropdownSize;
|
||||||
|
error?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
label: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
message?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Controlled mode - parent manages state */
|
||||||
|
value?: string;
|
||||||
|
/** Uncontrolled mode - component manages state with initial value */
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange?: (value: string, option: T | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperSizeClasses: Record<DropdownSize, string> = {
|
||||||
|
sm: clsx("h-(--control-height-sm)", "rounded-(--border-radius-sm)"),
|
||||||
|
md: clsx("h-(--control-height-md)", "rounded-(--border-radius-sm)"),
|
||||||
|
lg: clsx("h-(--control-height-lg)", "rounded-(--border-radius-md)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const textSizeClasses: Record<DropdownSize, string> = {
|
||||||
|
sm: "body-normal-md",
|
||||||
|
md: "body-normal-md",
|
||||||
|
lg: "body-normal-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconContainerSizeClasses: Record<DropdownSize, string> = {
|
||||||
|
sm: clsx("w-(--control-height-sm) h-(--control-height-sm)"),
|
||||||
|
md: clsx("w-(--control-height-md) h-(--control-height-md)"),
|
||||||
|
lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses =
|
||||||
|
"relative inline-flex items-center bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium min-w-[110px]";
|
||||||
|
|
||||||
|
const defaultStateClasses = clsx(
|
||||||
|
"hover:border-base-ink-placeholder",
|
||||||
|
"focus-within:border-primary",
|
||||||
|
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorStateClasses = clsx(
|
||||||
|
"border-fire-100",
|
||||||
|
"outline outline-fire-100 outline-[length:var(--border-width-sm)]",
|
||||||
|
"focus-within:border-primary",
|
||||||
|
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Dropdown<T>({
|
||||||
|
options,
|
||||||
|
getOptionValue,
|
||||||
|
getOptionLabel,
|
||||||
|
size = "md",
|
||||||
|
error = false,
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
label,
|
||||||
|
hideLabel = false,
|
||||||
|
message,
|
||||||
|
placeholder,
|
||||||
|
className = "",
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: DropdownProps<T>) {
|
||||||
|
const selectId = useId();
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
|
||||||
|
const currentValue = isControlled ? value : internalValue;
|
||||||
|
|
||||||
|
const selectedOption = options.find(
|
||||||
|
(o) => getOptionValue(o) === currentValue,
|
||||||
|
);
|
||||||
|
const selectedLabel = selectedOption
|
||||||
|
? getOptionLabel(selectedOption)
|
||||||
|
: placeholder || "";
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
const option = options.find((o) => getOptionValue(o) === newValue);
|
||||||
|
onChange?.(newValue, option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derived values
|
||||||
|
const hasValue = currentValue !== "";
|
||||||
|
const useFixedWidth = fullWidth || customWidth;
|
||||||
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||||
|
const showVisibleLabel = !hideLabel;
|
||||||
|
|
||||||
|
// Find longest label for auto-width sizing, otherwise the dropdown will
|
||||||
|
// resize when different options are selected
|
||||||
|
const allLabels = [
|
||||||
|
placeholder,
|
||||||
|
...options.map((o) => getOptionLabel(o)),
|
||||||
|
].filter(Boolean);
|
||||||
|
const longestLabel = allLabels.reduce(
|
||||||
|
(a, b) => (a!.length > b!.length ? a : b),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invisible element that sets minimum width based on longest option
|
||||||
|
const autoWidthSizer = !useFixedWidth && (
|
||||||
|
<span
|
||||||
|
className={clsx("invisible pl-(--padding-md)", textSizeClasses[size])}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{longestLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shows current selection or placeholder
|
||||||
|
const displayLabel = (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
textSizeClasses[size],
|
||||||
|
useFixedWidth
|
||||||
|
? "flex-1 pl-(--padding-md)"
|
||||||
|
: "absolute left-0 pl-(--padding-md)",
|
||||||
|
hasValue ? "text-base-ink-strong" : "text-base-ink-placeholder",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dropdown arrow icon
|
||||||
|
const chevron = (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center shrink-0 text-base-ink-placeholder pointer-events-none",
|
||||||
|
iconContainerSizeClasses[size],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon size={size} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native select (invisible, handles interaction)
|
||||||
|
const selectElement = (
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
aria-label={hideLabel ? label : undefined}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={getOptionValue(o)} value={getOptionValue(o)}>
|
||||||
|
{getOptionLabel(o)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The styled dropdown control
|
||||||
|
const dropdownField = (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
baseClasses,
|
||||||
|
wrapperSizeClasses[size],
|
||||||
|
stateClasses,
|
||||||
|
fullWidth && "w-full",
|
||||||
|
!fullWidth && !customWidth && "w-fit",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{autoWidthSizer}
|
||||||
|
{displayLabel}
|
||||||
|
{chevron}
|
||||||
|
{selectElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showVisibleLabel && !message) {
|
||||||
|
return dropdownField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-(--spacing-sm)",
|
||||||
|
fullWidth && "w-full",
|
||||||
|
!fullWidth && !customWidth && "w-fit",
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{showVisibleLabel && (
|
||||||
|
<label htmlFor={selectId} className="body-bold-md text-base-ink-strong">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{dropdownField}
|
||||||
|
{message && (
|
||||||
|
<span className="body-light-sm text-base-ink-strong">{message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user