Merge pull request #5161 from naturalcrit/refactor-combobox

Refactor of combobox as functional
This commit is contained in:
Víctor Losada Hernández
2026-10-30 00:35:22 +01:00
committed by GitHub
3 changed files with 99 additions and 107 deletions
+94 -104
View File
@@ -1,143 +1,133 @@
import React from 'react';
import createReactClass from 'create-react-class';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import _ from 'lodash';
import './combobox.less';
const Combobox = createReactClass({
displayName : 'Combobox',
getDefaultProps : function() {
return {
id : '',
className : '',
trigger : 'hover',
default : '',
placeholder : '',
tooltip : '',
autoSuggest : {
clearAutoSuggestOnClick : true,
suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
},
valuePatterns : /.+/
};
const Combobox = ({
id = '',
className = '',
trigger = 'hover',
defaultValue = '',
placeholder = '',
tooltip = '',
autoSuggest = {
clearAutoSuggestOnClick : true,
suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
},
getInitialState : function() {
this.dropdownRef = React.createRef();
return {
showDropdown : false,
value : '',
options : [...this.props.options],
inputFocused : false
};
},
componentDidMount : function() {
if(this.props.trigger == 'click')
document.addEventListener('click', this.handleClickOutside);
this.setState({
value : this.props.default
});
},
componentWillUnmount : function() {
if(this.props.trigger == 'click')
document.removeEventListener('click', this.handleClickOutside);
},
handleClickOutside : function(e){
// Close dropdown when clicked outside
if(this.dropdownRef.current && !this.dropdownRef.current.contains(e.target)) {
this.handleDropdown(false);
valuePatterns = '.+',
options = [],
keepValueOnClose = false,
...props
})=>{
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [value, setValue] = useState(defaultValue);
useEffect(()=>{
if(trigger === 'click') {
document.addEventListener('click', handleClickOutside);
return ()=>document.removeEventListener('click', handleClickOutside);
}
},
handleDropdown : function(show){
this.setState({
value : show ? '' : this.props.default,
showDropdown : show,
inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false
});
},
handleInput : function(e){
e.persist();
this.setState({
value : e.target.value,
inputFocused : false
}, ()=>{
this.props.onEntry(e);
});
},
handleSelect : function(value, data=value){
this.setState({
value : value
}, ()=>{this.props.onSelect(data);});
;
},
renderTextInput : function(){
}, []);
const handleClickOutside = useCallback((e)=>{
// Close dropdown when clicked outside
if(!dropdownRef.current?.contains(e.target)) {
handleDropdown(false);
}
}, []);
const handleDropdown = (show)=>{
if(show) {
if(autoSuggest.clearAutoSuggestOnClick) {
setValue('');
}
} else if(!keepValueOnClose) {
setValue(defaultValue);
}
setShowDropdown(show);
setInputFocused(autoSuggest.clearAutoSuggestOnClick && show);
};
const handleInput = (e)=>{
setValue(e.target.value);
setInputFocused(false);
props.onEntry(e);
};
const handleSelect = (value, data=value)=>{
setValue(value);
props.onSelect(data);
};
const renderTextInput = ()=>{
return (
<div className='dropdown-input item'
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
onMouseEnter={trigger == 'hover' ? ()=>{handleDropdown(true);} : undefined}
onClick= {trigger == 'click' ? ()=>{handleDropdown(true);} : undefined}
{...(tooltip ? { 'data-tooltip-right': tooltip } : {})}>
<input
id={this.props.id}
id={id}
type='text'
onChange={(e)=>this.handleInput(e)}
value={this.state.value || ''}
onChange={(e)=>handleInput(e)}
value={value || ''}
title=''
pattern={this.props.valuePatterns}
placeholder={this.props.placeholder}
pattern={valuePatterns}
placeholder={placeholder}
onBlur={(e)=>{
if(!e.target.checkValidity()){
this.setState({
value : this.props.default
});
setValue(defaultValue);
}
}}
onKeyDown={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
this.props.onEntry(e);
props.onEntry(e);
}
}}
/>
<i className='fas fa-caret-down'/>
</div>
);
},
renderDropdown : function(dropdownChildren){
if(!this.state.showDropdown) return null;
if(this.props.autoSuggest && !this.state.inputFocused){
const suggestMethod = this.props.autoSuggest.suggestMethod;
const filterOn = _.isString(this.props.autoSuggest.filterOn) ? [this.props.autoSuggest.filterOn] : this.props.autoSuggest.filterOn;
};
const renderDropdown = ()=>{
if(!showDropdown) return null;
let dropdownChildren = options.map((child, i)=>{
const clone = React.cloneElement(child, { onClick: ()=>handleSelect(child.props.value, child.props.data) });
return clone;
});
if(autoSuggest && !inputFocused){
const suggestMethod = autoSuggest.suggestMethod;
const filterOn = _.isString(autoSuggest.filterOn) ? [autoSuggest.filterOn] : autoSuggest.filterOn;
const filteredArrays = filterOn.map((attr)=>{
const children = dropdownChildren.filter((item)=>{
if(suggestMethod === 'includes')
return item.props[attr]?.toLowerCase().includes(this.state.value.toLowerCase());
return item.props[attr]?.toLowerCase().includes(value.toLowerCase());
if(suggestMethod === 'startsWith')
return item.props[attr]?.toLowerCase().startsWith(this.state.value.toLowerCase());
return item.props[attr]?.toLowerCase().startsWith(value.toLowerCase());
});
return children;
});
dropdownChildren = _.uniq(filteredArrays.flat(1));
}
return (
<div className='dropdown-options'>
{dropdownChildren}
</div>
);
},
render : function () {
const dropdownChildren = this.state.options.map((child, i)=>{
const clone = React.cloneElement(child, { onClick: ()=>this.handleSelect(child.props.value, child.props.data) });
return clone;
});
return (
<div className={`dropdown-container ${this.props.className}`}
ref={this.dropdownRef}
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
{this.renderTextInput()}
{this.renderDropdown(dropdownChildren)}
</div>
);
}
});
};
return (
<div className={`dropdown-container ${className}`}
ref={dropdownRef}
onMouseLeave={trigger == 'hover' ? ()=>{handleDropdown(false);} : undefined}>
{renderTextInput()}
{renderDropdown()}
</div>
);
};
export default Combobox;
@@ -219,7 +219,7 @@ const MetadataEditor = ({
<Combobox trigger='click'
id='combobox-themes'
className='themes-dropdown'
default={currentThemeDisplay}
defaultValue={currentThemeDisplay}
placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag'
onSelect={(value)=>handleTheme(value)}
onEntry={(e)=>{
@@ -261,8 +261,10 @@ const MetadataEditor = ({
<Combobox trigger='click'
id='combobox-language'
className='language-dropdown'
default={metadata.lang || ''}
defaultValue={metadata.lang || ''}
keepValueOnClose={true}
placeholder='en'
valuePatterns="^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$"
onSelect={(value)=>handleLanguage(value)}
onEntry={(e)=>{
e.target.setCustomValidity(''); //Clear the validation popup while typing
+1 -1
View File
@@ -131,7 +131,7 @@ const TagInput = ({ id, tooltip, label, valuePatterns, values = [], unique = tru
id={id}
trigger='click'
className='tagInput-dropdown'
default=''
defaultValue=''
placeholder={placeholder}
options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip}