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 fd389dae01 - Show all commits

View File

@ -1,4 +1,4 @@
import { useState, useId, type SelectHTMLAttributes } from "react"; import { useId, type SelectHTMLAttributes } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { ChevronDownIcon } from "../Icon/Icon"; import { ChevronDownIcon } from "../Icon/Icon";
@ -20,11 +20,8 @@ export interface DropdownProps<T>
hideLabel?: boolean; hideLabel?: boolean;
message?: string; message?: string;
placeholder?: string; placeholder?: string;
/** Controlled mode - parent manages state */ value: string;
value?: string; onChange: (value: string, option: T | undefined) => void;
/** Uncontrolled mode - component manages state with initial value */
defaultValue?: string;
onChange?: (value: string, option: T | undefined) => void;
} }
const wrapperSizeClasses: Record<DropdownSize, string> = { const wrapperSizeClasses: Record<DropdownSize, string> = {
@ -45,6 +42,13 @@ const iconContainerSizeClasses: Record<DropdownSize, string> = {
lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"), lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"),
}; };
// Right padding to reserve space for the chevron icon
const chevronSpacingClasses: Record<DropdownSize, string> = {
sm: "pr-(--control-height-sm)",
md: "pr-(--control-height-md)",
lg: "pr-(--control-height-lg)",
};
const baseClasses = const baseClasses =
"relative inline-flex items-center bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium min-w-[110px]"; "relative inline-flex items-center bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium min-w-[110px]";
@ -75,33 +79,19 @@ export default function Dropdown<T>({
placeholder, placeholder,
className = "", className = "",
value, value,
defaultValue,
onChange, onChange,
...props ...props
}: DropdownProps<T>) { }: DropdownProps<T>) {
const selectId = useId(); 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 handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
if (!isControlled) {
setInternalValue(newValue);
}
const option = options.find((o) => getOptionValue(o) === newValue); const option = options.find((o) => getOptionValue(o) === newValue);
onChange?.(newValue, option); onChange(newValue, option);
}; };
// Derived values // Derived values
const hasValue = currentValue !== ""; const hasValue = value !== "";
const useFixedWidth = fullWidth || customWidth; const useFixedWidth = fullWidth || customWidth;
const widthStyle = customWidth ? { width: customWidth } : undefined; const widthStyle = customWidth ? { width: customWidth } : undefined;
const stateClasses = error ? errorStateClasses : defaultStateClasses; const stateClasses = error ? errorStateClasses : defaultStateClasses;
@ -118,36 +108,28 @@ export default function Dropdown<T>({
"", "",
); );
// Invisible element that sets minimum width based on longest option // Invisible element that sets minimum width based on longest option.
// Includes right padding to account for the absolutely positioned chevron.
// A non-breaking space is appended to compensate for native select text
// rendering differences.
const autoWidthSizer = !useFixedWidth && ( const autoWidthSizer = !useFixedWidth && (
<span <span
className={clsx("invisible pl-(--padding-md)", textSizeClasses[size])} className={clsx(
"invisible pl-(--padding-md)",
chevronSpacingClasses[size],
textSizeClasses[size],
)}
aria-hidden="true" aria-hidden="true"
> >
{longestLabel} {longestLabel}&nbsp;
</span> </span>
); );
// Shows current selection or placeholder // Dropdown arrow icon - absolutely positioned on the right
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 = ( const chevron = (
<div <div
className={clsx( className={clsx(
"flex items-center justify-center shrink-0 text-base-ink-placeholder pointer-events-none", "absolute right-0 top-0 flex items-center justify-center text-base-ink-placeholder pointer-events-none",
iconContainerSizeClasses[size], iconContainerSizeClasses[size],
)} )}
> >
@ -155,13 +137,24 @@ export default function Dropdown<T>({
</div> </div>
); );
// Native select (invisible, handles interaction) // Native select with appearance-none for custom styling while retaining
// native browser behavior and mobile OS pickers.
// Read here for more details:
// https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Advanced_form_styling#selects_and_datalists
// New styling techniques are not widely supported yet:
// https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Customizable_select
const selectElement = ( const selectElement = (
<select <select
id={selectId} id={selectId}
aria-label={hideLabel ? label : undefined} aria-label={hideLabel ? label : undefined}
className="absolute inset-0 opacity-0 cursor-pointer" className={clsx(
value={currentValue} "appearance-none bg-transparent cursor-pointer outline-none",
"absolute inset-0 pl-(--padding-md)",
chevronSpacingClasses[size],
textSizeClasses[size],
hasValue ? "text-base-ink-strong" : "text-base-ink-placeholder",
)}
value={value}
onChange={handleChange} onChange={handleChange}
{...props} {...props}
> >
@ -192,7 +185,6 @@ export default function Dropdown<T>({
style={widthStyle} style={widthStyle}
> >
{autoWidthSizer} {autoWidthSizer}
{displayLabel}
{chevron} {chevron}
{selectElement} {selectElement}
</div> </div>