Files
community-rule/app/components/controls/SelectInput/SelectInput.view.tsx
T
adilallo ca928e306b Match the 3 Nov color ramps and size-based strokes, and replace leftover palette hex with tokens.
Drop Cookies Settings from the footer, link the Home features that already have pages, and give leftover routes their own descriptions.
2026-11-05 15:37:55 -07:00

286 lines
9.1 KiB
TypeScript

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;
// Figma props
asterisk?: boolean;
iconHelp?: boolean;
textOptional?: boolean;
textData?: boolean;
iconRight?: boolean;
textHint?: boolean;
selectAriaLabel: string;
hintDefault: string;
}
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,
asterisk = false,
iconHelp = true,
textOptional = false,
textData = true,
iconRight = true,
textHint = false,
selectAriaLabel,
hintDefault,
}: SelectInputViewProps) {
// Styles based on Figma design
const containerClasses = "flex flex-col gap-[8px]";
const labelClasses =
"text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]";
// Button styles per Figma
const getButtonClasses = (): string => {
const baseClasses = `
w-full
h-[40px]
px-[12px]
py-[8px]
text-medium-label
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-invert-tertiary)] 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-control)]`;
}
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-control)]`;
}
// 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-control)]`;
};
const buttonClasses = getButtonClasses();
// Text color based on filled state
const textColorClass = isFilled
? "text-[var(--color-content-default-primary)]"
: "text-[var(--color-content-default-tertiary)]";
// 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(--space-100)_var(--space-200)] items-baseline pr-[var(--space-100)] relative shrink-0 w-full">
<div className="flex gap-[var(--space-050)] items-center relative shrink-0">
<label id={labelId} className={labelClasses}>
{label}
</label>
{asterisk && (
<span className="text-[var(--color-content-default-negative-primary)] text-xx-small-label">
*
</span>
)}
{iconHelp && (
<div className="relative shrink-0 size-[12px]">
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
<img
src={getAssetPath(ASSETS.ICON_HELP)}
alt="Help"
className="block max-w-none size-full"
/>
</div>
)}
</div>
{textOptional && (
<span className="text-[var(--color-content-default-tertiary)] text-xx-small-paragraph">
Optional text
</span>
)}
</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 ${iconRight ? "pr-[32px]" : ""} ${textColorClass}`}
>
{textData ? displayText : _placeholder}
</span>
{iconRight && (
<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-invert-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 ariaLabel={selectAriaLabel}>
{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>
{textHint && (
<div className="flex items-start relative shrink-0 w-full">
<p
className={`flex-[1_0_0] text-x-small-paragraph min-h-px min-w-px relative ${
ariaInvalid
? "text-[color:var(--color-content-default-negative-primary)]"
: "text-[color:var(--color-content-default-tertiary)]"
}`}
>
{hintDefault}
</p>
</div>
)}
</div>
);
}