diff --git a/packages/bruno-app/src/components/Checkbox/StyledWrapper.js b/packages/bruno-app/src/components/Checkbox/StyledWrapper.js deleted file mode 100644 index ddfffe2250a..00000000000 --- a/packages/bruno-app/src/components/Checkbox/StyledWrapper.js +++ /dev/null @@ -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; diff --git a/packages/bruno-app/src/components/Checkbox/index.js b/packages/bruno-app/src/components/Checkbox/index.js deleted file mode 100644 index 175292db0f7..00000000000 --- a/packages/bruno-app/src/components/Checkbox/index.js +++ /dev/null @@ -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 ( - -
- - -
- -
- ); -}; - -export default Checkbox; diff --git a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js index e627f71d1cb..2a4c5a4862f 100644 --- a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js +++ b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js @@ -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}; } `} `; diff --git a/packages/bruno-app/src/ui/Checkbox/StyledWrapper.js b/packages/bruno-app/src/ui/Checkbox/StyledWrapper.js new file mode 100644 index 00000000000..edf15bdf507 --- /dev/null +++ b/packages/bruno-app/src/ui/Checkbox/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/Checkbox/index.js b/packages/bruno-app/src/ui/Checkbox/index.js new file mode 100644 index 00000000000..e4c632874e1 --- /dev/null +++ b/packages/bruno-app/src/ui/Checkbox/index.js @@ -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 + ? + : + ); + + return ( + +
+ e.stopPropagation()} + /> + {checkedIcon} +
+ {(label || description || error) && ( +
+ {label && {label}} + {description && {description}} + {error && {error}} +
+ )} +
+ ); +}; + +export default Checkbox; diff --git a/packages/bruno-app/src/ui/InputWrapper/StyledWrapper.js b/packages/bruno-app/src/ui/InputWrapper/StyledWrapper.js new file mode 100644 index 00000000000..9eb639eaee1 --- /dev/null +++ b/packages/bruno-app/src/ui/InputWrapper/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/InputWrapper/constants.js b/packages/bruno-app/src/ui/InputWrapper/constants.js new file mode 100644 index 00000000000..c72407173a3 --- /dev/null +++ b/packages/bruno-app/src/ui/InputWrapper/constants.js @@ -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' + } +}; diff --git a/packages/bruno-app/src/ui/InputWrapper/index.js b/packages/bruno-app/src/ui/InputWrapper/index.js new file mode 100644 index 00000000000..c0887f445c0 --- /dev/null +++ b/packages/bruno-app/src/ui/InputWrapper/index.js @@ -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 ( + + {label && ( + + )} + {description &&
{description}
} + {children} + {error &&
{error}
} +
+ ); +}; + +export default InputWrapper; diff --git a/packages/bruno-app/src/ui/MaskedInput/StyledWrapper.js b/packages/bruno-app/src/ui/MaskedInput/StyledWrapper.js new file mode 100644 index 00000000000..455265bedd8 --- /dev/null +++ b/packages/bruno-app/src/ui/MaskedInput/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/MaskedInput/index.js b/packages/bruno-app/src/ui/MaskedInput/index.js new file mode 100644 index 00000000000..84cd7ceb52b --- /dev/null +++ b/packages/bruno-app/src/ui/MaskedInput/index.js @@ -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 ( + + +
+ {leftSection && {leftSection}} + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + {!disabled && ( + + )} + {rightSection && {rightSection}} +
+
+
+ ); +}; + +export default MaskedInput; diff --git a/packages/bruno-app/src/ui/Select/StyledWrapper.js b/packages/bruno-app/src/ui/Select/StyledWrapper.js new file mode 100644 index 00000000000..9c36df1bcfc --- /dev/null +++ b/packages/bruno-app/src/ui/Select/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/Select/index.js b/packages/bruno-app/src/ui/Select/index.js new file mode 100644 index 00000000000..86de3384754 --- /dev/null +++ b/packages/bruno-app/src/ui/Select/index.js @@ -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 {rightSection}; + if (loading) { + return ( + + + + ); + } + if (clearable && value != null && value !== '') { + return ( + + ); + } + return ( + + + + ); + }; + + // Trigger content (label/placeholder or search input) + const renderTriggerContent = () => { + if (searchable && isOpen) { + return ( + + ); + } + if (selectedOption) { + return {selectedOption.label}; + } + return {placeholder}; + }; + + 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 = ( +
+ {leftSection && {leftSection}} + + {renderTriggerContent()} + + {renderRightSection()} +
+ ); + + // Option rendering — shared between searchable and non-searchable + const renderOptions = () => { + if (filteredOptions.length === 0) { + return
{nothingFoundMessage}
; + } + 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 ( +
handleSelect(option)} + > + {renderOption + ? renderOption({ option, isSelected, isFocused }) + : {option.label}} +
+ ); + }); + }; + + return ( + + + +
+ {renderOptions()} +
+
+
+
+ ); +}; + +export default Select; diff --git a/packages/bruno-app/src/ui/TextInput/StyledWrapper.js b/packages/bruno-app/src/ui/TextInput/StyledWrapper.js new file mode 100644 index 00000000000..2d12dbb5fc9 --- /dev/null +++ b/packages/bruno-app/src/ui/TextInput/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/TextInput/index.js b/packages/bruno-app/src/ui/TextInput/index.js new file mode 100644 index 00000000000..fdd687bd112 --- /dev/null +++ b/packages/bruno-app/src/ui/TextInput/index.js @@ -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 ( + + +
+ {leftSection && {leftSection}} + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + {rightSection && {rightSection}} +
+
+
+ ); +}; + +export default TextInput;