🚸 Add a better select input
Also improves other inputs behavior
This commit is contained in:
230
apps/builder/src/components/inputs/AutocompleteInput.tsx
Normal file
230
apps/builder/src/components/inputs/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
useDisclosure,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
PopoverAnchor,
|
||||
Portal,
|
||||
Input,
|
||||
HStack,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react'
|
||||
import { useState, useRef, useEffect, ReactNode } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env, isDefined } from 'utils'
|
||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
||||
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { Variable } from 'models'
|
||||
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
|
||||
import { focusInput } from '@/utils/focusInput'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
type Props = {
|
||||
items: string[]
|
||||
defaultValue?: string
|
||||
debounceTimeout?: number
|
||||
placeholder?: string
|
||||
withVariableButton?: boolean
|
||||
label?: ReactNode
|
||||
moreInfoTooltip?: string
|
||||
isRequired?: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const AutocompleteInput = ({
|
||||
items,
|
||||
onChange: _onChange,
|
||||
debounceTimeout,
|
||||
placeholder,
|
||||
withVariableButton = true,
|
||||
defaultValue,
|
||||
label,
|
||||
moreInfoTooltip,
|
||||
isRequired,
|
||||
}: Props) => {
|
||||
const bg = useColorModeValue('gray.200', 'gray.700')
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||
const [carretPosition, setCarretPosition] = useState<number>(
|
||||
inputValue.length ?? 0
|
||||
)
|
||||
|
||||
const onChange = useDebouncedCallback(
|
||||
_onChange,
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isTouched || inputValue !== '' || !defaultValue || defaultValue === '')
|
||||
return
|
||||
setInputValue(defaultValue ?? '')
|
||||
}, [defaultValue, inputValue, isTouched])
|
||||
|
||||
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
const dropdownRef = useRef(null)
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
|
||||
const { ref: parentModalRef } = useParentModal()
|
||||
|
||||
const filteredItems = (
|
||||
inputValue === ''
|
||||
? items
|
||||
: [
|
||||
...items.filter(
|
||||
(item) =>
|
||||
item.toLowerCase().startsWith((inputValue ?? '').toLowerCase()) &&
|
||||
item.toLowerCase() !== inputValue.toLowerCase()
|
||||
),
|
||||
]
|
||||
).slice(0, 50)
|
||||
|
||||
useOutsideClick({
|
||||
ref: dropdownRef,
|
||||
handler: onClose,
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onChange.flush()
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const changeValue = (value: string) => {
|
||||
if (!isTouched) setIsTouched(true)
|
||||
if (!isOpen) onOpen()
|
||||
setInputValue(value)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
const handleItemClick = (value: string) => () => {
|
||||
setInputValue(value)
|
||||
onChange(value)
|
||||
setKeyboardFocusIndex(undefined)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
|
||||
handleItemClick(filteredItems[keyboardFocusIndex])()
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
|
||||
if (keyboardFocusIndex === filteredItems.length - 1)
|
||||
return setKeyboardFocusIndex(0)
|
||||
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (keyboardFocusIndex === 0 || keyboardFocusIndex === undefined)
|
||||
return setKeyboardFocusIndex(filteredItems.length - 1)
|
||||
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
setKeyboardFocusIndex(keyboardFocusIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!variable) return
|
||||
const { text, carretPosition: newCarretPosition } = injectVariableInText({
|
||||
variable,
|
||||
text: inputValue,
|
||||
at: carretPosition,
|
||||
})
|
||||
changeValue(text)
|
||||
focusInput({ at: newCarretPosition, input: inputRef.current })
|
||||
}
|
||||
|
||||
const updateCarretPosition = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const carretPosition = e.target.selectionStart
|
||||
if (!carretPosition) return
|
||||
setCarretPosition(carretPosition)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl isRequired={isRequired}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
<HStack ref={dropdownRef} spacing={0} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
offset={[0, 1]}
|
||||
isLazy
|
||||
>
|
||||
<PopoverAnchor>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => changeValue(e.target.value)}
|
||||
onFocus={onOpen}
|
||||
onBlur={updateCarretPosition}
|
||||
onKeyDown={handleKeyUp}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{filteredItems.length > 0 && (
|
||||
<Portal containerRef={parentModalRef}>
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
return (
|
||||
<Button
|
||||
ref={(el) => (itemsRef.current[idx] = el)}
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={handleItemClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
bg={keyboardFocusIndex === idx ? bg : 'transparent'}
|
||||
justifyContent="flex-start"
|
||||
transition="none"
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
)}
|
||||
</Popover>
|
||||
{withVariableButton && (
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
)}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
142
apps/builder/src/components/inputs/CodeEditor.tsx
Normal file
142
apps/builder/src/components/inputs/CodeEditor.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
BoxProps,
|
||||
Fade,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { Variable } from 'models'
|
||||
import { env } from 'utils'
|
||||
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'
|
||||
import { tokyoNight } from '@uiw/codemirror-theme-tokyo-night'
|
||||
import { githubLight } from '@uiw/codemirror-theme-github'
|
||||
import { LanguageName, loadLanguage } from '@uiw/codemirror-extensions-langs'
|
||||
import { isDefined } from '@udecode/plate-common'
|
||||
import { CopyButton } from '../CopyButton'
|
||||
|
||||
type Props = {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
lang: LanguageName
|
||||
isReadOnly?: boolean
|
||||
debounceTimeout?: number
|
||||
withVariableButton?: boolean
|
||||
height?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
export const CodeEditor = ({
|
||||
defaultValue,
|
||||
lang,
|
||||
onChange,
|
||||
height = '250px',
|
||||
withVariableButton = true,
|
||||
isReadOnly = false,
|
||||
debounceTimeout = 1000,
|
||||
...props
|
||||
}: Props & Omit<BoxProps, 'onChange'>) => {
|
||||
const theme = useColorModeValue(githubLight, tokyoNight)
|
||||
const codeEditor = useRef<ReactCodeMirrorRef | null>(null)
|
||||
const [carretPosition, setCarretPosition] = useState<number>(0)
|
||||
const isVariableButtonDisplayed = withVariableButton && !isReadOnly
|
||||
const [value, _setValue] = useState(defaultValue ?? '')
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
|
||||
const setValue = useDebouncedCallback(
|
||||
(value) => {
|
||||
_setValue(value)
|
||||
onChange && onChange(value)
|
||||
},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
const handleVariableSelected = (variable?: Pick<Variable, 'id' | 'name'>) => {
|
||||
codeEditor.current?.view?.focus()
|
||||
const insert = `{{${variable?.name}}}`
|
||||
codeEditor.current?.view?.dispatch({
|
||||
changes: {
|
||||
from: carretPosition,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: carretPosition + insert.length },
|
||||
})
|
||||
}
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (isDefined(props.value)) return
|
||||
setValue(newValue)
|
||||
setCarretPosition(codeEditor.current?.state?.selection.main.head ?? 0)
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
setValue.flush()
|
||||
},
|
||||
[setValue]
|
||||
)
|
||||
|
||||
return (
|
||||
<HStack
|
||||
align="flex-end"
|
||||
spacing={0}
|
||||
borderWidth={'1px'}
|
||||
rounded="md"
|
||||
bg={useColorModeValue('white', '#1A1B26')}
|
||||
width="full"
|
||||
h="full"
|
||||
pos="relative"
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={onClose}
|
||||
sx={{
|
||||
'& .cm-editor': {
|
||||
maxH: '70vh',
|
||||
outline: '0px solid transparent !important',
|
||||
rounded: 'md',
|
||||
},
|
||||
'& .cm-scroller': {
|
||||
rounded: 'md',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'& .cm-gutter,.cm-content': {
|
||||
minH: isReadOnly ? '0' : height,
|
||||
},
|
||||
'& .ͼ1 .cm-scroller': {
|
||||
fontSize: '14px',
|
||||
fontFamily:
|
||||
'JetBrainsMono, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CodeMirror
|
||||
data-testid="code-editor"
|
||||
ref={codeEditor}
|
||||
value={props.value ?? value}
|
||||
onChange={handleChange}
|
||||
theme={theme}
|
||||
extensions={[loadLanguage(lang)].filter(isDefined)}
|
||||
editable={!isReadOnly}
|
||||
style={{
|
||||
width: isVariableButtonDisplayed ? 'calc(100% - 32px)' : '100%',
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isVariableButtonDisplayed && (
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} size="sm" />
|
||||
)}
|
||||
{isReadOnly && (
|
||||
<Fade in={isOpen}>
|
||||
<CopyButton
|
||||
textToCopy={props.value ?? value}
|
||||
pos="absolute"
|
||||
right={0.5}
|
||||
top={0.5}
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Input as ChakraInput } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { TextBox, TextBoxProps } from './TextBox'
|
||||
|
||||
export const Input = (props: Omit<TextBoxProps, 'TextBox'>) => (
|
||||
<TextBox TextBox={ChakraInput} {...props} />
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import {
|
||||
NumberInputProps,
|
||||
NumberInput,
|
||||
NumberInput as ChakraNumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
@@ -30,7 +30,7 @@ type Props<HasVariable extends boolean> = {
|
||||
onValueChange: (value?: Value<HasVariable>) => void
|
||||
} & Omit<NumberInputProps, 'defaultValue' | 'value' | 'onChange' | 'isRequired'>
|
||||
|
||||
export const SmartNumberInput = <HasVariable extends boolean>({
|
||||
export const NumberInput = <HasVariable extends boolean>({
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
withVariableButton,
|
||||
@@ -79,13 +79,13 @@ export const SmartNumberInput = <HasVariable extends boolean>({
|
||||
}
|
||||
|
||||
const Input = (
|
||||
<NumberInput onChange={handleValueChange} value={value} {...props}>
|
||||
<ChakraNumberInput onChange={handleValueChange} value={value} {...props}>
|
||||
<NumberInputField placeholder={props.placeholder} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</ChakraNumberInput>
|
||||
)
|
||||
|
||||
return (
|
||||
227
apps/builder/src/components/inputs/Select.tsx
Normal file
227
apps/builder/src/components/inputs/Select.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
useDisclosure,
|
||||
Flex,
|
||||
Popover,
|
||||
Input,
|
||||
PopoverContent,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
PopoverAnchor,
|
||||
Portal,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Text,
|
||||
Box,
|
||||
} from '@chakra-ui/react'
|
||||
import { useState, useRef, ChangeEvent } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
||||
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
|
||||
import { ChevronDownIcon } from '../icons'
|
||||
|
||||
const dropdownCloseAnimationDuration = 200
|
||||
|
||||
type Item = string | { icon?: JSX.Element; label: string; value: string }
|
||||
|
||||
type Props = {
|
||||
selectedItem?: string
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
debounceTimeout?: number
|
||||
onSelect?: (value: string) => void
|
||||
}
|
||||
|
||||
export const Select = ({
|
||||
selectedItem,
|
||||
placeholder,
|
||||
items,
|
||||
onSelect,
|
||||
}: Props) => {
|
||||
const focusedItemBgColor = useColorModeValue('gray.200', 'gray.700')
|
||||
const selectedItemBgColor = useColorModeValue('blue.50', 'blue.400')
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const [inputValue, setInputValue] = useState(
|
||||
getItemLabel(
|
||||
items.find((item) =>
|
||||
typeof item === 'string'
|
||||
? selectedItem === item
|
||||
: selectedItem === item.value
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const closeDropwdown = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
const dropdownRef = useRef(null)
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { ref: parentModalRef } = useParentModal()
|
||||
|
||||
const filteredItems = (
|
||||
isTouched
|
||||
? [
|
||||
...items.filter((item) =>
|
||||
getItemLabel(item)
|
||||
.toLowerCase()
|
||||
.includes((inputValue ?? '').toLowerCase())
|
||||
),
|
||||
]
|
||||
: items
|
||||
).slice(0, 50)
|
||||
|
||||
useOutsideClick({
|
||||
ref: dropdownRef,
|
||||
handler: closeDropwdown,
|
||||
})
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) onOpen()
|
||||
if (!isTouched) setIsTouched(true)
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
|
||||
const handleItemClick = (item: Item) => () => {
|
||||
if (!isTouched) setIsTouched(true)
|
||||
setInputValue(getItemLabel(item))
|
||||
onSelect?.(getItemValue(item))
|
||||
setKeyboardFocusIndex(undefined)
|
||||
closeDropwdown()
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
|
||||
handleItemClick(filteredItems[keyboardFocusIndex])()
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
|
||||
if (keyboardFocusIndex === filteredItems.length - 1)
|
||||
return setKeyboardFocusIndex(0)
|
||||
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (keyboardFocusIndex === 0 || keyboardFocusIndex === undefined)
|
||||
return setKeyboardFocusIndex(filteredItems.length - 1)
|
||||
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
setKeyboardFocusIndex(keyboardFocusIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const resetIsTouched = () => {
|
||||
setTimeout(() => {
|
||||
setIsTouched(false)
|
||||
}, dropdownCloseAnimationDuration)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
offset={[0, 1]}
|
||||
isLazy
|
||||
>
|
||||
<PopoverAnchor>
|
||||
<InputGroup>
|
||||
<Box pos="absolute" py={2} pl={4} pr={6}>
|
||||
{!isTouched && (
|
||||
<Text noOfLines={1} data-testid="selected-item-label">
|
||||
{inputValue}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
ref={inputRef}
|
||||
className="select-input"
|
||||
value={isTouched ? inputValue : ''}
|
||||
placeholder={
|
||||
!isTouched && inputValue !== '' ? undefined : placeholder
|
||||
}
|
||||
onBlur={resetIsTouched}
|
||||
onChange={handleInputChange}
|
||||
onFocus={onOpen}
|
||||
onKeyDown={handleKeyUp}
|
||||
/>
|
||||
|
||||
<InputRightElement pointerEvents="none" cursor="pointer">
|
||||
<ChevronDownIcon />
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</PopoverAnchor>
|
||||
<Portal containerRef={parentModalRef}>
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{filteredItems.length > 0 && (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
return (
|
||||
<Button
|
||||
ref={(el) => (itemsRef.current[idx] = el)}
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={handleItemClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
role="menuitem"
|
||||
variant="ghost"
|
||||
bg={
|
||||
keyboardFocusIndex === idx
|
||||
? focusedItemBgColor
|
||||
: selectedItem === getItemValue(item)
|
||||
? selectedItemBgColor
|
||||
: 'transparent'
|
||||
}
|
||||
justifyContent="flex-start"
|
||||
transition="none"
|
||||
leftIcon={
|
||||
typeof item === 'object' ? item.icon : undefined
|
||||
}
|
||||
>
|
||||
{getItemLabel(item)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const getItemLabel = (item?: Item) => {
|
||||
if (!item) return ''
|
||||
if (typeof item === 'object') return item.label
|
||||
return item
|
||||
}
|
||||
|
||||
const getItemValue = (item: Item) => {
|
||||
if (typeof item === 'object') return item.value
|
||||
return item
|
||||
}
|
||||
44
apps/builder/src/components/inputs/SwitchWithLabel.tsx
Normal file
44
apps/builder/src/components/inputs/SwitchWithLabel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Switch,
|
||||
SwitchProps,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { useState } from 'react'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
type SwitchWithLabelProps = {
|
||||
label: string
|
||||
initialValue: boolean
|
||||
moreInfoContent?: string
|
||||
onCheckChange: (isChecked: boolean) => void
|
||||
} & SwitchProps
|
||||
|
||||
export const SwitchWithLabel = ({
|
||||
label,
|
||||
initialValue,
|
||||
moreInfoContent,
|
||||
onCheckChange,
|
||||
...switchProps
|
||||
}: SwitchWithLabelProps) => {
|
||||
const [isChecked, setIsChecked] = useState(initialValue)
|
||||
|
||||
const handleChange = () => {
|
||||
setIsChecked(!isChecked)
|
||||
onCheckChange(!isChecked)
|
||||
}
|
||||
return (
|
||||
<FormControl as={HStack} justifyContent="space-between">
|
||||
<FormLabel mb="0">
|
||||
{label}
|
||||
{moreInfoContent && (
|
||||
<>
|
||||
<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
|
||||
</>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Switch isChecked={isChecked} onChange={handleChange} {...switchProps} />
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import {
|
||||
ComponentWithAs,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
InputProps,
|
||||
TextareaProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable } from 'models'
|
||||
import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env } from 'utils'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
export type TextBoxProps = {
|
||||
defaultValue?: string
|
||||
onChange: (value: string) => void
|
||||
TextBox:
|
||||
| ComponentWithAs<'textarea', TextareaProps>
|
||||
| ComponentWithAs<'input', InputProps>
|
||||
withVariableButton?: boolean
|
||||
debounceTimeout?: number
|
||||
label?: string
|
||||
moreInfoTooltip?: string
|
||||
} & Omit<InputProps & TextareaProps, 'onChange' | 'defaultValue'>
|
||||
|
||||
export const TextBox = ({
|
||||
onChange,
|
||||
TextBox,
|
||||
withVariableButton = true,
|
||||
debounceTimeout = 1000,
|
||||
label,
|
||||
moreInfoTooltip,
|
||||
defaultValue,
|
||||
isRequired,
|
||||
...props
|
||||
}: TextBoxProps) => {
|
||||
const textBoxRef = useRef<(HTMLInputElement & HTMLTextAreaElement) | null>(
|
||||
null
|
||||
)
|
||||
const [value, setValue] = useState<string>(defaultValue ?? '')
|
||||
const [carretPosition, setCarretPosition] = useState<number>(
|
||||
defaultValue?.length ?? 0
|
||||
)
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const debounced = useDebouncedCallback(
|
||||
(value) => {
|
||||
onChange(value)
|
||||
},
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isTouched || defaultValue === value) return
|
||||
setValue(defaultValue ?? '')
|
||||
}, [defaultValue, isTouched, value])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debounced.flush()
|
||||
},
|
||||
[debounced]
|
||||
)
|
||||
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement & HTMLTextAreaElement>
|
||||
) => {
|
||||
setIsTouched(true)
|
||||
setValue(e.target.value)
|
||||
debounced(e.target.value)
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!variable) return
|
||||
setIsTouched(true)
|
||||
const textBeforeCursorPosition = value.substring(0, carretPosition)
|
||||
const textAfterCursorPosition = value.substring(
|
||||
carretPosition,
|
||||
value.length
|
||||
)
|
||||
const newValue =
|
||||
textBeforeCursorPosition +
|
||||
`{{${variable.name}}}` +
|
||||
textAfterCursorPosition
|
||||
setValue(newValue)
|
||||
debounced(newValue)
|
||||
const newCarretPosition = carretPosition + `{{${variable.name}}}`.length
|
||||
setCarretPosition(newCarretPosition)
|
||||
textBoxRef.current?.focus()
|
||||
setTimeout(() => {
|
||||
if (!textBoxRef.current) return
|
||||
textBoxRef.current.selectionStart = textBoxRef.current.selectionEnd =
|
||||
newCarretPosition
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const updateCarretPosition = () => {
|
||||
if (textBoxRef.current?.selectionStart === undefined) return
|
||||
setCarretPosition(textBoxRef.current.selectionStart)
|
||||
}
|
||||
|
||||
const Input = (
|
||||
<TextBox
|
||||
ref={textBoxRef}
|
||||
value={value}
|
||||
onKeyUp={updateCarretPosition}
|
||||
onClick={updateCarretPosition}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<FormControl isRequired={isRequired}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{withVariableButton ? (
|
||||
<HStack spacing={0} align={'flex-end'}>
|
||||
{Input}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Input
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
131
apps/builder/src/components/inputs/TextInput.tsx
Normal file
131
apps/builder/src/components/inputs/TextInput.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
|
||||
import { focusInput } from '@/utils/focusInput'
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Input as ChakraInput,
|
||||
InputProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable } from 'models'
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env } from 'utils'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
export type TextInputProps = {
|
||||
defaultValue?: string
|
||||
onChange: (value: string) => void
|
||||
debounceTimeout?: number
|
||||
label?: ReactNode
|
||||
moreInfoTooltip?: string
|
||||
withVariableButton?: boolean
|
||||
isRequired?: boolean
|
||||
placeholder?: string
|
||||
isDisabled?: boolean
|
||||
} & Pick<
|
||||
InputProps,
|
||||
'autoComplete' | 'onFocus' | 'onKeyUp' | 'type' | 'autoFocus'
|
||||
>
|
||||
|
||||
export const TextInput = ({
|
||||
type,
|
||||
defaultValue,
|
||||
debounceTimeout = 1000,
|
||||
label,
|
||||
moreInfoTooltip,
|
||||
withVariableButton = true,
|
||||
isRequired,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
isDisabled,
|
||||
autoFocus,
|
||||
onChange: _onChange,
|
||||
onFocus,
|
||||
onKeyUp,
|
||||
}: TextInputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const [localValue, setLocalValue] = useState<string>(defaultValue ?? '')
|
||||
const [carretPosition, setCarretPosition] = useState<number>(
|
||||
localValue.length ?? 0
|
||||
)
|
||||
const onChange = useDebouncedCallback(
|
||||
_onChange,
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isTouched || localValue !== '' || !defaultValue || defaultValue === '')
|
||||
return
|
||||
setLocalValue(defaultValue ?? '')
|
||||
}, [defaultValue, isTouched, localValue])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onChange.flush()
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const changeValue = (value: string) => {
|
||||
if (!isTouched) setIsTouched(true)
|
||||
setLocalValue(value)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!variable) return
|
||||
const { text, carretPosition: newCarretPosition } = injectVariableInText({
|
||||
variable,
|
||||
text: localValue,
|
||||
at: carretPosition,
|
||||
})
|
||||
changeValue(text)
|
||||
focusInput({ at: newCarretPosition, input: inputRef.current })
|
||||
}
|
||||
|
||||
const updateCarretPosition = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const carretPosition = e.target.selectionStart
|
||||
if (!carretPosition) return
|
||||
setCarretPosition(carretPosition)
|
||||
}
|
||||
|
||||
const Input = (
|
||||
<ChakraInput
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
value={localValue}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled}
|
||||
autoFocus={autoFocus}
|
||||
onFocus={onFocus}
|
||||
onKeyUp={onKeyUp}
|
||||
onBlur={updateCarretPosition}
|
||||
onChange={(e) => changeValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<FormControl isRequired={isRequired}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{withVariableButton ? (
|
||||
<HStack spacing={0} align={'flex-end'}>
|
||||
{Input}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Input
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,115 @@
|
||||
import { Textarea as ChakraTextarea } from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
import { TextBox, TextBoxProps } from './TextBox'
|
||||
import { VariablesButton } from '@/features/variables'
|
||||
import { injectVariableInText } from '@/features/variables/utils/injectVariableInTextInput'
|
||||
import { focusInput } from '@/utils/focusInput'
|
||||
import {
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Textarea as ChakraTextarea,
|
||||
TextareaProps,
|
||||
} from '@chakra-ui/react'
|
||||
import { Variable } from 'models'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { env } from 'utils'
|
||||
import { MoreInfoTooltip } from '../MoreInfoTooltip'
|
||||
|
||||
export const Textarea = (props: Omit<TextBoxProps, 'TextBox'>) => (
|
||||
<TextBox TextBox={ChakraTextarea} {...props} />
|
||||
)
|
||||
type Props = {
|
||||
id?: string
|
||||
defaultValue?: string
|
||||
debounceTimeout?: number
|
||||
label?: string
|
||||
moreInfoTooltip?: string
|
||||
withVariableButton?: boolean
|
||||
isRequired?: boolean
|
||||
onChange: (value: string) => void
|
||||
} & Pick<TextareaProps, 'minH'>
|
||||
|
||||
export const Textarea = ({
|
||||
id,
|
||||
defaultValue,
|
||||
onChange: _onChange,
|
||||
debounceTimeout = 1000,
|
||||
label,
|
||||
moreInfoTooltip,
|
||||
withVariableButton = true,
|
||||
isRequired,
|
||||
}: Props) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const [isTouched, setIsTouched] = useState(false)
|
||||
const [localValue, setLocalValue] = useState<string>(defaultValue ?? '')
|
||||
const [carretPosition, setCarretPosition] = useState<number>(
|
||||
localValue.length ?? 0
|
||||
)
|
||||
const onChange = useDebouncedCallback(
|
||||
_onChange,
|
||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isTouched || localValue !== '' || !defaultValue || defaultValue === '')
|
||||
return
|
||||
setLocalValue(defaultValue ?? '')
|
||||
}, [defaultValue, isTouched, localValue])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
onChange.flush()
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const changeValue = (value: string) => {
|
||||
if (!isTouched) setIsTouched(true)
|
||||
setLocalValue(value)
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
const handleVariableSelected = (variable?: Variable) => {
|
||||
if (!variable) return
|
||||
const { text, carretPosition: newCarretPosition } = injectVariableInText({
|
||||
variable,
|
||||
text: localValue,
|
||||
at: carretPosition,
|
||||
})
|
||||
changeValue(text)
|
||||
focusInput({ at: newCarretPosition, input: inputRef.current })
|
||||
}
|
||||
|
||||
const updateCarretPosition = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
const carretPosition = e.target.selectionStart
|
||||
if (!carretPosition) return
|
||||
setCarretPosition(carretPosition)
|
||||
}
|
||||
|
||||
const Textarea = (
|
||||
<ChakraTextarea
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
value={localValue}
|
||||
onBlur={updateCarretPosition}
|
||||
onChange={(e) => changeValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<FormControl isRequired={isRequired}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}{' '}
|
||||
{moreInfoTooltip && (
|
||||
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
)}
|
||||
{withVariableButton ? (
|
||||
<HStack spacing={0} align={'flex-end'}>
|
||||
{Textarea}
|
||||
<VariablesButton onSelectVariable={handleVariableSelected} />
|
||||
</HStack>
|
||||
) : (
|
||||
Textarea
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
268
apps/builder/src/components/inputs/VariableSearchInput.tsx
Normal file
268
apps/builder/src/components/inputs/VariableSearchInput.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
useDisclosure,
|
||||
Flex,
|
||||
Popover,
|
||||
Input,
|
||||
PopoverContent,
|
||||
Button,
|
||||
InputProps,
|
||||
IconButton,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
PopoverAnchor,
|
||||
Portal,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider/TypebotProvider'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { Variable } from 'models'
|
||||
import React, { useState, useRef, ChangeEvent, useEffect } from 'react'
|
||||
import { byId, isDefined, isNotDefined } from 'utils'
|
||||
import { useOutsideClick } from '@/hooks/useOutsideClick'
|
||||
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
|
||||
|
||||
type Props = {
|
||||
initialVariableId?: string
|
||||
autoFocus?: boolean
|
||||
onSelectVariable: (
|
||||
variable: Pick<Variable, 'id' | 'name'> | undefined
|
||||
) => void
|
||||
} & InputProps
|
||||
|
||||
export const VariableSearchInput = ({
|
||||
initialVariableId,
|
||||
onSelectVariable,
|
||||
autoFocus,
|
||||
...inputProps
|
||||
}: Props) => {
|
||||
const focusedItemBgColor = useColorModeValue('gray.200', 'gray.700')
|
||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||
const { typebot, createVariable, deleteVariable, updateVariable } =
|
||||
useTypebot()
|
||||
const variables = typebot?.variables ?? []
|
||||
const [inputValue, setInputValue] = useState(
|
||||
variables.find(byId(initialVariableId))?.name ?? ''
|
||||
)
|
||||
const [filteredItems, setFilteredItems] = useState<Variable[]>(
|
||||
variables ?? []
|
||||
)
|
||||
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
|
||||
number | undefined
|
||||
>()
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const createVariableItemRef = useRef<HTMLButtonElement | null>(null)
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const { ref: parentModalRef } = useParentModal()
|
||||
|
||||
useOutsideClick({
|
||||
ref: dropdownRef,
|
||||
handler: onClose,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) onOpen()
|
||||
}, [autoFocus, onOpen])
|
||||
|
||||
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
if (e.target.value === '') {
|
||||
if (inputValue.length > 0) {
|
||||
onSelectVariable(undefined)
|
||||
}
|
||||
setFilteredItems([...variables.slice(0, 50)])
|
||||
return
|
||||
}
|
||||
setFilteredItems([
|
||||
...variables
|
||||
.filter((item) =>
|
||||
item.name.toLowerCase().includes((e.target.value ?? '').toLowerCase())
|
||||
)
|
||||
.slice(0, 50),
|
||||
])
|
||||
}
|
||||
|
||||
const handleVariableNameClick = (variable: Variable) => () => {
|
||||
setInputValue(variable.name)
|
||||
onSelectVariable(variable)
|
||||
setKeyboardFocusIndex(undefined)
|
||||
inputRef.current?.blur()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCreateNewVariableClick = () => {
|
||||
if (!inputValue || inputValue === '') return
|
||||
const id = 'v' + createId()
|
||||
onSelectVariable({ id, name: inputValue })
|
||||
createVariable({ id, name: inputValue })
|
||||
inputRef.current?.blur()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDeleteVariableClick =
|
||||
(variable: Variable) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
deleteVariable(variable.id)
|
||||
setFilteredItems(filteredItems.filter((item) => item.id !== variable.id))
|
||||
if (variable.name === inputValue) {
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameVariableClick =
|
||||
(variable: Variable) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const name = prompt('Rename variable', variable.name)
|
||||
if (!name) return
|
||||
updateVariable(variable.id, { name })
|
||||
setFilteredItems(
|
||||
filteredItems.map((item) =>
|
||||
item.id === variable.id ? { ...item, name } : item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const isCreateVariableButtonDisplayed =
|
||||
(inputValue?.length ?? 0) > 0 &&
|
||||
isNotDefined(variables.find((v) => v.name === inputValue))
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
|
||||
if (keyboardFocusIndex === 0 && isCreateVariableButtonDisplayed)
|
||||
handleCreateNewVariableClick()
|
||||
else
|
||||
handleVariableNameClick(
|
||||
filteredItems[
|
||||
keyboardFocusIndex - (isCreateVariableButtonDisplayed ? 1 : 0)
|
||||
]
|
||||
)()
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
|
||||
if (keyboardFocusIndex >= filteredItems.length) return
|
||||
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (keyboardFocusIndex === undefined) return
|
||||
if (keyboardFocusIndex <= 0) return setKeyboardFocusIndex(undefined)
|
||||
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
return setKeyboardFocusIndex(keyboardFocusIndex - 1)
|
||||
}
|
||||
return setKeyboardFocusIndex(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex ref={dropdownRef} w="full">
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
initialFocusRef={inputRef}
|
||||
matchWidth
|
||||
isLazy
|
||||
offset={[0, 2]}
|
||||
>
|
||||
<PopoverAnchor>
|
||||
<Input
|
||||
data-testid="variables-input"
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onFocus={onOpen}
|
||||
onKeyDown={handleKeyUp}
|
||||
placeholder={inputProps.placeholder ?? 'Select a variable'}
|
||||
{...inputProps}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<Portal containerRef={parentModalRef}>
|
||||
<PopoverContent
|
||||
maxH="35vh"
|
||||
overflowY="scroll"
|
||||
role="menu"
|
||||
w="inherit"
|
||||
shadow="lg"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isCreateVariableButtonDisplayed && (
|
||||
<Button
|
||||
ref={createVariableItemRef}
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
onClick={handleCreateNewVariableClick}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
leftIcon={<PlusIcon />}
|
||||
bgColor={
|
||||
keyboardFocusIndex === 0 ? focusedItemBgColor : 'transparent'
|
||||
}
|
||||
>
|
||||
Create
|
||||
<Tag colorScheme="orange" ml="1">
|
||||
{inputValue}
|
||||
</Tag>
|
||||
</Button>
|
||||
)}
|
||||
{filteredItems.length > 0 && (
|
||||
<>
|
||||
{filteredItems.map((item, idx) => {
|
||||
const indexInList = isCreateVariableButtonDisplayed
|
||||
? idx + 1
|
||||
: idx
|
||||
return (
|
||||
<Button
|
||||
ref={(el) => (itemsRef.current[idx] = el)}
|
||||
role="menuitem"
|
||||
minH="40px"
|
||||
key={idx}
|
||||
onClick={handleVariableNameClick(item)}
|
||||
fontSize="16px"
|
||||
fontWeight="normal"
|
||||
rounded="none"
|
||||
colorScheme="gray"
|
||||
variant="ghost"
|
||||
justifyContent="space-between"
|
||||
bgColor={
|
||||
keyboardFocusIndex === indexInList
|
||||
? focusedItemBgColor
|
||||
: 'transparent'
|
||||
}
|
||||
transition="none"
|
||||
>
|
||||
{item.name}
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria-label="Rename variable"
|
||||
size="xs"
|
||||
onClick={handleRenameVariableClick(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove variable"
|
||||
size="xs"
|
||||
onClick={handleDeleteVariableClick(item)}
|
||||
/>
|
||||
</HStack>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { Input } from './Input'
|
||||
export { TextInput } from './TextInput'
|
||||
export { Textarea } from './Textarea'
|
||||
export { SmartNumberInput } from './SmartNumberInput'
|
||||
export { NumberInput } from './NumberInput'
|
||||
|
||||
Reference in New Issue
Block a user