Start time grid #62

Merged
stne3960 merged 30 commits from start_time_grid into main 2026-01-16 14:17:09 +01:00
Showing only changes of commit 1be7ba6952 - Show all commits

View 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>
);
}