Start organizational migration

This commit is contained in:
adilallo
2026-02-05 18:21:56 -07:00
parent 69074b23f3
commit db3c0274f6
161 changed files with 145 additions and 145 deletions
@@ -0,0 +1,227 @@
"use client";
import React, {
Children,
type ReactElement,
type ReactNode,
forwardRef,
useId,
useState,
useRef,
useCallback,
memo,
useImperativeHandle,
useEffect,
} from "react";
import { useClickOutside } from "../../../hooks";
import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types";
import { normalizeState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization";
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
{
id,
label,
labelVariant: labelVariantProp,
size: sizeProp,
state: externalStateProp = "default",
disabled = false,
error = false,
placeholder = "Choose an option",
className = "",
children,
value,
onChange,
options,
...props
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
// Note: labelVariant and size are normalized for future use but not yet implemented in the view
const _labelVariant = labelVariantProp ? normalizeLabelVariant(labelVariantProp) : undefined;
const _size = sizeProp ? normalizeSmallMediumLargeSize(sizeProp) : undefined;
// Mark as intentionally unused for future implementation
void _labelVariant;
void _size;
const externalState = normalizeState(externalStateProp);
const generatedId = useId();
const selectId = id || `select-input-${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);
// 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
useEffect(() => {
if (value !== undefined && value !== selectedValue) {
setSelectedValue(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useImperativeHandle(
ref,
() => selectRef.current as HTMLButtonElement | null,
);
// Handle click outside to close menu
useClickOutside([menuRef, selectRef], () => setIsOpen(false), isOpen);
// Handle option selection
const handleOptionSelect = useCallback(
(optionValue: string, optionText: string) => {
setSelectedValue(optionValue);
setIsOpen(false);
if (onChange) {
onChange({ target: { value: optionValue, text: optionText } });
}
if (selectRef.current) {
selectRef.current.focus();
}
},
[onChange],
);
// Handle mouse down to detect mouse clicks
const handleMouseDown = useCallback(() => {
if (!disabled && shouldAutoManageFocus) {
wasMouseDownRef.current = true;
}
}, [disabled, shouldAutoManageFocus]);
// 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],
);
// Handle focus to detect mouse vs keyboard
const handleFocus = useCallback(() => {
if (disabled) return;
const method = wasMouseDownRef.current ? "mouse" : "keyboard";
if (shouldAutoManageFocus) {
setIsFocused(true);
setFocusMethod(method);
wasMouseDownRef.current = false;
}
}, [disabled, shouldAutoManageFocus]);
// Handle blur
const handleBlur = useCallback(() => {
if (shouldAutoManageFocus) {
setIsFocused(false);
setFocusMethod(null);
wasMouseDownRef.current = false;
}
}, [shouldAutoManageFocus]);
// Determine actual state:
// - Active: when clicked (mouse focus) or when dropdown is open
// - Focus: when tabbed (keyboard focus)
// - Default: when not focused
const actualState = shouldAutoManageFocus
? isOpen || isFocused
? focusMethod === "mouse" || isOpen
? "active"
: "focus"
: "default"
: externalState;
// Determine if select is filled (has selected value)
const isFilled = Boolean(selectedValue && selectedValue.trim().length > 0);
// Get display text for selected value
const getDisplayText = (): string => {
if (!selectedValue) return placeholder;
if (options && Array.isArray(options)) {
const selectedOption = options.find(
(option) => option.value === selectedValue,
);
return selectedOption ? selectedOption.label : placeholder;
}
const selectedOption = Children.toArray(children).find(
(
child,
): child is ReactElement<{
value: string;
children: ReactNode;
}> => {
if (!React.isValidElement(child)) return false;
const props = child.props as {
value?: string;
children?: ReactNode;
};
return props.value === selectedValue;
},
);
return selectedOption
? String(selectedOption.props.children)
: placeholder;
};
return (
<SelectInputView
label={label}
placeholder={placeholder}
state={actualState}
disabled={disabled}
error={error}
className={className}
options={options}
selectId={selectId}
labelId={labelId}
isOpen={isOpen}
selectedValue={selectedValue}
displayText={getDisplayText()}
isFilled={isFilled}
onButtonClick={handleSelectClick}
onButtonKeyDown={handleKeyDown}
onButtonMouseDown={handleMouseDown}
onButtonFocus={handleFocus}
onButtonBlur={handleBlur}
onOptionClick={handleOptionSelect}
selectRef={selectRef}
menuRef={menuRef}
ariaLabelledby={label ? labelId : undefined}
ariaInvalid={error}
{...props}
/>
);
},
);
SelectInputContainer.displayName = "SelectInput";
export default memo(SelectInputContainer);
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
export interface SelectOptionData {
value: string;
label: string;
}
import type { StateValue } from "../../../../lib/propNormalization";
export type SelectInputLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal";
export type SelectInputSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large";
export interface SelectInputProps {
id?: string;
label?: string;
/**
* Label variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
labelVariant?: SelectInputLabelVariantValue;
/**
* Select input size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
size?: SelectInputSizeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
disabled?: boolean;
error?: boolean;
placeholder?: string;
className?: string;
children?: ReactNode;
value?: string;
onChange?: (_data: { target: { value: string; text: string } }) => void;
options?: SelectOptionData[];
}
@@ -0,0 +1,240 @@
import React, { Children, type ReactNode } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import SelectDropdown from "../../SelectDropdown";
import SelectOption from "../../SelectOption";
import type { SelectOptionData } from "./SelectInput.types";
export interface SelectInputViewProps {
label?: string;
placeholder: string;
state: "default" | "active" | "hover" | "focus" | "selected";
disabled: boolean;
error: boolean;
className: string;
options?: SelectOptionData[];
children?: ReactNode;
// Computed props from container
selectId: string;
labelId: string;
isOpen: boolean;
selectedValue: string;
displayText: string;
isFilled: boolean;
// Callbacks
onButtonClick: () => void;
onButtonKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onButtonMouseDown?: () => void;
onButtonFocus?: () => void;
onButtonBlur?: () => void;
onOptionClick: (_value: string, _text: string) => void;
// Refs
selectRef: React.RefObject<HTMLButtonElement>;
menuRef: React.RefObject<HTMLDivElement>;
// Additional props
ariaLabelledby?: string;
ariaInvalid?: boolean;
}
export function SelectInputView({
label,
placeholder: _placeholder,
state,
disabled,
error,
options,
children,
selectId,
labelId,
isOpen,
selectedValue,
displayText,
isFilled,
onButtonClick,
onButtonKeyDown,
onButtonMouseDown,
onButtonFocus,
onButtonBlur,
onOptionClick,
selectRef,
menuRef,
ariaLabelledby,
ariaInvalid,
}: 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 (
<div className={containerClasses}>
{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">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
id={labelId}
className={labelClasses}
>
{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">
<button
ref={selectRef}
id={selectId}
disabled={disabled}
className={buttonClasses}
aria-labelledby={ariaLabelledby}
aria-invalid={ariaInvalid}
aria-expanded={isOpen}
aria-haspopup="listbox"
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
onMouseDown={onButtonMouseDown}
onFocus={onButtonFocus}
onBlur={onButtonBlur}
>
<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>
{state === "focus" && (
<div
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"
aria-hidden="true"
/>
)}
{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="medium"
onClick={() => onOptionClick(option.value, option.label)}
>
{option.label}
</SelectOption>
))
: Children.map(children, (child) => {
if (
React.isValidElement(child) &&
child.type === "option"
) {
const optionProps = child.props as {
value: string;
children: ReactNode;
};
return (
<SelectOption
key={optionProps.value}
selected={optionProps.value === selectedValue}
size="medium"
onClick={() =>
onOptionClick(
optionProps.value,
String(optionProps.children),
)
}
>
{optionProps.children}
</SelectOption>
);
}
return child;
})}
</SelectDropdown>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export { default } from "./SelectInput.container";
export type { SelectInputProps, SelectOptionData } from "./SelectInput.types";