Start time grid #62
@ -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}
|
||||||
</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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user