Files
community-rule/app/components/controls/SelectInput/SelectInput.container.tsx
T
2026-04-29 07:20:16 -06:00

253 lines
7.1 KiB
TypeScript

"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";
/**
* Figma: "Control / SelectInput". Custom-styled select dropdown
* with a labelled trigger button and floating option menu.
*/
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
{
id,
labelText,
showLabel,
labelVariant: labelVariantProp,
size: sizeProp,
state: externalStateProp = "default",
asterisk = false,
iconHelp = true,
textOptional = false,
textData = true,
iconRight = true,
textHint = false,
disabled = false,
error = false,
placeholder = "Choose an option",
className = "",
children,
value,
onChange,
options,
...props
},
ref,
) => {
// Determine if label should be shown
const shouldShowLabel =
showLabel !== undefined ? showLabel : labelText !== undefined;
let normalizedState = externalStateProp;
if (normalizedState === "state5" || normalizedState === "State5") {
normalizedState = "default";
}
const externalState = normalizedState;
const _labelVariant = labelVariantProp;
const _size = sizeProp;
void _labelVariant;
void _size;
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={shouldShowLabel ? labelText : undefined}
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={shouldShowLabel ? labelId : undefined}
ariaInvalid={error}
asterisk={asterisk}
iconHelp={iconHelp}
textOptional={textOptional}
textData={textData}
iconRight={iconRight}
textHint={textHint}
{...props}
/>
);
},
);
SelectInputContainer.displayName = "SelectInput";
export default memo(SelectInputContainer);