92a3337aeb
CI Pipeline / test (20) (pull_request) Successful in 2m41s
CI Pipeline / test (18) (pull_request) Successful in 3m21s
CI Pipeline / e2e (chromium) (pull_request) Failing after 1m25s
CI Pipeline / e2e (firefox) (pull_request) Failing after 1m24s
CI Pipeline / e2e (webkit) (pull_request) Failing after 1m24s
CI Pipeline / visual-regression (pull_request) Failing after 1m53s
CI Pipeline / performance (pull_request) Failing after 1m31s
CI Pipeline / lint (pull_request) Failing after 1m5s
CI Pipeline / storybook (pull_request) Successful in 1m36s
CI Pipeline / build (pull_request) Failing after 1m19s
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, {
|
|
forwardRef,
|
|
useId,
|
|
useState,
|
|
useRef,
|
|
useEffect,
|
|
useCallback,
|
|
memo,
|
|
} from "react";
|
|
import SelectDropdown from "./SelectDropdown";
|
|
import SelectOption from "./SelectOption";
|
|
|
|
interface SelectOptionData {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface SelectProps {
|
|
id?: string;
|
|
label?: string;
|
|
labelVariant?: "default" | "horizontal";
|
|
size?: "small" | "medium" | "large";
|
|
state?: "default" | "hover" | "focus";
|
|
disabled?: boolean;
|
|
error?: boolean;
|
|
placeholder?: string;
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
value?: string;
|
|
onChange?: (data: { target: { value: string; text: string } }) => void;
|
|
options?: SelectOptionData[];
|
|
}
|
|
|
|
const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
|
(
|
|
{
|
|
id,
|
|
label,
|
|
labelVariant = "default",
|
|
size = "medium",
|
|
state = "default",
|
|
disabled = false,
|
|
error = false,
|
|
placeholder = "Select an option",
|
|
className = "",
|
|
children,
|
|
value,
|
|
onChange,
|
|
options,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const generatedId = useId();
|
|
const selectId = id || `select-${generatedId}`;
|
|
const labelId = `${selectId}-label`;
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [selectedValue, setSelectedValue] = useState(value || "");
|
|
const selectRef = useRef<HTMLButtonElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Handle click outside to close menu
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
menuRef.current &&
|
|
!menuRef.current.contains(event.target as Node) &&
|
|
selectRef.current &&
|
|
!selectRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () =>
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle option selection
|
|
const handleOptionSelect = useCallback(
|
|
(optionValue: string, optionText: string) => {
|
|
setSelectedValue(optionValue);
|
|
setIsOpen(false);
|
|
if (onChange) {
|
|
onChange({ target: { value: optionValue, text: optionText } });
|
|
}
|
|
// Return focus to the select button for accessibility
|
|
if (selectRef.current) {
|
|
selectRef.current.focus();
|
|
}
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
// Handle select button click
|
|
const handleSelectClick = useCallback(() => {
|
|
if (!disabled) {
|
|
setIsOpen(!isOpen);
|
|
}
|
|
}, [disabled, isOpen]);
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (disabled) return;
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
setIsOpen(!isOpen);
|
|
} else if (e.key === "Escape") {
|
|
setIsOpen(false);
|
|
}
|
|
},
|
|
[disabled, isOpen],
|
|
);
|
|
|
|
const getSizeStyles = (): string => {
|
|
const baseStyles = "w-full";
|
|
|
|
switch (size) {
|
|
case "small":
|
|
const smallHeight =
|
|
labelVariant === "horizontal" ? "h-[30px]" : "h-[32px]";
|
|
return `${baseStyles} ${smallHeight} pl-[12px] pr-[36px] py-[8px] text-[10px] leading-[14px]`;
|
|
case "medium":
|
|
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
|
|
case "large":
|
|
return `${baseStyles} h-[40px] pl-[12px] pr-[40px] py-[8px] text-[16px] leading-[24px]`;
|
|
default:
|
|
return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`;
|
|
}
|
|
};
|
|
|
|
const getLabelSizeStyles = (): string => {
|
|
switch (size) {
|
|
case "small":
|
|
return "text-[12px] leading-[14px]";
|
|
case "medium":
|
|
return "text-[14px] leading-[16px]";
|
|
case "large":
|
|
return "text-[16px] leading-[20px]";
|
|
default:
|
|
return "text-[14px] leading-[16px]";
|
|
}
|
|
};
|
|
|
|
const getStateStyles = (): {
|
|
select: string;
|
|
label: string;
|
|
} => {
|
|
if (disabled) {
|
|
return {
|
|
select:
|
|
"bg-[var(--color-content-default-secondary)] border-[var(--color-border-default-tertiary)] cursor-not-allowed opacity-40",
|
|
label: "text-[var(--color-content-default-secondary)]",
|
|
};
|
|
}
|
|
|
|
if (error) {
|
|
return {
|
|
select: "border-[var(--color-border-default-utility-negative)]",
|
|
label: "text-[var(--color-content-default-secondary)]",
|
|
};
|
|
}
|
|
|
|
switch (state) {
|
|
case "hover":
|
|
return {
|
|
select:
|
|
"border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]",
|
|
label: "text-[var(--color-content-default-secondary)]",
|
|
};
|
|
case "focus":
|
|
return {
|
|
select:
|
|
"border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]",
|
|
label: "text-[var(--color-content-default-secondary)]",
|
|
};
|
|
default:
|
|
return {
|
|
select: "border-[var(--color-border-default-tertiary)]",
|
|
label: "text-[var(--color-content-default-secondary)]",
|
|
};
|
|
}
|
|
};
|
|
|
|
const getBorderRadius = (): string => {
|
|
switch (size) {
|
|
case "small":
|
|
return "rounded-[var(--measures-radius-small)]";
|
|
case "medium":
|
|
return "rounded-[var(--measures-radius-medium)]";
|
|
case "large":
|
|
return "rounded-[var(--measures-radius-large)]";
|
|
default:
|
|
return "rounded-[var(--measures-radius-medium)]";
|
|
}
|
|
};
|
|
|
|
const sizeStyles = getSizeStyles();
|
|
const labelSizeStyles = getLabelSizeStyles();
|
|
const stateStyles = getStateStyles();
|
|
const borderRadius = getBorderRadius();
|
|
|
|
const selectClasses = `
|
|
${sizeStyles}
|
|
${stateStyles.select}
|
|
${borderRadius}
|
|
bg-[var(--color-background-default-primary)]
|
|
text-[var(--color-content-default-primary)]
|
|
border
|
|
font-inter
|
|
font-normal
|
|
appearance-none
|
|
cursor-pointer
|
|
transition-all
|
|
duration-200
|
|
focus:outline-none
|
|
focus-visible:border focus-visible:border-[var(--color-border-default-utility-info)] focus-visible:shadow-[0_0_5px_3px_#3281F8]
|
|
text-left
|
|
justify-start
|
|
hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]
|
|
${className}
|
|
`
|
|
.trim()
|
|
.replace(/\s+/g, " ");
|
|
|
|
const labelClasses = `
|
|
${labelSizeStyles}
|
|
${stateStyles.label}
|
|
font-inter
|
|
font-medium
|
|
block
|
|
mb-[4px]
|
|
`
|
|
.trim()
|
|
.replace(/\s+/g, " ");
|
|
|
|
const containerClasses =
|
|
labelVariant === "horizontal"
|
|
? "flex items-center gap-[12px]"
|
|
: "flex flex-col";
|
|
|
|
// Get display text for selected value
|
|
const getDisplayText = (): string => {
|
|
if (!selectedValue) return placeholder;
|
|
|
|
// Handle options prop
|
|
if (options && Array.isArray(options)) {
|
|
const selectedOption = options.find(
|
|
(option) => option.value === selectedValue,
|
|
);
|
|
return selectedOption ? selectedOption.label : placeholder;
|
|
}
|
|
|
|
// Handle children (option elements)
|
|
const selectedOption = React.Children.toArray(children).find(
|
|
(child) =>
|
|
React.isValidElement(child) && child.props.value === selectedValue,
|
|
) as
|
|
| React.ReactElement<{ value: string; children: React.ReactNode }>
|
|
| undefined;
|
|
return selectedOption
|
|
? String(selectedOption.props.children)
|
|
: placeholder;
|
|
};
|
|
|
|
return (
|
|
<div className={containerClasses}>
|
|
{label && (
|
|
<label
|
|
id={labelId}
|
|
htmlFor={selectId}
|
|
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
|
|
>
|
|
{label}
|
|
</label>
|
|
)}
|
|
<div className="relative">
|
|
<button
|
|
ref={selectRef}
|
|
id={selectId}
|
|
disabled={disabled}
|
|
className={selectClasses}
|
|
aria-labelledby={label ? labelId : undefined}
|
|
aria-invalid={error}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
onClick={handleSelectClick}
|
|
onKeyDown={handleKeyDown}
|
|
{...props}
|
|
>
|
|
<span className="text-left">{getDisplayText()}</span>
|
|
</button>
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
|
|
<svg
|
|
className={`${
|
|
size === "large" ? "w-5 h-5" : "w-4 h-4"
|
|
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
|
|
isOpen ? "rotate-180" : ""
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div
|
|
ref={menuRef}
|
|
className="absolute top-full left-0 right-0 z-50 mt-1"
|
|
>
|
|
<SelectDropdown>
|
|
{options && Array.isArray(options)
|
|
? options.map((option) => (
|
|
<SelectOption
|
|
key={option.value}
|
|
selected={option.value === selectedValue}
|
|
size={size}
|
|
onClick={() =>
|
|
handleOptionSelect(option.value, option.label)
|
|
}
|
|
>
|
|
{option.label}
|
|
</SelectOption>
|
|
))
|
|
: React.Children.map(children, (child) => {
|
|
if (
|
|
React.isValidElement(child) &&
|
|
child.type === "option"
|
|
) {
|
|
const optionProps = child.props as {
|
|
value: string;
|
|
children: React.ReactNode;
|
|
};
|
|
return (
|
|
<SelectOption
|
|
key={optionProps.value}
|
|
selected={optionProps.value === selectedValue}
|
|
size={size}
|
|
onClick={() =>
|
|
handleOptionSelect(
|
|
optionProps.value,
|
|
String(optionProps.children),
|
|
)
|
|
}
|
|
>
|
|
{optionProps.children}
|
|
</SelectOption>
|
|
);
|
|
}
|
|
return child;
|
|
})}
|
|
</SelectDropdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
Select.displayName = "Select";
|
|
|
|
export default memo(Select);
|