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;