Update select input component
This commit is contained in:
@@ -75,55 +75,11 @@ export default function ComponentsPreview() {
|
|||||||
|
|
||||||
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
<div>
|
|
||||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
|
|
||||||
All Sizes
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
|
||||||
<SelectInput
|
|
||||||
label="Small Select Input"
|
|
||||||
placeholder="Choose an option"
|
|
||||||
size="small"
|
|
||||||
value={selectValue}
|
|
||||||
onChange={(data) => setSelectValue(data.target.value)}
|
|
||||||
options={[
|
|
||||||
{ value: "option1", label: "Option 1" },
|
|
||||||
{ value: "option2", label: "Option 2" },
|
|
||||||
{ value: "option3", label: "Option 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
label="Medium Select Input"
|
|
||||||
placeholder="Choose an option"
|
|
||||||
size="medium"
|
|
||||||
value={selectValue}
|
|
||||||
onChange={(data) => setSelectValue(data.target.value)}
|
|
||||||
options={[
|
|
||||||
{ value: "option1", label: "Option 1" },
|
|
||||||
{ value: "option2", label: "Option 2" },
|
|
||||||
{ value: "option3", label: "Option 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
label="Large Select Input"
|
|
||||||
placeholder="Choose an option"
|
|
||||||
size="large"
|
|
||||||
value={selectValue}
|
|
||||||
onChange={(data) => setSelectValue(data.target.value)}
|
|
||||||
options={[
|
|
||||||
{ value: "option1", label: "Option 1" },
|
|
||||||
{ value: "option2", label: "Option 2" },
|
|
||||||
{ value: "option3", label: "Option 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
|
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
|
||||||
States
|
States
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Default Select Input"
|
label="Default Select Input"
|
||||||
placeholder="Choose an option"
|
placeholder="Choose an option"
|
||||||
@@ -134,6 +90,17 @@ export default function ComponentsPreview() {
|
|||||||
{ value: "option3", label: "Option 3" },
|
{ value: "option3", label: "Option 3" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label="Interactive Select Input (click = active)"
|
||||||
|
placeholder="Choose an option"
|
||||||
|
value={selectValue}
|
||||||
|
onChange={(data) => setSelectValue(data.target.value)}
|
||||||
|
options={[
|
||||||
|
{ value: "option1", label: "Option 1" },
|
||||||
|
{ value: "option2", label: "Option 2" },
|
||||||
|
{ value: "option3", label: "Option 3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Disabled Select Input"
|
label="Disabled Select Input"
|
||||||
placeholder="Choose an option"
|
placeholder="Choose an option"
|
||||||
@@ -158,36 +125,6 @@ export default function ComponentsPreview() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
|
|
||||||
Label Variants
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
|
||||||
<SelectInput
|
|
||||||
label="Default Label"
|
|
||||||
placeholder="Choose an option"
|
|
||||||
value=""
|
|
||||||
labelVariant="default"
|
|
||||||
options={[
|
|
||||||
{ value: "option1", label: "Option 1" },
|
|
||||||
{ value: "option2", label: "Option 2" },
|
|
||||||
{ value: "option3", label: "Option 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
label="Horizontal Label"
|
|
||||||
placeholder="Choose an option"
|
|
||||||
value=""
|
|
||||||
labelVariant="horizontal"
|
|
||||||
options={[
|
|
||||||
{ value: "option1", label: "Option 1" },
|
|
||||||
{ value: "option2", label: "Option 2" },
|
|
||||||
{ value: "option3", label: "Option 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
labelVariant = "default",
|
state: externalState = "default",
|
||||||
size = "medium",
|
|
||||||
state = "default",
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
error = false,
|
error = false,
|
||||||
placeholder = "Select an option",
|
placeholder = "Choose an option",
|
||||||
className = "",
|
className = "",
|
||||||
children,
|
children,
|
||||||
value,
|
value,
|
||||||
@@ -45,6 +43,14 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
const selectRef = useRef<HTMLButtonElement>(null);
|
const selectRef = useRef<HTMLButtonElement>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Internal state management: track if focused and how (mouse vs keyboard)
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null);
|
||||||
|
const wasMouseDownRef = useRef(false);
|
||||||
|
|
||||||
|
// Determine if we should auto-manage focus (only when state is "default" or undefined)
|
||||||
|
const shouldAutoManageFocus = externalState === "default" || externalState === undefined;
|
||||||
|
|
||||||
// Sync internal state with external value prop
|
// Sync internal state with external value prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== undefined && value !== selectedValue) {
|
if (value !== undefined && value !== selectedValue) {
|
||||||
@@ -69,7 +75,6 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange({ target: { value: optionValue, text: optionText } });
|
onChange({ target: { value: optionValue, text: optionText } });
|
||||||
}
|
}
|
||||||
// Return focus to the select button for accessibility
|
|
||||||
if (selectRef.current) {
|
if (selectRef.current) {
|
||||||
selectRef.current.focus();
|
selectRef.current.focus();
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,13 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle mouse down to detect mouse clicks
|
||||||
|
const handleMouseDown = useCallback(() => {
|
||||||
|
if (!disabled && shouldAutoManageFocus) {
|
||||||
|
wasMouseDownRef.current = true;
|
||||||
|
}
|
||||||
|
}, [disabled, shouldAutoManageFocus]);
|
||||||
|
|
||||||
// Handle select button click
|
// Handle select button click
|
||||||
const handleSelectClick = useCallback(() => {
|
const handleSelectClick = useCallback(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
@@ -99,145 +111,47 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
[disabled, isOpen],
|
[disabled, isOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSizeStyles = (): string => {
|
// Handle focus to detect mouse vs keyboard
|
||||||
const baseStyles = "w-full";
|
const handleFocus = useCallback(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
switch (size) {
|
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
|
||||||
case "small": {
|
|
||||||
const smallHeight =
|
if (shouldAutoManageFocus) {
|
||||||
labelVariant === "horizontal" ? "h-[30px]" : "h-[32px]";
|
setIsFocused(true);
|
||||||
return `${baseStyles} ${smallHeight} pl-[12px] pr-[36px] py-[8px] text-[10px] leading-[14px]`;
|
setFocusMethod(method);
|
||||||
}
|
wasMouseDownRef.current = false;
|
||||||
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]`;
|
|
||||||
}
|
}
|
||||||
};
|
}, [disabled, shouldAutoManageFocus]);
|
||||||
|
|
||||||
const getLabelSizeStyles = (): string => {
|
// Handle blur
|
||||||
switch (size) {
|
const handleBlur = useCallback(() => {
|
||||||
case "small":
|
if (shouldAutoManageFocus) {
|
||||||
return "text-[12px] leading-[14px]";
|
setIsFocused(false);
|
||||||
case "medium":
|
setFocusMethod(null);
|
||||||
return "text-[14px] leading-[16px]";
|
wasMouseDownRef.current = false;
|
||||||
case "large":
|
|
||||||
return "text-[16px] leading-[20px]";
|
|
||||||
default:
|
|
||||||
return "text-[14px] leading-[16px]";
|
|
||||||
}
|
}
|
||||||
};
|
}, [shouldAutoManageFocus]);
|
||||||
|
|
||||||
const getStateStyles = (): {
|
// Determine actual state:
|
||||||
select: string;
|
// - Active: when clicked (mouse focus) or when dropdown is open
|
||||||
label: string;
|
// - Focus: when tabbed (keyboard focus)
|
||||||
} => {
|
// - Default: when not focused
|
||||||
if (disabled) {
|
const actualState = shouldAutoManageFocus
|
||||||
return {
|
? isOpen || isFocused
|
||||||
select:
|
? focusMethod === "mouse" || isOpen
|
||||||
"bg-[var(--color-content-default-secondary)] border-[var(--color-border-default-tertiary)] cursor-not-allowed opacity-40",
|
? "active"
|
||||||
label: "text-[var(--color-content-default-secondary)]",
|
: "focus"
|
||||||
};
|
: "default"
|
||||||
}
|
: externalState;
|
||||||
|
|
||||||
if (error) {
|
// Determine if select is filled (has selected value)
|
||||||
return {
|
const isFilled = Boolean(selectedValue && selectedValue.trim().length > 0);
|
||||||
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";
|
|
||||||
|
|
||||||
const chevronClasses = `${
|
|
||||||
size === "large" ? "w-5 h-5" : "w-4 h-4"
|
|
||||||
} text-[var(--color-content-default-primary)] transition-transform duration-200 ${
|
|
||||||
isOpen ? "rotate-180" : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// Get display text for selected value
|
// Get display text for selected value
|
||||||
const getDisplayText = (): string => {
|
const getDisplayText = (): string => {
|
||||||
if (!selectedValue) return placeholder;
|
if (!selectedValue) return placeholder;
|
||||||
|
|
||||||
// Handle options prop
|
|
||||||
if (options && Array.isArray(options)) {
|
if (options && Array.isArray(options)) {
|
||||||
const selectedOption = options.find(
|
const selectedOption = options.find(
|
||||||
(option) => option.value === selectedValue,
|
(option) => option.value === selectedValue,
|
||||||
@@ -245,7 +159,6 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
return selectedOption ? selectedOption.label : placeholder;
|
return selectedOption ? selectedOption.label : placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle children (option elements)
|
|
||||||
const selectedOption = Children.toArray(children).find(
|
const selectedOption = Children.toArray(children).find(
|
||||||
(
|
(
|
||||||
child,
|
child,
|
||||||
@@ -270,11 +183,9 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
<SelectInputView
|
<SelectInputView
|
||||||
label={label}
|
label={label}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
size={size}
|
state={actualState}
|
||||||
state={state}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
error={error}
|
error={error}
|
||||||
labelVariant={labelVariant}
|
|
||||||
className={className}
|
className={className}
|
||||||
options={options}
|
options={options}
|
||||||
selectId={selectId}
|
selectId={selectId}
|
||||||
@@ -282,12 +193,12 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
selectedValue={selectedValue}
|
selectedValue={selectedValue}
|
||||||
displayText={getDisplayText()}
|
displayText={getDisplayText()}
|
||||||
selectClasses={selectClasses}
|
isFilled={isFilled}
|
||||||
labelClasses={labelClasses}
|
|
||||||
containerClasses={containerClasses}
|
|
||||||
chevronClasses={chevronClasses}
|
|
||||||
onButtonClick={handleSelectClick}
|
onButtonClick={handleSelectClick}
|
||||||
onButtonKeyDown={handleKeyDown}
|
onButtonKeyDown={handleKeyDown}
|
||||||
|
onButtonMouseDown={handleMouseDown}
|
||||||
|
onButtonFocus={handleFocus}
|
||||||
|
onButtonBlur={handleBlur}
|
||||||
onOptionClick={handleOptionSelect}
|
onOptionClick={handleOptionSelect}
|
||||||
selectRef={selectRef}
|
selectRef={selectRef}
|
||||||
menuRef={menuRef}
|
menuRef={menuRef}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { Children, type ReactNode } from "react";
|
import React, { Children, type ReactNode } from "react";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
import SelectDropdown from "../SelectDropdown";
|
import SelectDropdown from "../SelectDropdown";
|
||||||
import SelectOption from "../SelectOption";
|
import SelectOption from "../SelectOption";
|
||||||
import type { SelectOptionData } from "./SelectInput.types";
|
import type { SelectOptionData } from "./SelectInput.types";
|
||||||
@@ -6,11 +7,9 @@ import type { SelectOptionData } from "./SelectInput.types";
|
|||||||
export interface SelectInputViewProps {
|
export interface SelectInputViewProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
size: "small" | "medium" | "large";
|
state: "default" | "active" | "hover" | "focus";
|
||||||
state: "default" | "hover" | "focus";
|
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
labelVariant: "default" | "horizontal";
|
|
||||||
className: string;
|
className: string;
|
||||||
options?: SelectOptionData[];
|
options?: SelectOptionData[];
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -20,13 +19,13 @@ export interface SelectInputViewProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
selectedValue: string;
|
selectedValue: string;
|
||||||
displayText: string;
|
displayText: string;
|
||||||
selectClasses: string;
|
isFilled: boolean;
|
||||||
labelClasses: string;
|
|
||||||
containerClasses: string;
|
|
||||||
chevronClasses: string;
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
onButtonClick: () => void;
|
onButtonClick: () => void;
|
||||||
onButtonKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
onButtonKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||||
|
onButtonMouseDown?: () => void;
|
||||||
|
onButtonFocus?: () => void;
|
||||||
|
onButtonBlur?: () => void;
|
||||||
onOptionClick: (_value: string, _text: string) => void;
|
onOptionClick: (_value: string, _text: string) => void;
|
||||||
// Refs
|
// Refs
|
||||||
selectRef: React.RefObject<HTMLButtonElement>;
|
selectRef: React.RefObject<HTMLButtonElement>;
|
||||||
@@ -39,10 +38,9 @@ export interface SelectInputViewProps {
|
|||||||
export function SelectInputView({
|
export function SelectInputView({
|
||||||
label,
|
label,
|
||||||
placeholder: _placeholder,
|
placeholder: _placeholder,
|
||||||
size,
|
state,
|
||||||
disabled,
|
disabled,
|
||||||
error: _error,
|
error,
|
||||||
labelVariant: _labelVariant,
|
|
||||||
options,
|
options,
|
||||||
children,
|
children,
|
||||||
selectId,
|
selectId,
|
||||||
@@ -50,12 +48,12 @@ export function SelectInputView({
|
|||||||
isOpen,
|
isOpen,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
displayText,
|
displayText,
|
||||||
selectClasses,
|
isFilled,
|
||||||
labelClasses,
|
|
||||||
containerClasses,
|
|
||||||
chevronClasses,
|
|
||||||
onButtonClick,
|
onButtonClick,
|
||||||
onButtonKeyDown,
|
onButtonKeyDown,
|
||||||
|
onButtonMouseDown,
|
||||||
|
onButtonFocus,
|
||||||
|
onButtonBlur,
|
||||||
onOptionClick,
|
onOptionClick,
|
||||||
selectRef,
|
selectRef,
|
||||||
menuRef,
|
menuRef,
|
||||||
@@ -63,48 +61,132 @@ export function SelectInputView({
|
|||||||
ariaInvalid,
|
ariaInvalid,
|
||||||
...props
|
...props
|
||||||
}: SelectInputViewProps) {
|
}: SelectInputViewProps) {
|
||||||
|
// Styles based on Figma design
|
||||||
|
const containerClasses = "flex flex-col gap-[8px]";
|
||||||
|
const labelClasses = "text-[14px] leading-[20px] font-medium font-inter text-[var(--color-content-default-primary)]";
|
||||||
|
|
||||||
|
// Button styles per Figma
|
||||||
|
const getButtonClasses = (): string => {
|
||||||
|
const baseClasses = `
|
||||||
|
w-full
|
||||||
|
h-[40px]
|
||||||
|
px-[12px]
|
||||||
|
py-[8px]
|
||||||
|
text-[16px]
|
||||||
|
font-medium
|
||||||
|
leading-[20px]
|
||||||
|
rounded-[8px]
|
||||||
|
border
|
||||||
|
border-solid
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-between
|
||||||
|
gap-[12px]
|
||||||
|
transition-all
|
||||||
|
duration-200
|
||||||
|
focus:outline-none
|
||||||
|
focus:ring-0
|
||||||
|
cursor-pointer
|
||||||
|
appearance-none
|
||||||
|
m-0
|
||||||
|
`.trim().replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border-[var(--color-border-default-primary)] cursor-not-allowed opacity-40`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return `${baseClasses} bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-[var(--color-border-default-utility-negative)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "focus") {
|
||||||
|
// Focus state: secondary background, tertiary border, with focus ring
|
||||||
|
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "active" || isOpen) {
|
||||||
|
// Active state per Figma: secondary background, tertiary border
|
||||||
|
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-tertiary)]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default state per Figma: secondary background, primary border (subtle)
|
||||||
|
return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-primary)]`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonClasses = getButtonClasses();
|
||||||
|
|
||||||
|
// Text color based on filled state
|
||||||
|
const textColorClass = isFilled
|
||||||
|
? "text-[var(--color-content-default-primary)]"
|
||||||
|
: "text-[var(--color-content-default-tertiary,#b4b4b4)]";
|
||||||
|
|
||||||
|
// Chevron icon
|
||||||
|
const chevronClasses = `w-5 h-5 text-[var(--color-content-default-primary)] transition-transform duration-200 ${
|
||||||
|
isOpen ? "rotate-180" : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
|
||||||
id={labelId}
|
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
|
||||||
htmlFor={selectId}
|
<label
|
||||||
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
|
id={labelId}
|
||||||
>
|
className={labelClasses}
|
||||||
{label}
|
>
|
||||||
</label>
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="relative shrink-0 size-[12px]">
|
||||||
|
<img
|
||||||
|
src={getAssetPath(ASSETS.ICON_HELP)}
|
||||||
|
alt="Help"
|
||||||
|
className="block max-w-none size-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={selectRef}
|
ref={selectRef}
|
||||||
id={selectId}
|
id={selectId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={selectClasses}
|
className={buttonClasses}
|
||||||
aria-labelledby={ariaLabelledby}
|
aria-labelledby={ariaLabelledby}
|
||||||
aria-invalid={ariaInvalid}
|
aria-invalid={ariaInvalid}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
onKeyDown={onButtonKeyDown}
|
onKeyDown={onButtonKeyDown}
|
||||||
{...props}
|
onMouseDown={onButtonMouseDown}
|
||||||
|
onFocus={onButtonFocus}
|
||||||
|
onBlur={onButtonBlur}
|
||||||
>
|
>
|
||||||
<span className="text-left">{displayText}</span>
|
<span className={`flex-1 text-left pr-[32px] ${textColorClass}`}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-center shrink-0">
|
||||||
|
<svg
|
||||||
|
className={chevronClasses}
|
||||||
|
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>
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-[12px] pointer-events-none">
|
{state === "focus" && (
|
||||||
<svg
|
<div
|
||||||
className={chevronClasses}
|
className="absolute border-2 border-solid border-[var(--color-border-inverse-primary)] inset-0 rounded-[8px] shadow-[0px_0px_0px_2px_var(--color-border-default-primary)] pointer-events-none z-10"
|
||||||
fill="none"
|
aria-hidden="true"
|
||||||
stroke="currentColor"
|
/>
|
||||||
viewBox="0 0 24 24"
|
)}
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -117,7 +199,7 @@ export function SelectInputView({
|
|||||||
<SelectOption
|
<SelectOption
|
||||||
key={option.value}
|
key={option.value}
|
||||||
selected={option.value === selectedValue}
|
selected={option.value === selectedValue}
|
||||||
size={size}
|
size="medium"
|
||||||
onClick={() => onOptionClick(option.value, option.label)}
|
onClick={() => onOptionClick(option.value, option.label)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -136,7 +218,7 @@ export function SelectInputView({
|
|||||||
<SelectOption
|
<SelectOption
|
||||||
key={optionProps.value}
|
key={optionProps.value}
|
||||||
selected={optionProps.value === selectedValue}
|
selected={optionProps.value === selectedValue}
|
||||||
size={size}
|
size="medium"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onOptionClick(
|
onOptionClick(
|
||||||
optionProps.value,
|
optionProps.value,
|
||||||
|
|||||||
+97
-150
@@ -4,18 +4,13 @@ import SelectInput from "../app/components/SelectInput";
|
|||||||
export default {
|
export default {
|
||||||
title: "Forms/SelectInput",
|
title: "Forms/SelectInput",
|
||||||
component: SelectInput,
|
component: SelectInput,
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
size: {
|
|
||||||
control: { type: "select" },
|
|
||||||
options: ["small", "medium", "large"],
|
|
||||||
},
|
|
||||||
labelVariant: {
|
|
||||||
control: { type: "select" },
|
|
||||||
options: ["default", "horizontal"],
|
|
||||||
},
|
|
||||||
state: {
|
state: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["default", "hover", "focus", "error", "disabled"],
|
options: ["default", "active", "focus"],
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
control: { type: "boolean" },
|
control: { type: "boolean" },
|
||||||
@@ -48,178 +43,130 @@ const Template = (args) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Default story
|
||||||
export const Default = Template.bind({});
|
export const Default = Template.bind({});
|
||||||
Default.args = {
|
Default.args = {
|
||||||
label: "Default Select Input",
|
label: "Default Select Input",
|
||||||
placeholder: "Select",
|
placeholder: "Choose an option",
|
||||||
};
|
state: "default",
|
||||||
|
|
||||||
export const Small = Template.bind({});
|
|
||||||
Small.args = {
|
|
||||||
label: "Small Select Input",
|
|
||||||
size: "small",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Medium = Template.bind({});
|
|
||||||
Medium.args = {
|
|
||||||
label: "Medium Select Input",
|
|
||||||
size: "medium",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Large = Template.bind({});
|
|
||||||
Large.args = {
|
|
||||||
label: "Large Select Input",
|
|
||||||
size: "large",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DefaultLabel = Template.bind({});
|
|
||||||
DefaultLabel.args = {
|
|
||||||
label: "Default (Top Label)",
|
|
||||||
labelVariant: "default",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HorizontalLabel = Template.bind({});
|
|
||||||
HorizontalLabel.args = {
|
|
||||||
label: "Horizontal (Left Label)",
|
|
||||||
labelVariant: "horizontal",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// States
|
||||||
export const Active = Template.bind({});
|
export const Active = Template.bind({});
|
||||||
Active.args = {
|
Active.args = {
|
||||||
label: "Active State",
|
label: "Active State",
|
||||||
state: "default",
|
placeholder: "Choose an option",
|
||||||
placeholder: "Select",
|
state: "active",
|
||||||
};
|
|
||||||
|
|
||||||
export const Hover = Template.bind({});
|
|
||||||
Hover.args = {
|
|
||||||
label: "Hover State",
|
|
||||||
state: "hover",
|
|
||||||
placeholder: "Select",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Focus = Template.bind({});
|
export const Focus = Template.bind({});
|
||||||
Focus.args = {
|
Focus.args = {
|
||||||
label: "Focus State",
|
label: "Focus State",
|
||||||
|
placeholder: "Choose an option",
|
||||||
state: "focus",
|
state: "focus",
|
||||||
placeholder: "Select",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Error = Template.bind({});
|
export const Error = Template.bind({});
|
||||||
Error.args = {
|
Error.args = {
|
||||||
label: "Error State",
|
label: "Error State",
|
||||||
|
placeholder: "Choose an option",
|
||||||
error: true,
|
error: true,
|
||||||
placeholder: "Select",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled = Template.bind({});
|
export const Disabled = Template.bind({});
|
||||||
Disabled.args = {
|
Disabled.args = {
|
||||||
label: "Disabled State",
|
label: "Disabled State",
|
||||||
|
placeholder: "Choose an option",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
placeholder: "Select",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Interactive = Template.bind({});
|
// Interactive example
|
||||||
|
export const Interactive = (args) => {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SelectInput
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={(data) => setValue(data.target.value)}
|
||||||
|
options={[
|
||||||
|
{ value: "option1", label: "Option 1" },
|
||||||
|
{ value: "option2", label: "Option 2" },
|
||||||
|
{ value: "option3", label: "Option 3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600">Current value: "{value}"</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Interactive.args = {
|
Interactive.args = {
|
||||||
label: "Interactive Select Input",
|
label: "Interactive Select Input",
|
||||||
placeholder: "Choose an option",
|
placeholder: "Choose an option",
|
||||||
|
state: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Comparison stories
|
// All states comparison
|
||||||
export const AllSizes = () => {
|
export const AllStates = () => (
|
||||||
const [smallValue, setSmallValue] = useState("");
|
<div className="space-y-6">
|
||||||
const [mediumValue, setMediumValue] = useState("");
|
<div>
|
||||||
const [largeValue, setLargeValue] = useState("");
|
<h3 className="text-lg font-semibold mb-4">Select Input States</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
return (
|
<SelectInput
|
||||||
<div className="space-y-4">
|
label="Default State"
|
||||||
<SelectInput
|
placeholder="Choose an option"
|
||||||
label="Small"
|
value=""
|
||||||
size="small"
|
options={[
|
||||||
value={smallValue}
|
{ value: "option1", label: "Option 1" },
|
||||||
onChange={(data) => setSmallValue(data.target.value)}
|
{ value: "option2", label: "Option 2" },
|
||||||
placeholder="Select"
|
{ value: "option3", label: "Option 3" },
|
||||||
options={[
|
]}
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
/>
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
<SelectInput
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
label="Active State"
|
||||||
]}
|
placeholder="Choose an option"
|
||||||
/>
|
state="active"
|
||||||
<SelectInput
|
value=""
|
||||||
label="Medium"
|
options={[
|
||||||
size="medium"
|
{ value: "option1", label: "Option 1" },
|
||||||
value={mediumValue}
|
{ value: "option2", label: "Option 2" },
|
||||||
onChange={(data) => setMediumValue(data.target.value)}
|
{ value: "option3", label: "Option 3" },
|
||||||
placeholder="Select"
|
]}
|
||||||
options={[
|
/>
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
<SelectInput
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
label="Focus State"
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
placeholder="Choose an option"
|
||||||
]}
|
state="focus"
|
||||||
/>
|
value=""
|
||||||
<SelectInput
|
options={[
|
||||||
label="Large"
|
{ value: "option1", label: "Option 1" },
|
||||||
size="large"
|
{ value: "option2", label: "Option 2" },
|
||||||
value={largeValue}
|
{ value: "option3", label: "Option 3" },
|
||||||
onChange={(data) => setLargeValue(data.target.value)}
|
]}
|
||||||
placeholder="Select"
|
/>
|
||||||
options={[
|
<SelectInput
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
label="Error State"
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
placeholder="Choose an option"
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
error={true}
|
||||||
]}
|
value=""
|
||||||
/>
|
options={[
|
||||||
|
{ value: "option1", label: "Option 1" },
|
||||||
|
{ value: "option2", label: "Option 2" },
|
||||||
|
{ value: "option3", label: "Option 3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SelectInput
|
||||||
|
label="Disabled State"
|
||||||
|
placeholder="Choose an option"
|
||||||
|
disabled={true}
|
||||||
|
value=""
|
||||||
|
options={[
|
||||||
|
{ value: "option1", label: "Option 1" },
|
||||||
|
{ value: "option2", label: "Option 2" },
|
||||||
|
{ value: "option3", label: "Option 3" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
};
|
);
|
||||||
|
|
||||||
export const AllStates = () => {
|
|
||||||
const [defaultValue, setDefaultValue] = useState("");
|
|
||||||
const [errorValue, setErrorValue] = useState("");
|
|
||||||
const [disabledValue, setDisabledValue] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SelectInput
|
|
||||||
label="Default State"
|
|
||||||
value={defaultValue}
|
|
||||||
onChange={(data) => setDefaultValue(data.target.value)}
|
|
||||||
placeholder="Select"
|
|
||||||
options={[
|
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
label="Error State"
|
|
||||||
error={true}
|
|
||||||
value={errorValue}
|
|
||||||
onChange={(data) => setErrorValue(data.target.value)}
|
|
||||||
placeholder="Select"
|
|
||||||
options={[
|
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectInput
|
|
||||||
label="Disabled State"
|
|
||||||
disabled={true}
|
|
||||||
value={disabledValue}
|
|
||||||
onChange={(data) => setDisabledValue(data.target.value)}
|
|
||||||
placeholder="Select"
|
|
||||||
options={[
|
|
||||||
{ value: "item1", label: "Context Menu Item 1" },
|
|
||||||
{ value: "item2", label: "Context Menu Item 2" },
|
|
||||||
{ value: "item3", label: "Context Menu Item 3" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ componentTestSuite<SelectInputProps>({
|
|||||||
],
|
],
|
||||||
} as SelectInputProps,
|
} as SelectInputProps,
|
||||||
requiredProps: ["options"],
|
requiredProps: ["options"],
|
||||||
optionalProps: {
|
optionalProps: {},
|
||||||
size: "medium",
|
|
||||||
},
|
|
||||||
primaryRole: "button",
|
primaryRole: "button",
|
||||||
testCases: {
|
testCases: {
|
||||||
renders: true,
|
renders: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user