mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
107
packages/bruno-app/src/ui/Checkbox/StyledWrapper.js
Normal file
107
packages/bruno-app/src/ui/Checkbox/StyledWrapper.js
Normal 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;
|
||||
120
packages/bruno-app/src/ui/Checkbox/index.js
Normal file
120
packages/bruno-app/src/ui/Checkbox/index.js
Normal 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;
|
||||
33
packages/bruno-app/src/ui/InputWrapper/StyledWrapper.js
Normal file
33
packages/bruno-app/src/ui/InputWrapper/StyledWrapper.js
Normal 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;
|
||||
20
packages/bruno-app/src/ui/InputWrapper/constants.js
Normal file
20
packages/bruno-app/src/ui/InputWrapper/constants.js
Normal 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'
|
||||
}
|
||||
};
|
||||
34
packages/bruno-app/src/ui/InputWrapper/index.js
Normal file
34
packages/bruno-app/src/ui/InputWrapper/index.js
Normal 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;
|
||||
83
packages/bruno-app/src/ui/MaskedInput/StyledWrapper.js
Normal file
83
packages/bruno-app/src/ui/MaskedInput/StyledWrapper.js
Normal 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;
|
||||
104
packages/bruno-app/src/ui/MaskedInput/index.js
Normal file
104
packages/bruno-app/src/ui/MaskedInput/index.js
Normal 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;
|
||||
122
packages/bruno-app/src/ui/Select/StyledWrapper.js
Normal file
122
packages/bruno-app/src/ui/Select/StyledWrapper.js
Normal 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;
|
||||
381
packages/bruno-app/src/ui/Select/index.js
Normal file
381
packages/bruno-app/src/ui/Select/index.js
Normal 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;
|
||||
57
packages/bruno-app/src/ui/TextInput/StyledWrapper.js
Normal file
57
packages/bruno-app/src/ui/TextInput/StyledWrapper.js
Normal 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;
|
||||
108
packages/bruno-app/src/ui/TextInput/index.js
Normal file
108
packages/bruno-app/src/ui/TextInput/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user