refactor: replace old Checkbox component and Added new TextInput, MaskedInput, and Select components (#7755)

* refactor: replace old Checkbox component and Added new TextInput, MaskedInput, and Select components

- Deleted the old Checkbox and StyledWrapper components from the components directory.
- Introduced a new Checkbox component with enhanced features including indeterminate state and customizable icons.
- Added a new StyledWrapper for the Checkbox to manage styles and layout.
- Created InputWrapper and StyledWrapper components for consistent form field handling.
- Added new TextInput, MaskedInput, and Select components with their respective styles for improved UI consistency.

* refactor: enhance Checkbox, InputWrapper, and Select components with accessibility improvements

- Added useId for unique ID generation in Checkbox and Select components, improving accessibility.
- Updated Checkbox to use generated IDs for aria attributes and labels.
- Refactored InputWrapper to accept and apply generated IDs for label, description, and error elements.
- Modified Select component to include aria attributes for better screen reader support.
- Adjusted StyledWrapper imports to reference constants for consistent styling across components.
This commit is contained in:
Abhishek S Lal
2026-04-16 12:30:44 +05:30
committed by lohit
parent e8468ac9a5
commit 260c9e1f4c
14 changed files with 1170 additions and 125 deletions

View File

@@ -1,79 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.checkbox-container {
width: 1rem;
height: 1rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.checkbox-checkmark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
visibility: ${(props) => props.checked ? 'visible' : 'hidden'};
pointer-events: none;
}
.checkbox-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 1rem;
height: 1rem;
border: 2px solid ${(props) => {
if (props.checked && props.disabled) {
return props.theme.colors.text.muted;
}
if (props.checked && !props.disabled) {
return props.theme.colors.text.yellow;
}
return props.theme.colors.text.muted;
}};
border-radius: 4px;
background-color: ${(props) => {
if (props.checked && !props.disabled) {
return props.theme.colors.text.yellow;
}
if (props.checked && props.disabled) {
return props.theme.colors.text.muted;
}
return 'transparent';
}};
cursor: pointer;
position: relative;
transition: all 0.2s ease;
outline: none;
box-shadow: none;
&:hover:not(:disabled) {
opacity: 0.8;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.yellow}40;
}
}
`;
export default StyledWrapper;

View File

@@ -1,45 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import IconCheckMark from 'components/Icons/IconCheckMark';
import { useTheme } from 'providers/Theme';
const Checkbox = ({
checked = false,
disabled = false,
onChange,
className = '',
id,
name,
value,
dataTestId = 'checkbox'
}) => {
const { theme } = useTheme();
const handleChange = (e) => {
if (!disabled && onChange) {
onChange(e);
}
};
return (
<StyledWrapper checked={checked} disabled={disabled} className={className}>
<div className="checkbox-container">
<input
type="checkbox"
id={id}
name={name}
value={value}
checked={checked}
disabled={disabled}
onChange={handleChange}
className="checkbox-input"
data-testid={dataTestId}
/>
<IconCheckMark className="checkbox-checkmark" color={theme.examples.checkbox.color} size={14} />
</div>
</StyledWrapper>
);
};
export default Checkbox;

View File

@@ -45,7 +45,7 @@ const StyledWrapper = styled.button`
${(props) => props.$colorOnHover && css`
&:hover:not(:disabled) {
color: ${props.$colorOnHover};
color: ${props.theme.colors?.text?.[props.$colorOnHover] || props.$colorOnHover};
}
`}
`;

View File

@@ -0,0 +1,107 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const SIZES = {
sm: { box: '14px', icon: '10px', gap: '0.375rem' },
md: { box: '16px', icon: '12px', gap: '0.5rem' }
};
const StyledWrapper = styled.div`
display: inline-flex;
align-items: flex-start;
gap: ${(props) => (SIZES[props.$size] || SIZES.md).gap};
cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'pointer')};
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
flex-direction: ${(props) => (props.$labelPosition === 'left' ? 'row-reverse' : 'row')};
.checkbox-box {
position: relative;
flex-shrink: 0;
width: ${(props) => (SIZES[props.$size] || SIZES.md).box};
height: ${(props) => (SIZES[props.$size] || SIZES.md).box};
}
.checkbox-input {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 100%;
margin: 0;
border: 1.5px solid ${(props) => props.theme.border.border2};
border-radius: ${(props) => {
const r = props.$radius;
if (typeof r === 'number') return `${r}px`;
if (r === 'md') return props.theme.border.radius.md;
return props.theme.border.radius.sm;
}};
background: transparent;
cursor: inherit;
outline: none;
transition: all 0.15s ease;
&:checked {
background-color: ${(props) => props.$color || props.theme.primary.solid};
border-color: ${(props) => props.$color || props.theme.primary.solid};
}
&:focus-visible {
box-shadow: 0 0 0 2px ${(props) => rgba(props.$color || props.theme.primary.solid, 0.25)};
}
}
.checkbox-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
display: none;
}
.checkbox-input:checked + .checkbox-icon {
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.$iconColor || props.theme.button2.color.primary.text};
}
/* Indeterminate state */
.checkbox-input.checkbox-indeterminate {
background-color: ${(props) => props.$color || props.theme.primary.solid};
border-color: ${(props) => props.$color || props.theme.primary.solid};
}
.checkbox-input.checkbox-indeterminate + .checkbox-icon {
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.$iconColor || props.theme.button2.color.primary.text};
}
.checkbox-label-content {
display: flex;
flex-direction: column;
user-select: none;
padding-top: 1px;
}
.checkbox-label {
font-size: ${(props) => props.theme.font.size[props.$size === 'sm' ? 'xs' : 'sm']};
color: ${(props) => props.theme.colors.text.body};
line-height: 1.4;
}
.checkbox-description {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 0.125rem;
}
.checkbox-error {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.danger};
margin-top: 0.25rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,120 @@
import React, { useRef, useEffect, useId } from 'react';
import { IconCheck, IconMinus } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ICON_SIZES = { sm: 10, md: 12 };
/**
* Checkbox - A reusable checkbox component
*
* @param {boolean} props.checked - Controlled checked state
* @param {function} props.onChange - Called with the native change event
* @param {boolean} props.defaultChecked - Initial state for uncontrolled usage
* @param {boolean} props.indeterminate - Indeterminate state (overrides checked visually)
* @param {boolean} props.disabled - Disables interaction
* @param {string|ReactNode} props.label - Label text
* @param {string} props.description - Description below label
* @param {'left'|'right'} props.labelPosition - Label placement (default: 'right')
* @param {string} props.id - Input id
* @param {string} props.name - Input name
* @param {string} props.value - Input value for form submission
* @param {string} props.error - Error message
* @param {string} props.color - Override accent color
* @param {string} props.iconColor - Checkmark color (default: white)
* @param {ReactNode} props.icon - Custom icon for checked state
* @param {'sm'|'md'|number} props.radius - Border radius (default: 'sm')
* @param {'sm'|'md'} props.size - Checkbox size (default: 'md')
* @param {string} props.className - Additional CSS class
*/
const Checkbox = ({
checked,
onChange,
defaultChecked,
indeterminate = false,
disabled = false,
label,
description,
labelPosition = 'right',
id,
name,
value,
error,
color,
iconColor,
icon,
radius = 'sm',
size = 'md',
className,
'data-testid': testId
}) => {
const inputRef = useRef(null);
const autoId = useId();
const inputId = id || autoId;
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
const iconSize = ICON_SIZES[size] || 12;
const labelId = label ? `${inputId}-label` : undefined;
const descId = description ? `${inputId}-desc` : undefined;
const errId = error ? `${inputId}-err` : undefined;
const describedBy = [descId, errId].filter(Boolean).join(' ') || undefined;
const handleClick = (e) => {
e.stopPropagation();
if (!disabled && inputRef.current) {
inputRef.current.click();
}
};
const checkedIcon = icon || (
indeterminate
? <IconMinus size={iconSize} strokeWidth={3} />
: <IconCheck size={iconSize} strokeWidth={3} />
);
return (
<StyledWrapper
className={className}
$size={size}
$disabled={disabled}
$labelPosition={labelPosition}
$color={color}
$iconColor={iconColor}
$radius={radius}
onClick={handleClick}
>
<div className="checkbox-box">
<input
ref={inputRef}
type="checkbox"
className={`checkbox-input ${indeterminate ? 'checkbox-indeterminate' : ''}`}
id={inputId}
name={name}
data-testid={testId}
value={value}
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
onChange={onChange}
aria-labelledby={labelId}
aria-describedby={describedBy}
onClick={(e) => e.stopPropagation()}
/>
<span className="checkbox-icon">{checkedIcon}</span>
</div>
{(label || description || error) && (
<div className="checkbox-label-content">
{label && <span id={labelId} className="checkbox-label">{label}</span>}
{description && <span id={descId} className="checkbox-description">{description}</span>}
{error && <span id={errId} className="checkbox-error">{error}</span>}
</div>
)}
</StyledWrapper>
);
};
export default Checkbox;

View File

@@ -0,0 +1,33 @@
import styled from 'styled-components';
import { INPUT_SIZES } from './constants';
const StyledWrapper = styled.div`
position: relative;
width: 100%;
.input-wrapper-label {
display: block;
margin-bottom: 0.25rem;
font-size: ${(props) => props.theme.font.size[INPUT_SIZES[props.$size || 'md'].labelFontSize]};
color: ${(props) => props.theme.colors.text.body};
}
.input-wrapper-required {
color: ${(props) => props.theme.colors.text.danger};
margin-left: 0.125rem;
}
.input-wrapper-description {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 0.25rem;
}
.input-wrapper-error {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.danger};
margin-top: 0.25rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,20 @@
/**
* Input size definitions - shared across TextInput, MaskedInput, Select
*
* sm: compact inputs for inline/auth contexts
* md: default form inputs (matches .textbox)
*/
export const INPUT_SIZES = {
sm: {
padding: '0.15rem 0.4rem',
fontSize: 'xs',
borderRadius: 'sm',
labelFontSize: 'xs'
},
md: {
padding: '0.45rem',
fontSize: 'sm',
borderRadius: 'base',
labelFontSize: 'sm'
}
};

View File

@@ -0,0 +1,34 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
/**
* InputWrapper - Shared form field wrapper for label, description, error
*
* Used internally by TextInput, Select, MaskedInput, and other form components.
*
* @param {string|ReactNode} props.label - Label above the input
* @param {string|ReactNode} props.description - Description text below the label
* @param {string} props.error - Error message below the input
* @param {string} props.htmlFor - Links label to input id
* @param {boolean} props.required - Shows asterisk on label
* @param {string} props.size - Input size: 'sm' | 'md' (default: 'md')
* @param {string} props.className - Additional CSS class
* @param {ReactNode} props.children - The actual input element
*/
const InputWrapper = ({ label, description, error, htmlFor, required, size = 'md', className, labelId, descriptionId, errorId, children }) => {
return (
<StyledWrapper className={className} $size={size}>
{label && (
<label id={labelId} className="input-wrapper-label" htmlFor={htmlFor}>
{label}
{required && <span className="input-wrapper-required">*</span>}
</label>
)}
{description && <div id={descriptionId} className="input-wrapper-description">{description}</div>}
{children}
{error && <div id={errorId} className="input-wrapper-error">{error}</div>}
</StyledWrapper>
);
};
export default InputWrapper;

View File

@@ -0,0 +1,83 @@
import styled from 'styled-components';
import { INPUT_SIZES } from 'ui/InputWrapper/constants';
const StyledWrapper = styled.div`
.masked-input-wrapper {
display: flex;
align-items: center;
width: 100%;
padding: ${(props) => INPUT_SIZES[props.$size || 'md'].padding};
font-size: ${(props) => props.theme.font.size[INPUT_SIZES[props.$size || 'md'].fontSize]};
border-radius: ${(props) => props.theme.border.radius[INPUT_SIZES[props.$size || 'md'].borderRadius]};
&.masked-input-focused {
border-color: ${(props) => props.theme.input.focusBorder} !important;
}
&.masked-input-error {
border-color: ${(props) => props.theme.colors.text.danger} !important;
}
&.masked-input-disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.masked-input-left-section {
flex-shrink: 0;
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.masked-input-field {
outline: none;
width: 100%;
background: transparent;
border: none;
font-size: inherit;
font-family: inherit;
color: inherit;
padding: 0;
&:disabled {
cursor: not-allowed;
}
}
.masked-input-toggle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin-left: 0.5rem;
cursor: pointer;
color: inherit;
opacity: 0.6;
border-radius: 2px;
&:hover {
opacity: 1;
}
&:focus-visible {
opacity: 1;
outline: 2px solid ${(props) => props.theme.primary.solid};
outline-offset: 1px;
}
}
.masked-input-right-section {
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { IconEye, IconEyeOff } from '@tabler/icons';
import InputWrapper from 'ui/InputWrapper';
import StyledWrapper from './StyledWrapper';
/**
* MaskedInput - A password/secret input with visibility toggle
*
* @param {string} props.value - Controlled input value
* @param {function} props.onChange - Called with the input change event
* @param {string} props.id - Input id attribute
* @param {string} props.name - Input name attribute (defaults to id)
* @param {string} props.placeholder - Placeholder text
* @param {boolean} props.disabled - Disables input and hides toggle
* @param {string} props.error - Error message displayed below the input
* @param {string} props.label - Label text displayed above the input
* @param {string} props.description - Description text displayed below the label
* @param {boolean} props.required - Shows asterisk on label
* @param {boolean} props.visible - Controlled visibility state
* @param {function} props.onVisibilityChange - Called when visibility toggles: (visible: boolean) => void
* @param {ReactNode} props.leftSection - Element rendered on the left side of the input
* @param {ReactNode} props.rightSection - Element rendered after the visibility toggle
* @param {string} props.className - Additional CSS class for the wrapper
*/
const MaskedInput = ({
value,
onChange,
id,
name,
placeholder,
disabled = false,
error,
label,
description,
required = false,
visible: controlledVisible,
onVisibilityChange,
leftSection,
rightSection,
size = 'md',
className,
'data-testid': testId
}) => {
const [internalVisible, setInternalVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const isControlled = controlledVisible !== undefined;
const isVisible = isControlled ? controlledVisible : internalVisible;
const handleToggle = () => {
if (disabled) return;
const next = !isVisible;
if (isControlled) {
onVisibilityChange?.(next);
} else {
setInternalVisible(next);
}
};
const wrapperClasses = [
'masked-input-wrapper',
'textbox',
isFocused ? 'masked-input-focused' : '',
error ? 'masked-input-error' : '',
disabled ? 'masked-input-disabled' : ''
]
.filter(Boolean)
.join(' ');
return (
<InputWrapper label={label} description={description} error={error} htmlFor={id} required={required} size={size} className={className}>
<StyledWrapper $size={size}>
<div className={wrapperClasses}>
{leftSection && <span className="masked-input-left-section">{leftSection}</span>}
<input
id={id}
type={isVisible ? 'text' : 'password'}
name={name || id}
className="masked-input-field"
value={value}
onChange={onChange}
data-testid={testId}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
{!disabled && (
<button type="button" className="masked-input-toggle" onClick={handleToggle} aria-label={isVisible ? 'Hide value' : 'Show value'}>
{isVisible ? <IconEyeOff size={16} strokeWidth={2} /> : <IconEye size={16} strokeWidth={2} />}
</button>
)}
{rightSection && <span className="masked-input-right-section">{rightSection}</span>}
</div>
</StyledWrapper>
</InputWrapper>
);
};
export default MaskedInput;

View File

@@ -0,0 +1,122 @@
import styled, { keyframes } from 'styled-components';
import { INPUT_SIZES } from 'ui/InputWrapper/constants';
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const StyledWrapper = styled.div`
position: relative;
width: 100%;
.select-trigger {
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
user-select: none;
padding: ${(props) => INPUT_SIZES[props.$size || 'md'].padding};
font-size: ${(props) => props.theme.font.size[INPUT_SIZES[props.$size || 'md'].fontSize]};
border-radius: ${(props) => props.theme.border.radius[INPUT_SIZES[props.$size || 'md'].borderRadius]};
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
&.select-open {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
}
}
.select-trigger-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.select-trigger-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select-trigger-placeholder {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.5;
}
.select-section {
flex-shrink: 0;
display: flex;
align-items: center;
}
.select-left-section {
margin-right: 0.5rem;
}
.select-right-section {
margin-left: 0.5rem;
opacity: 0.6;
}
.select-caret {
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 0.5rem;
opacity: 0.6;
svg {
fill: currentColor;
}
}
.select-clear {
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
cursor: pointer;
opacity: 0.4;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}
.select-spinner {
animation: ${spin} 0.75s linear infinite;
}
.select-search-input {
border: none;
outline: none;
background: transparent;
width: 100%;
font-size: inherit;
font-family: inherit;
color: inherit;
padding: 0;
&::placeholder {
opacity: 0.5;
}
}
.select-nothing-found {
padding: 0.5rem 0.625rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,381 @@
import React, { useState, useRef, useCallback, useEffect, useMemo, useId } from 'react';
import Dropdown from 'components/Dropdown';
import { IconCaretDown, IconX, IconLoader2 } from '@tabler/icons';
import InputWrapper from 'ui/InputWrapper';
import StyledWrapper from './StyledWrapper';
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
const ACTION_KEYS = ['Enter'];
const getNextIndex = (currentIndex, total, key) => {
if (key === 'Home') return 0;
if (key === 'End') return total - 1;
if (key === 'ArrowDown') return currentIndex === -1 ? 0 : (currentIndex + 1) % total;
if (key === 'ArrowUp') return currentIndex === -1 ? total - 1 : (currentIndex - 1 + total) % total;
return currentIndex;
};
const normalizeData = (data) => {
if (!data) return [];
return data.map((item) => {
if (typeof item === 'string') {
return { value: item, label: item };
}
return { value: item.value, label: item.label || item.value, disabled: item.disabled };
});
};
const sameWidthModifier = {
name: 'sameWidth',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
state.styles.popper.width = `${state.rects.reference.width}px`;
},
effect: ({ state }) => {
state.elements.popper.style.width = `${state.elements.reference.offsetWidth}px`;
}
};
/**
* Select - A reusable select/dropdown component for forms
*
* @param {Array} props.data - Array of strings or { value, label, disabled? } objects
* @param {string} props.value - Controlled selected value
* @param {function} props.onChange - Called with the selected value string
* @param {string} props.placeholder - Placeholder text when no value selected
* @param {boolean} props.disabled - Disables interaction
* @param {string} props.error - Error message displayed below the select
* @param {boolean} props.searchable - Enables type-to-filter when dropdown is open
* @param {string} props.nothingFoundMessage - Message shown when search yields no results
* @param {boolean} props.clearable - Shows a clear button when a value is selected
* @param {boolean} props.allowDeselect - Clicking the selected option deselects it (default: true)
* @param {number} props.maxDropdownHeight - Max height of the dropdown in px (default: 250)
* @param {function} props.renderOption - Custom option renderer: ({ option, isSelected, isFocused }) => ReactNode
* @param {boolean} props.loading - Shows a loading spinner in the right section
* @param {ReactNode} props.leftSection - Element rendered on the left side of the trigger
* @param {ReactNode} props.rightSection - Element rendered on the right side (replaces default caret)
* @param {string} props.label - Label text displayed above the select
* @param {string} props.description - Description text displayed below the label
* @param {string} props.className - Additional CSS class for the wrapper
*/
const Select = ({
data,
value,
onChange,
placeholder = 'Select...',
disabled = false,
error,
label,
description,
searchable = false,
nothingFoundMessage = 'No options found',
clearable = false,
allowDeselect = true,
maxDropdownHeight = 250,
renderOption,
loading = false,
leftSection,
rightSection,
required = false,
size = 'md',
className,
'data-testid': testId
}) => {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const [searchValue, setSearchValue] = useState('');
const menuRef = useRef(null);
const inputRef = useRef(null);
const tippyRef = useRef(null);
const autoId = useId();
const labelId = label ? `${autoId}-label` : undefined;
const descriptionId = description ? `${autoId}-desc` : undefined;
const errorId = error ? `${autoId}-err` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const options = useMemo(() => normalizeData(data), [data]);
const filteredOptions = useMemo(() => {
if (!searchable || !searchValue) return options;
const query = searchValue.toLowerCase();
return options.filter((opt) => opt.label.toLowerCase().includes(query));
}, [options, searchable, searchValue]);
const selectedOption = useMemo(
() => options.find((opt) => opt.value === value),
[options, value]
);
const handleOpen = useCallback(() => {
if (disabled) return;
setIsOpen(true);
setSearchValue('');
const idx = options.findIndex((opt) => opt.value === value);
setFocusedIndex(idx >= 0 ? idx : 0);
}, [disabled, options, value]);
const handleClose = useCallback(() => {
setIsOpen(false);
setFocusedIndex(-1);
setSearchValue('');
}, []);
const handleToggle = useCallback(() => {
if (isOpen) {
handleClose();
} else {
handleOpen();
}
}, [isOpen, handleOpen, handleClose]);
const handleSelect = useCallback(
(option) => {
if (option.disabled) return;
if (allowDeselect && option.value === value) {
onChange?.(null);
} else {
onChange?.(option.value);
}
handleClose();
},
[onChange, handleClose, allowDeselect, value]
);
const handleClear = useCallback(
(e) => {
e.stopPropagation();
onChange?.(null);
},
[onChange]
);
const handleClickOutside = useCallback(() => {
handleClose();
}, [handleClose]);
const handleTriggerKeyDown = useCallback(
(e) => {
if (disabled) return;
if (ACTION_KEYS.includes(e.key) || NAVIGATION_KEYS.includes(e.key)) {
e.preventDefault();
if (!isOpen) {
handleOpen();
}
}
},
[disabled, isOpen, handleOpen]
);
const handleKeyDown = useCallback(
(e) => {
if (NAVIGATION_KEYS.includes(e.key)) {
e.preventDefault();
if (e.key === 'Escape') {
handleClose();
return;
}
const enabledIndices = filteredOptions.reduce((acc, opt, i) => {
if (!opt.disabled) acc.push(i);
return acc;
}, []);
if (enabledIndices.length === 0) return;
const currentEnabledIdx = enabledIndices.indexOf(focusedIndex);
const nextEnabledIdx = getNextIndex(currentEnabledIdx, enabledIndices.length, e.key);
setFocusedIndex(enabledIndices[nextEnabledIdx] ?? 0);
}
if (ACTION_KEYS.includes(e.key)) {
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
handleSelect(filteredOptions[focusedIndex]);
}
}
if (e.key === 'Tab') {
handleClose();
}
},
[filteredOptions, focusedIndex, handleClose, handleSelect]
);
const handleSearchChange = useCallback((e) => {
setSearchValue(e.target.value);
setFocusedIndex(0);
}, []);
useEffect(() => {
if (isOpen) {
if (searchable && inputRef.current) {
inputRef.current.focus();
} else if (menuRef.current) {
menuRef.current.focus();
}
}
}, [isOpen, searchable]);
useEffect(() => {
if (isOpen && menuRef.current && focusedIndex >= 0) {
const focusedEl = menuRef.current.querySelector(`[data-index="${focusedIndex}"]`);
if (focusedEl) {
focusedEl.scrollIntoView({ block: 'nearest' });
}
}
}, [isOpen, focusedIndex]);
const onDropdownCreate = useCallback((instance) => {
tippyRef.current = instance;
}, []);
// Right section: custom > loading spinner > clearable X > default caret
const renderRightSection = () => {
if (rightSection) return <span className="select-section select-right-section">{rightSection}</span>;
if (loading) {
return (
<span className="select-section select-right-section">
<IconLoader2 className="select-spinner" size={14} strokeWidth={2} />
</span>
);
}
if (clearable && value != null && value !== '') {
return (
<button
type="button"
className="select-section select-right-section select-clear"
onClick={handleClear}
aria-label="Clear selection"
>
<IconX size={14} strokeWidth={2} />
</button>
);
}
return (
<span className="select-caret">
<IconCaretDown size={14} strokeWidth={2} />
</span>
);
};
// Trigger content (label/placeholder or search input)
const renderTriggerContent = () => {
if (searchable && isOpen) {
return (
<input
ref={inputRef}
type="text"
className="select-search-input"
value={searchValue}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder={selectedOption ? selectedOption.label : placeholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
);
}
if (selectedOption) {
return <span className="select-trigger-label">{selectedOption.label}</span>;
}
return <span className="select-trigger-placeholder">{placeholder}</span>;
};
const triggerClickHandler = searchable
? () => { if (!isOpen) handleOpen(); }
: handleToggle;
const triggerKeyHandler = searchable
? (e) => {
if (!isOpen && (ACTION_KEYS.includes(e.key) || NAVIGATION_KEYS.includes(e.key))) {
e.preventDefault();
handleOpen();
}
}
: handleTriggerKeyDown;
const trigger = (
<div
className={`select-trigger textbox ${disabled ? 'disabled' : ''} ${isOpen ? 'select-open' : ''}`}
onClick={triggerClickHandler}
onKeyDown={triggerKeyHandler}
tabIndex={disabled ? -1 : 0}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-labelledby={labelId}
aria-describedby={describedBy}
aria-required={required || undefined}
data-testid={testId}
>
{leftSection && <span className="select-section select-left-section">{leftSection}</span>}
<span className="select-trigger-content">
{renderTriggerContent()}
</span>
{renderRightSection()}
</div>
);
// Option rendering — shared between searchable and non-searchable
const renderOptions = () => {
if (filteredOptions.length === 0) {
return <div className="select-nothing-found">{nothingFoundMessage}</div>;
}
return filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isFocused = index === focusedIndex;
const classNames = [
'dropdown-item',
isSelected ? 'dropdown-item-active' : '',
isFocused ? 'dropdown-item-focused' : '',
option.disabled ? 'disabled' : ''
]
.filter(Boolean)
.join(' ');
return (
<div
key={option.value}
className={classNames}
data-index={index}
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option)}
>
{renderOption
? renderOption({ option, isSelected, isFocused })
: <span className="dropdown-label">{option.label}</span>}
</div>
);
});
};
return (
<InputWrapper label={label} description={description} error={error} required={required} size={size} className={className} labelId={labelId} descriptionId={descriptionId} errorId={errorId}>
<StyledWrapper $size={size}>
<Dropdown
onCreate={onDropdownCreate}
icon={trigger}
placement="bottom-start"
visible={isOpen}
onClickOutside={handleClickOutside}
popperOptions={{ modifiers: [sameWidthModifier] }}
maxWidth="none"
>
<div
ref={menuRef}
role="listbox"
tabIndex={searchable ? undefined : -1}
onKeyDown={searchable ? undefined : handleKeyDown}
style={{ maxHeight: maxDropdownHeight, overflowY: 'auto', outline: 'none' }}
>
{renderOptions()}
</div>
</Dropdown>
</StyledWrapper>
</InputWrapper>
);
};
export default Select;

View File

@@ -0,0 +1,57 @@
import styled from 'styled-components';
import { INPUT_SIZES } from 'ui/InputWrapper/constants';
const StyledWrapper = styled.div`
.text-input-wrapper {
display: flex;
align-items: center;
width: 100%;
padding: ${(props) => INPUT_SIZES[props.$size || 'md'].padding};
font-size: ${(props) => props.theme.font.size[INPUT_SIZES[props.$size || 'md'].fontSize]};
border-radius: ${(props) => props.theme.border.radius[INPUT_SIZES[props.$size || 'md'].borderRadius]};
&.text-input-focused {
border-color: ${(props) => props.theme.input.focusBorder} !important;
}
&.text-input-error {
border-color: ${(props) => props.theme.colors.text.danger} !important;
}
&.text-input-disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.text-input-left-section {
flex-shrink: 0;
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.text-input-field {
outline: none;
width: 100%;
background: transparent;
border: none;
font-size: inherit;
font-family: inherit;
color: inherit;
padding: 0;
&:disabled {
cursor: not-allowed;
}
}
.text-input-right-section {
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import InputWrapper from 'ui/InputWrapper';
import StyledWrapper from './StyledWrapper';
/**
* TextInput - A form text input component
*
* @param {string} props.value - Controlled input value
* @param {function} props.onChange - Called with the input change event
* @param {string} props.id - Input id attribute
* @param {string} props.name - Input name attribute (defaults to id)
* @param {string} props.type - Input type: 'text' | 'number' | 'email' | 'url' (default: 'text')
* @param {string} props.placeholder - Placeholder text
* @param {boolean} props.disabled - Disables the input
* @param {string} props.error - Error message displayed below the input
* @param {string} props.label - Label text displayed above the input
* @param {string} props.description - Description text below the label
* @param {boolean} props.required - Shows asterisk on label
* @param {ReactNode} props.leftSection - Element rendered on the left side
* @param {ReactNode} props.rightSection - Element rendered on the right side
* @param {string} props.size - Input size: 'sm' | 'md' (default: 'md')
* @param {string} props.className - Additional CSS class for the wrapper
* @param {boolean} props.autoFocus - Auto-focus on mount
* @param {boolean} props.readOnly - Makes input read-only
* @param {string} props.autoComplete - HTML autoComplete attribute
* @param {number} props.maxLength - Max character length
* @param {number} props.min - Min value for type="number"
* @param {number} props.max - Max value for type="number"
* @param {number} props.step - Step value for type="number"
*/
const TextInput = ({
value,
onChange,
id,
name,
type = 'text',
placeholder,
disabled = false,
error,
label,
description,
required = false,
leftSection,
rightSection,
size = 'md',
className,
autoFocus,
readOnly,
autoComplete,
maxLength,
min,
max,
step,
'data-testid': testId
}) => {
const [isFocused, setIsFocused] = useState(false);
const wrapperClasses = [
'text-input-wrapper',
'textbox',
isFocused ? 'text-input-focused' : '',
error ? 'text-input-error' : '',
disabled ? 'text-input-disabled' : ''
]
.filter(Boolean)
.join(' ');
return (
<InputWrapper
label={label}
description={description}
error={error}
htmlFor={id}
required={required}
size={size}
className={className}
>
<StyledWrapper $size={size}>
<div className={wrapperClasses}>
{leftSection && <span className="text-input-left-section">{leftSection}</span>}
<input
id={id}
type={type}
name={name || id}
className="text-input-field"
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
data-testid={testId}
readOnly={readOnly}
autoFocus={autoFocus}
autoComplete={autoComplete}
maxLength={maxLength}
min={min}
max={max}
step={step}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
{rightSection && <span className="text-input-right-section">{rightSection}</span>}
</div>
</StyledWrapper>
</InputWrapper>
);
};
export default TextInput;