Merge pull request #12269 from ToolJet/feat/dropdown-multiselect-navigation

Feat: Added support for arrow key navigation in Dropdown V2 and Multiselect V2
This commit is contained in:
Johnson Cherian 2025-04-01 16:41:53 +05:30 committed by GitHub
commit be8dbfe611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 84 additions and 21 deletions

@ -1 +1 @@
Subproject commit 715a830c7a8d75efc7f77106292d9e4499005b69
Subproject commit d93ee7e1318f044ef2327671b8b257648071453d

View file

@ -295,7 +295,7 @@ export function Select({ componentMeta, darkMode, ...restProps }) {
</div>
<div className="field mb-2" data-cy={`input-and-label-column-name`}>
<CodeHinter
initialValue={isMultiSelect ? `{{${markedAsDefault.includes(item?.value)}}}` : item?.default?.value}
initialValue={isMultiSelect ? `{{${markedAsDefault?.includes(item?.value)}}}` : item?.default?.value}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}

View file

@ -15,6 +15,7 @@ import Label from '@/_ui/Label';
import cx from 'classnames';
import { getInputBackgroundColor, getInputBorderColor, getInputFocusedColor } from './utils';
import { isMobileDevice } from '@/_helpers/appUtils';
import useStore from '@/AppBuilder/_stores/store';
const { DropdownIndicator, ClearIndicator } = components;
const INDICATOR_CONTAINER_WIDTH = 60;
@ -39,7 +40,7 @@ export const CustomDropdownIndicator = (props) => {
export const CustomClearIndicator = (props) => {
return (
<ClearIndicator {...props}>
<ClearIndicatorIcon width={'18'} fill={'var(--borders-strong)'} className="cursor-pointer" />
<ClearIndicatorIcon width={'18'} fill={'var(--borders-strong)'} className="cursor-pointer clear-indicator" />
</ClearIndicator>
);
};
@ -88,6 +89,7 @@ export const DropdownV2 = ({
padding,
} = styles;
const isInitialRender = useRef(true);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [currentValue, setCurrentValue] = useState(() => findDefaultItem(schema));
const isMandatory = validation?.mandatory ?? false;
const options = properties?.options;
@ -95,11 +97,14 @@ export const DropdownV2 = ({
const { isValid, validationError } = validationStatus;
const ref = React.useRef(null);
const dropdownRef = React.useRef(null);
const selectRef = React.useRef(null);
const [visibility, setVisibility] = useState(properties.visibility);
const [isDropdownLoading, setIsDropdownLoading] = useState(dropdownLoadingState);
const [isDropdownDisabled, setIsDropdownDisabled] = useState(disabledState);
const [searchInputValue, setSearchInputValue] = useState('');
const [userInteracted, setUserInteracted] = useState(false);
const currentMode = useStore((state) => state.currentMode);
const isEditor = currentMode === 'edit';
const _height = padding === 'default' ? `${height}px` : `${height + 4}px`;
const labelRef = useRef();
@ -166,6 +171,12 @@ export const DropdownV2 = ({
setExposedVariable('isValid', validationStatus?.isValid);
};
const handleClickInEditor = (e) => {
if (e.target.className.includes('clear-indicator') || isMenuOpen) return;
e.stopPropagation();
selectRef.current?.onControlMouseDown(e);
};
useEffect(() => {
setInputValue(findDefaultItem(advanced ? schema : options));
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -353,15 +364,16 @@ export const DropdownV2 = ({
...provided,
padding: '0px',
}),
option: (provided) => ({
option: (provided, _state) => ({
...provided,
backgroundColor: 'var(--surfaces-surface-01)',
backgroundColor: _state.isFocused ? 'var(--interactive-overlays-fill-hover)' : 'var(--surfaces-surface-01)',
color:
selectedTextColor !== '#1B1F24'
? selectedTextColor
: isDropdownDisabled || isDropdownLoading
? 'var(--text-disabled)'
: 'var(--text-primary)',
borderRadius: _state.isFocused && '8px',
padding: '8px 6px 8px 38px',
'&:hover': {
backgroundColor: 'var(--interactive-overlays-fill-hover)',
@ -429,8 +441,14 @@ export const DropdownV2 = ({
_width={_width}
top={'1px'}
/>
<div className="w-100 px-0 h-100 dropdownV2-widget" ref={ref}>
<div
className="w-100 px-0 h-100 dropdownV2-widget"
ref={ref}
onMouseDownCapture={isEditor && handleClickInEditor}
>
<Select
ref={selectRef}
menuIsOpen={isMenuOpen}
isDisabled={isDropdownDisabled}
value={selectOptions.filter((option) => option.value === currentValue)[0] ?? null}
onChange={(selectedOption, actionProps) => {
@ -460,6 +478,7 @@ export const DropdownV2 = ({
ClearIndicator: CustomClearIndicator,
}}
isClearable
tabSelectsValue={false}
icon={icon}
doShowIcon={iconVisibility}
iconColor={iconColor}
@ -467,8 +486,24 @@ export const DropdownV2 = ({
darkMode={darkMode}
optionsLoadingState={optionsLoadingState && advanced}
menuPlacement="auto"
onMenuOpen={() => fireEvent('onFocus')}
onMenuClose={() => fireEvent('onBlur')}
onMenuOpen={() => {
setIsMenuOpen(true);
fireEvent('onFocus');
}}
onMenuClose={() => {
setIsMenuOpen(false);
fireEvent('onBlur');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isMenuOpen) {
setIsMenuOpen(true);
e.preventDefault();
}
if (e.key === 'Escape' && isMenuOpen) {
setIsMenuOpen(false);
e.preventDefault();
}
}}
/>
</div>
</div>

View file

@ -4,7 +4,7 @@ import * as Icons from '@tabler/icons-react';
const { ValueContainer, Placeholder } = components;
import './multiselectV2.scss';
const CustomValueContainer = ({ ...props }) => {
const CustomValueContainer = ({ children, ...props }) => {
const selectProps = props.selectProps;
const values = Array.isArray(selectProps?.value) && selectProps?.value?.map((option) => option.label);
const isAllOptionsSelected = selectProps?.value.length === selectProps.options.length;
@ -39,6 +39,13 @@ const CustomValueContainer = ({ ...props }) => {
{isAllOptionsSelected ? 'All items are selected.' : values.join(', ')}
</span>
)}
{/* Rendering children except Placeholder component to preserve the default behavior of react-select like focus
handling */}
{React.Children.map(children, (child) => {
if (child.type !== Placeholder) {
return child;
}
})}
</span>
</div>
</ValueContainer>

View file

@ -12,6 +12,7 @@ import Label from '@/_ui/Label';
const tinycolor = require('tinycolor2');
import { CustomDropdownIndicator, CustomClearIndicator } from '../DropdownV2/DropdownV2';
import { getInputBackgroundColor, getInputBorderColor, getInputFocusedColor } from '../DropdownV2/utils';
import useStore from '@/AppBuilder/_stores/store';
export const MultiselectV2 = ({
id,
@ -62,6 +63,7 @@ export const MultiselectV2 = ({
const isMandatory = validation?.mandatory ?? false;
const multiselectRef = React.useRef(null);
const labelRef = React.useRef(null);
const selectRef = React.useRef(null);
const [validationStatus, setValidationStatus] = useState(
validate(selected?.length ? selected?.map((option) => option.value) : null)
);
@ -74,6 +76,8 @@ export const MultiselectV2 = ({
const [searchInputValue, setSearchInputValue] = useState('');
const _height = padding === 'default' ? `${height}px` : `${height + 4}px`;
const [userInteracted, setUserInteracted] = useState(false);
const currentMode = useStore((state) => state.currentMode);
const isEditor = currentMode === 'edit';
const [isMultiselectOpen, setIsMultiselectOpen] = useState(false);
useEffect(() => {
@ -281,6 +285,12 @@ export const MultiselectV2 = ({
}
};
const handleClickInEditor = (e) => {
if (e.target.className.includes('clear-indicator') || isMultiselectOpen) return;
e.stopPropagation();
selectRef.current?.onControlMouseDown(e);
};
const setInputValue = (values) => {
setSelected(values);
setExposedVariables({
@ -386,7 +396,7 @@ export const MultiselectV2 = ({
}),
option: (provided, _state) => ({
...provided,
backgroundColor: 'var(--surfaces-surface-01)',
backgroundColor: _state.isFocused ? 'var(--interactive-overlays-fill-hover)' : 'var(--surfaces-surface-01)',
color: _state.isDisabled
? 'var(_--text-disbled)'
: selectedTextColor !== '#1B1F24'
@ -394,6 +404,7 @@ export const MultiselectV2 = ({
: isMultiSelectDisabled || isMultiSelectLoading
? 'var(--text-disabled)'
: 'var(--text-primary)',
borderRadius: _state.isFocused && '8px',
padding: '8px 6px 8px 12px',
'&:hover': {
backgroundColor: 'var(--interactive-overlays-fill-hover)',
@ -456,16 +467,9 @@ export const MultiselectV2 = ({
_width={_width}
top={'1px'}
/>
<div
className="w-100 px-0 h-100"
onClick={() => {
if (!isMultiSelectDisabled) {
fireEvent('onFocus');
setIsMultiselectOpen(!isMultiselectOpen);
}
}}
>
<div className="w-100 px-0 h-100" onMouseDownCapture={isEditor && handleClickInEditor}>
<Select
ref={selectRef}
menuId={id}
isDisabled={isMultiSelectDisabled}
value={selected}
@ -490,9 +494,26 @@ export const MultiselectV2 = ({
isMulti
hideSelectedOptions={false}
closeMenuOnSelect={false}
tabSelectsValue={false}
controlShouldRenderValue={false}
isSearchable={false}
onMenuOpen={() => {
fireEvent('onFocus');
setIsMultiselectOpen(true);
fireEvent('onFocus');
}}
onMenuClose={() => {
setIsMultiselectOpen(false);
fireEvent('onBlur');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isMultiselectOpen) {
setIsMultiselectOpen(true);
e.preventDefault();
}
if (e.key === 'Escape' && isMultiselectOpen) {
setIsMultiselectOpen(false);
e.preventDefault();
}
}}
// select props
icon={icon}

@ -1 +1 @@
Subproject commit 0eefbb71a1d5288f49641af5efaaab25970f27d1
Subproject commit 1da04eef696345ce9f35d42af92e5d6de992cd85