Open Reavxn opened 10 months ago
👋 Thanks for opening your first issue here! If you're reporting a 🐞 bug, please make sure you've completed all the fields in the issue template so we can best help.
We get a lot of issues on this repo, so please be patient and we will get back to you as soon as we can.
No comments ?
I'll make PR end of March then, I guess
I need this, any update ?
so....any update on that! Such an important fix, specially talking about semantic and accessibility.
Did you open a Pull Request, @Reavxn ?
so....any update on that! Such an important fix, specially talking about semantic and accessibility.
Did you open a Pull Request, @Reavxn ?
Of course... not, ahah, completly forgot about this. I've been using this "feature" (fix ?) since I wrote this and so far no problems for my use cases.
I'll just share the code below, I have no time to make a clean PR (since I never did one and don't know how it works) and also didn't do unit tests so...
I duplicated the Dropdown as "Combobox" in a package of mine, so I only use it when I need it (to prevent too many maintenance issue if there is).
Basically two new props, closeOnScroll and menuPortalTarget, which I use as menuPortalTarget={document.body}
when needed.
Here's the code (and below the typescript definition of those 2 additionnals props) :
Combobox.jsx
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable consistent-return */
/* eslint-disable react/sort-comp */
/* eslint-disable react/destructuring-assignment */
/* eslint-disable no-underscore-dangle */
import _ from 'lodash';
import React, { Children, cloneElement, createRef } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import keyboardKey from 'keyboard-key';
import shallowEqual from 'shallowequal';
import EventStack from '@semantic-ui-react/event-stack';
import {
Icon,
Label,
Flag,
Image,
Dropdown,
DropdownDivider,
DropdownItem,
DropdownHeader,
DropdownMenu,
DropdownSearchInput,
DropdownText,
} from 'semantic-ui-react';
import { useKey, useValue } from '@jvs-group/jvs-mairistem-tools';
// copied from SUIR because unexported
import {
ModernAutoControlledComponent,
doesNodeContainClick,
getComponentType,
getMenuOptions,
getSelectedIndex,
getUnhandledProps,
setRef,
} from './utils';
const getKeyOrValue = (key, value) => (_.isNil(key) ? value : key);
const getKeyAndValues = (options) => (options ? options.map((option) => _.pick(option, ['key', 'value'])) : options);
function renderItemContent(item) {
const { flag, image, text } = item;
// TODO: remove this in v3
// This maintains compatibility with Shorthand API in v1 as this might be called in Label.create()
if (_.isFunction(text)) {
return text;
}
return {
content: (
<>
{Flag.create(flag)}
{Image.create(image)}
{text}
</>
),
};
}
/**
* A fork of dropdown with some more functionnalities.
* @see Form
* @see Select
* @see Menu
*/
const Combobox = React.forwardRef((props, ref) => {
const {
additionLabel = 'Add ',
additionPosition = 'top',
closeOnBlur = true,
closeOnEscape = true,
deburr = false,
icon = 'dropdown',
minCharacters = 1,
noResultsMessage = 'No results found.',
openOnFocus = true,
renderLabel = renderItemContent,
searchInput = 'text',
selectOnBlur = true,
selectOnNavigation = true,
wrapSelection = true,
...rest
} = props;
return (
<DropdownInner
additionLabel={additionLabel}
additionPosition={additionPosition}
closeOnBlur={closeOnBlur}
closeOnEscape={closeOnEscape}
deburr={deburr}
icon={icon}
minCharacters={minCharacters}
noResultsMessage={noResultsMessage}
openOnFocus={openOnFocus}
renderLabel={renderLabel}
searchInput={searchInput}
selectOnBlur={selectOnBlur}
selectOnNavigation={selectOnNavigation}
wrapSelection={wrapSelection}
{...rest}
innerRef={ref}
/>
);
});
class DropdownInner extends ModernAutoControlledComponent {
searchRef = createRef();
sizerRef = createRef();
ref = createRef();
portalRef = createRef();
resizeObserver = null;
constructor(props) {
super(props);
if (props.menuPortalTarget) {
this.setupResizeObserver();
}
}
static getAutoControlledStateFromProps(nextProps, computedState, prevState) {
// These values are stored only for a comparison on next getAutoControlledStateFromProps()
const derivedState = { __options: nextProps.options, __value: computedState.value };
// The selected index is only dependent:
// On value change
const shouldComputeSelectedIndex = !shallowEqual(prevState.__value, computedState.value)
// On option keys/values,
// we only check those properties to avoid recursive performance impacts.
// https://github.com/Semantic-Org/Semantic-UI-React/issues/3000
|| !_.isEqual(getKeyAndValues(nextProps.options), getKeyAndValues(prevState.__options));
if (shouldComputeSelectedIndex) {
derivedState.selectedIndex = getSelectedIndex({
additionLabel: nextProps.additionLabel,
additionPosition: nextProps.additionPosition,
allowAdditions: nextProps.allowAdditions,
deburr: nextProps.deburr,
multiple: nextProps.multiple,
search: nextProps.search,
selectedIndex: computedState.selectedIndex,
value: computedState.value,
options: nextProps.options,
searchQuery: computedState.searchQuery,
});
}
return derivedState;
}
componentDidMount() {
const { open } = this.state;
if (open) {
this.open(null, false);
}
}
shouldComponentUpdate(nextProps, nextState) {
return !shallowEqual(nextProps, this.props) || !shallowEqual(nextState, this.state);
}
componentDidUpdate(prevProps, prevState) {
const {
closeOnBlur, minCharacters, openOnFocus, search,
} = this.props;
/* eslint-disable no-console */
if (process.env.NODE_ENV !== 'production') {
// in development, validate value type matches dropdown type
const isNextValueArray = Array.isArray(this.props.value);
const hasValue = _.has(this.props, 'value');
if (hasValue && this.props.multiple && !isNextValueArray) {
console.error(
'Combobox `value` must be an array when `multiple` is set.'
+ ` Received type: \`${Object.prototype.toString.call(this.props.value)}\`.`,
);
} else if (hasValue && !this.props.multiple && isNextValueArray) {
console.error(
'Combobox `value` must not be an array when `multiple` is not set.'
+ ' Either set `multiple={true}` or use a string or number value.',
);
}
}
/* eslint-enable no-console */
// focused / blurred
if (!prevState.focus && this.state.focus) {
if (!this.isMouseDown) {
const openable = !search || (search && minCharacters === 1 && !this.state.open);
if (openOnFocus && openable) this.open();
}
} else if (prevState.focus && !this.state.focus) {
if (!this.isMouseDown && closeOnBlur) {
this.close();
}
}
// opened / closed
if (!prevState.open && this.state.open) {
this.setOpenDirection();
this.scrollSelectedItemIntoView();
}
if (prevState.selectedIndex !== this.state.selectedIndex) {
this.scrollSelectedItemIntoView();
}
}
componentWillUnmount() {
this.resizeObserver?.disconnect();
}
// eslint-disable-next-line class-methods-use-this, react/no-unused-class-component-methods
getInitialAutoControlledState() {
return { focus: false, searchQuery: '' };
}
handleRef = (el) => {
this.ref.current = el;
setRef(this.props.innerRef, el);
// Set for the first mount trigger's dimensions
if (el) {
const rect = el.getBoundingClientRect();
this.setState({ dropdownDimensions: rect });
}
};
handlePortalRef = (el) => {
this.portalRef.current = el;
// Setup du resizeObserver sur la dropdown
if (this.ref?.current) {
this.resizeObserver?.observe(this.ref.current);
}
};
// ----------------------------------------
// Document Event Handlers
// ----------------------------------------
// onChange needs to receive a value
// can't rely on props.value if we are controlled
handleChange = (e, value) => {
_.invoke(this.props, 'onChange', e, { ...this.props, value });
};
closeOnChange = (e) => {
const { closeOnChange, multiple } = this.props;
const shouldClose = _.isUndefined(closeOnChange) ? !multiple : closeOnChange;
if (shouldClose) {
this.close(e, _.noop);
}
};
closeOnEscape = (e) => {
if (!this.props.closeOnEscape) return;
if (keyboardKey.getCode(e) !== keyboardKey.Escape) return;
e.preventDefault();
this.close(e);
};
closeOnScroll = (e) => {
/**
* Note about portaling the menu :
*
* Keeping the menu's open state when scrolling would require to
* reposition it when a scroll event appears in any of the parents,
* with possibly nested scrolls, which is not supported yet.
*
* Once handled, removing the menuPortalTarget condition
* will allow the menu to stay open (or not)
* when scrolling anywhere on window
* according to the closeOnScroll props.
*/
if (!this.props.menuPortalTarget && !this.props.closeOnScroll) return;
// If event happened in the dropdown's menu, ignore it
if (
(this.ref.current && doesNodeContainClick(this.ref.current, e))
|| (this.portalRef.current && doesNodeContainClick(this.portalRef.current, e))
) {
return;
}
e.preventDefault();
this.close(e);
};
moveSelectionOnKeyDown = (e) => {
const { multiple, selectOnNavigation } = this.props;
const { open } = this.state;
if (!open) {
return;
}
const moves = {
[keyboardKey.ArrowDown]: 1,
[keyboardKey.ArrowUp]: -1,
};
const move = moves[keyboardKey.getCode(e)];
if (move === undefined) {
return;
}
e.preventDefault();
const nextIndex = this.getSelectedIndexAfterMove(move);
if (!multiple && selectOnNavigation) {
this.makeSelectedItemActive(e, nextIndex);
}
this.setState({ selectedIndex: nextIndex });
};
openOnSpace = (e) => {
const shouldHandleEvent = this.state.focus
&& !this.state.open
&& keyboardKey.getCode(e) === keyboardKey.Spacebar;
const shouldPreventDefault = e.target?.tagName !== 'INPUT'
&& e.target?.tagName !== 'TEXTAREA'
&& e.target?.isContentEditable !== true;
if (shouldHandleEvent) {
if (shouldPreventDefault) {
e.preventDefault();
}
this.open(e);
}
};
openOnArrow = (e) => {
const { focus, open } = this.state;
if (focus && !open) {
const code = keyboardKey.getCode(e);
if (code === keyboardKey.ArrowDown || code === keyboardKey.ArrowUp) {
e.preventDefault();
this.open(e);
}
}
};
makeSelectedItemActive = (e, selectedIndex) => {
const { open, value } = this.state;
const { multiple } = this.props;
const item = this.getSelectedItem(selectedIndex);
const selectedValue = _.get(item, 'value');
const disabled = _.get(item, 'disabled');
// prevent selecting null if there was no selected item value
// prevent selecting duplicate items when the dropdown is closed
// prevent selecting disabled items
if (_.isNil(selectedValue) || !open || disabled) {
return value;
}
// state value may be undefined
const newValue = multiple ? _.union(value, [selectedValue]) : selectedValue;
const valueHasChanged = multiple ? !!_.difference(newValue, value).length : newValue !== value;
if (valueHasChanged) {
// notify the onChange prop that the user is trying to change value
this.setState({ value: newValue });
this.handleChange(e, newValue);
// Heads up! This event handler should be called after `onChange`
// Notify the onAddItem prop if this is a new value
if (item['data-additional']) {
_.invoke(this.props, 'onAddItem', e, { ...this.props, value: selectedValue });
}
}
return value;
};
selectItemOnEnter = (e) => {
const { search } = this.props;
const { open, selectedIndex } = this.state;
if (!open) {
return;
}
const shouldSelect = keyboardKey.getCode(e) === keyboardKey.Enter
// https://github.com/Semantic-Org/Semantic-UI-React/pull/3766
|| (!search && keyboardKey.getCode(e) === keyboardKey.Spacebar);
if (!shouldSelect) {
return;
}
e.preventDefault();
const optionSize = _.size(
getMenuOptions({
value: this.state.value,
options: this.props.options,
searchQuery: this.state.searchQuery,
additionLabel: this.props.additionLabel,
additionPosition: this.props.additionPosition,
allowAdditions: this.props.allowAdditions,
deburr: this.props.deburr,
multiple: this.props.multiple,
search: this.props.search,
}),
);
if (search && optionSize === 0) {
return;
}
const nextValue = this.makeSelectedItemActive(e, selectedIndex);
// This is required as selected value may be the same
this.setState({
selectedIndex: getSelectedIndex({
additionLabel: this.props.additionLabel,
additionPosition: this.props.additionPosition,
allowAdditions: this.props.allowAdditions,
deburr: this.props.deburr,
multiple: this.props.multiple,
search: this.props.search,
selectedIndex,
value: nextValue,
options: this.props.options,
searchQuery: '',
}),
});
this.closeOnChange(e);
this.clearSearchQuery();
if (search) {
_.invoke(this.searchRef.current, 'focus');
}
};
removeItemOnBackspace = (e) => {
const { multiple, search } = this.props;
const { searchQuery, value } = this.state;
if (keyboardKey.getCode(e) !== keyboardKey.Backspace) return;
if (searchQuery || !search || !multiple || _.isEmpty(value)) return;
e.preventDefault();
// remove most recent value
const newValue = _.dropRight(value);
this.setState({ value: newValue });
this.handleChange(e, newValue);
};
closeOnDocumentClick = (e) => {
if (!this.props.closeOnBlur) return;
// If event happened in the dropdown or its menu, ignore it
if (
this.ref.current
&& doesNodeContainClick(
this.props.menuPortalTarget ? this.portalRef.current : this.ref.current,
e,
)
) {
return;
}
this.close();
};
setupResizeObserver = () => {
this.resizeObserver = new ResizeObserver(async (entries) => {
if (_.size(entries) > 0 && entries[0].target) {
// Updating dimensions because ResizeObserver informations are relative to parent,
// not from window.
const contentRect = entries[0].target.getBoundingClientRect();
this.setState({ dropdownDimensions: contentRect });
}
});
};
// ----------------------------------------
// Component Event Handlers
// ----------------------------------------
handleMouseDown = (e) => {
this.isMouseDown = true;
_.invoke(this.props, 'onMouseDown', e, this.props);
document.addEventListener('mouseup', this.handleDocumentMouseUp);
};
handleDocumentMouseUp = () => {
this.isMouseDown = false;
document.removeEventListener('mouseup', this.handleDocumentMouseUp);
};
handleClick = (e) => {
const { minCharacters, search } = this.props;
const { open, searchQuery } = this.state;
_.invoke(this.props, 'onClick', e, this.props);
// prevent closeOnDocumentClick()
e.stopPropagation();
if (!search) return this.toggle(e);
if (open) {
_.invoke(this.searchRef.current, 'focus');
return;
}
if (searchQuery.length >= minCharacters || minCharacters === 1) {
this.open(e);
return;
}
_.invoke(this.searchRef.current, 'focus');
};
handleIconClick = (e) => {
const { clearable } = this.props;
const hasValue = this.hasValue();
_.invoke(this.props, 'onClick', e, this.props);
// prevent handleClick()
e.stopPropagation();
if (clearable && hasValue) {
this.clearValue(e);
} else {
this.toggle(e);
}
};
handleItemClick = (e, item) => {
const { multiple, search } = this.props;
const { value: currentValue } = this.state;
const { value } = item;
// prevent toggle() in handleClick()
e.stopPropagation();
// prevent closeOnDocumentClick() if multiple or item is disabled
if (multiple || item.disabled) {
e.nativeEvent.stopImmediatePropagation();
}
if (item.disabled) {
return;
}
const isAdditionItem = item['data-additional'];
const newValue = multiple ? _.union(this.state.value, [value]) : value;
const valueHasChanged = multiple
? !!_.difference(newValue, currentValue).length
: newValue !== currentValue;
// notify the onChange prop that the user is trying to change value
if (valueHasChanged) {
this.setState({ value: newValue });
this.handleChange(e, newValue);
}
this.clearSearchQuery();
if (search) {
_.invoke(this.searchRef.current, 'focus');
} else {
_.invoke(this.ref.current, 'focus');
}
this.closeOnChange(e);
// Heads up! This event handler should be called after `onChange`
// Notify the onAddItem prop if this is a new value
if (isAdditionItem) {
_.invoke(this.props, 'onAddItem', e, { ...this.props, value });
}
};
handleFocus = (e) => {
const { focus } = this.state;
if (focus) return;
_.invoke(this.props, 'onFocus', e, this.props);
this.setState({ focus: true });
};
handleBlur = (e) => {
// Heads up! Don't remove this.
// https://github.com/Semantic-Org/Semantic-UI-React/issues/1315
const currentTarget = _.get(e, 'currentTarget');
if (currentTarget && currentTarget.contains(document.activeElement)) return;
const { closeOnBlur, multiple, selectOnBlur } = this.props;
// do not "blur" when the mouse is down inside of the Combobox
if (this.isMouseDown) return;
_.invoke(this.props, 'onBlur', e, this.props);
if (selectOnBlur && !multiple) {
this.makeSelectedItemActive(e, this.state.selectedIndex);
if (closeOnBlur) this.close();
}
this.setState({ focus: false });
this.clearSearchQuery();
};
handleSearchChange = (e, { value }) => {
// prevent propagating to this.props.onChange()
e.stopPropagation();
const { minCharacters } = this.props;
const { open } = this.state;
const newQuery = value;
_.invoke(this.props, 'onSearchChange', e, { ...this.props, searchQuery: newQuery });
this.setState({ searchQuery: newQuery, selectedIndex: 0 });
// open search dropdown on search query
if (!open && newQuery.length >= minCharacters) {
this.open();
return;
}
// close search dropdown if search query is too small
if (open && minCharacters !== 1 && newQuery.length < minCharacters) this.close();
};
handleKeyDown = (e) => {
this.moveSelectionOnKeyDown(e);
this.openOnArrow(e);
this.openOnSpace(e);
this.selectItemOnEnter(e);
_.invoke(this.props, 'onKeyDown', e);
};
// ----------------------------------------
// Getters
// ----------------------------------------
getSelectedItem = (selectedIndex) => {
const options = getMenuOptions({
value: this.state.value,
options: this.props.options,
searchQuery: this.state.searchQuery,
additionLabel: this.props.additionLabel,
additionPosition: this.props.additionPosition,
allowAdditions: this.props.allowAdditions,
deburr: this.props.deburr,
multiple: this.props.multiple,
search: this.props.search,
});
return _.get(options, `[${selectedIndex}]`);
};
getItemByValue = (value) => {
const { options } = this.props;
return _.find(options, { value });
};
getDropdownAriaOptions = () => {
const {
loading, disabled, search, multiple,
} = this.props;
const { open } = this.state;
const ariaOptions = {
role: search ? 'combobox' : 'listbox',
'aria-busy': loading,
'aria-disabled': disabled,
'aria-expanded': !!open,
};
if (ariaOptions.role === 'listbox') {
ariaOptions['aria-multiselectable'] = multiple;
}
return ariaOptions;
};
getDropdownMenuAriaOptions() {
const { search, multiple } = this.props;
const ariaOptions = {};
if (search) {
ariaOptions['aria-multiselectable'] = multiple;
ariaOptions.role = 'listbox';
}
return ariaOptions;
}
getPortalRect = () => {
const { pointing } = this.props;
const { dropdownDimensions, upward } = this.state;
const scrollDistance = window.scrollY;
let rect = dropdownDimensions;
if (!dropdownDimensions) {
rect = this.ref?.current?.getBoundingClientRect();
}
let upwardHeight = rect.height;
if (_.includes(['left', 'right'], pointing)) {
if (!upward) {
upwardHeight = 0;
}
} else if (upward) {
upwardHeight = 1; // So the menu goes over the dropdown a bit, to hide dropdown's top border.
}
return {
top: rect.top + scrollDistance + upwardHeight - 1, // 1 = border
left: rect.left,
width: rect.width,
};
};
// ----------------------------------------
// Setters
// ----------------------------------------
clearSearchQuery = () => {
const { searchQuery } = this.state;
if (searchQuery === undefined || searchQuery === '') return;
this.setState({ searchQuery: '' });
};
handleLabelClick = (e, labelProps) => {
// prevent focusing search input on click
e.stopPropagation();
this.setState({ selectedLabel: labelProps.value });
_.invoke(this.props, 'onLabelClick', e, labelProps);
};
handleLabelRemove = (e, labelProps) => {
// prevent focusing search input on click
e.stopPropagation();
const { value } = this.state;
const newValue = _.without(value, labelProps.value);
this.setState({ value: newValue });
this.handleChange(e, newValue);
};
getSelectedIndexAfterMove = (offset, startIndex = this.state.selectedIndex) => {
const options = getMenuOptions({
value: this.state.value,
options: this.props.options,
searchQuery: this.state.searchQuery,
additionLabel: this.props.additionLabel,
additionPosition: this.props.additionPosition,
allowAdditions: this.props.allowAdditions,
deburr: this.props.deburr,
multiple: this.props.multiple,
search: this.props.search,
});
// Prevent infinite loop
// TODO: remove left part of condition after children API will be removed
if (options === undefined || _.every(options, 'disabled')) return;
const lastIndex = options.length - 1;
const { wrapSelection } = this.props;
// next is after last, wrap to beginning
// next is before first, wrap to end
let nextIndex = startIndex + offset;
// if 'wrapSelection' is set to false and selection is after last or before first,
// it just does not change
if (!wrapSelection && (nextIndex > lastIndex || nextIndex < 0)) {
nextIndex = startIndex;
} else if (nextIndex > lastIndex) {
nextIndex = 0;
} else if (nextIndex < 0) {
nextIndex = lastIndex;
}
if (options[nextIndex].disabled) {
return this.getSelectedIndexAfterMove(offset, nextIndex);
}
return nextIndex;
};
// ----------------------------------------
// Overrides
// ----------------------------------------
handleIconOverrides = (predefinedProps) => {
const { clearable } = this.props;
const classes = classNames(clearable && this.hasValue() && 'clear', predefinedProps.className);
return {
className: classes,
onClick: (e) => {
_.invoke(predefinedProps, 'onClick', e, predefinedProps);
this.handleIconClick(e);
},
};
};
// ----------------------------------------
// Helpers
// ----------------------------------------
clearValue = (e) => {
const { multiple } = this.props;
const newValue = multiple ? [] : '';
this.setState({ value: newValue });
this.handleChange(e, newValue);
};
computeSearchInputTabIndex = () => {
const { disabled, tabIndex } = this.props;
if (!_.isNil(tabIndex)) return tabIndex;
return disabled ? -1 : 0;
};
computeSearchInputWidth = () => {
const { searchQuery } = this.state;
if (this.sizerRef.current && searchQuery) {
// resize the search input, temporarily show the sizer so we can measure it
this.sizerRef.current.style.display = 'inline';
this.sizerRef.current.textContent = searchQuery;
const searchWidth = Math.ceil(this.sizerRef.current.getBoundingClientRect().width);
this.sizerRef.current.style.removeProperty('display');
return searchWidth;
}
};
computeTabIndex = () => {
const { disabled, search, tabIndex } = this.props;
// don't set a root node tabIndex as the search input has its own tabIndex
if (search) return undefined;
if (disabled) return -1;
return _.isNil(tabIndex) ? 0 : tabIndex;
};
handleSearchInputOverrides = (predefinedProps) => ({
onChange: (e, inputProps) => {
_.invoke(predefinedProps, 'onChange', e, inputProps);
this.handleSearchChange(e, inputProps);
},
ref: this.searchRef,
});
hasValue = () => {
const { multiple } = this.props;
const { value } = this.state;
return multiple ? !_.isEmpty(value) : !_.isNil(value) && value !== '';
};
// ----------------------------------------
// Behavior
// ----------------------------------------
scrollSelectedItemIntoView = () => {
if (!this.ref.current) return;
const menu = this.ref.current.querySelector('.menu.visible');
if (!menu) return;
const item = menu.querySelector('.item.selected');
if (!item) return;
const isOutOfUpperView = item.offsetTop < menu.scrollTop;
const isOutOfLowerView = item.offsetTop + item.clientHeight
> menu.scrollTop + menu.clientHeight;
if (isOutOfUpperView) {
menu.scrollTop = item.offsetTop;
} else if (isOutOfLowerView) {
// eslint-disable-next-line no-mixed-operators
menu.scrollTop = item.offsetTop + item.clientHeight - menu.clientHeight;
}
};
setOpenDirection = () => {
const { menuPortalTarget } = this.props;
if (!this.ref.current) return;
const menu = menuPortalTarget
? this.portalRef.current.querySelector('.menu.visible')
: this.ref.current.querySelector('.menu.visible');
if (!menu) return;
const dropdownRect = this.ref.current.getBoundingClientRect();
const menuHeight = menu.clientHeight;
const spaceAtTheBottom = document.documentElement.clientHeight
- dropdownRect.top
- dropdownRect.height
- menuHeight;
const spaceAtTheTop = dropdownRect.top - menuHeight;
const upward = spaceAtTheBottom < 0 && spaceAtTheTop > spaceAtTheBottom;
// set state only if there's a relevant difference
if (!upward !== !this.state.upward) {
this.setState({ upward });
}
};
open = (e = null, triggerSetState = true) => {
const { disabled, search } = this.props;
if (disabled) return;
if (search) _.invoke(this.searchRef.current, 'focus');
_.invoke(this.props, 'onOpen', e, this.props);
if (triggerSetState) {
// Mise à jour des dimensions et positions du trigger
const rect = this.ref.current.getBoundingClientRect();
this.setState({ open: true, dropdownDimensions: rect });
}
this.scrollSelectedItemIntoView();
};
close = (e, callback = this.handleClose) => {
if (this.state.open) {
_.invoke(this.props, 'onClose', e, this.props);
this.setState({ open: false }, callback);
}
};
handleClose = () => {
const hasSearchFocus = document.activeElement === this.searchRef.current;
// https://github.com/Semantic-Org/Semantic-UI-React/issues/627
// Blur the Combobox on close so it is blurred after selecting an item.
// This is to prevent it from re-opening when switching tabs after selecting an item.
if (!hasSearchFocus && this.ref.current) {
this.ref.current.blur();
}
const hasDropdownFocus = document.activeElement === this.ref.current;
const hasFocus = hasSearchFocus || hasDropdownFocus;
// We need to keep the virtual model in sync with the browser focus change
// https://github.com/Semantic-Org/Semantic-UI-React/issues/692
this.setState({ focus: hasFocus });
//
};
toggle = (e) => (this.state.open ? this.close(e) : this.open(e));
// ----------------------------------------
// Render
// ----------------------------------------
renderText = () => {
const {
multiple, placeholder, search, text,
} = this.props;
const {
searchQuery, selectedIndex, value, open,
} = this.state;
const hasValue = this.hasValue();
const classes = classNames(
placeholder && !hasValue && 'default',
'text',
search && searchQuery && 'filtered',
);
let _text = placeholder;
let selectedItem;
if (text) {
_text = text;
} else if (open && !multiple) {
selectedItem = this.getSelectedItem(selectedIndex);
} else if (hasValue) {
selectedItem = this.getItemByValue(value);
}
return DropdownText.create(selectedItem ? renderItemContent(selectedItem) : _text, {
defaultProps: {
className: classes,
},
});
};
renderSearchInput = () => {
const { search, searchInput } = this.props;
const { searchQuery } = this.state;
return (
search
&& DropdownSearchInput.create(searchInput, {
defaultProps: {
style: { width: this.computeSearchInputWidth() },
tabIndex: this.computeSearchInputTabIndex(),
value: searchQuery,
},
overrideProps: this.handleSearchInputOverrides,
})
);
};
renderSearchSizer = () => {
const { search, multiple } = this.props;
return search && multiple && <span className="sizer" ref={this.sizerRef} />;
};
renderLabels = () => {
const { multiple, renderLabel } = this.props;
const { selectedLabel, value } = this.state;
if (!multiple || _.isEmpty(value)) {
return;
}
const selectedItems = _.map(value, this.getItemByValue);
// if no item could be found for a given state value the selected item will be undefined
// compact the selectedItems so we only have actual objects left
return _.map(_.compact(selectedItems), (item, index) => {
const defaultProps = {
active: item.value === selectedLabel,
as: 'a',
key: getKeyOrValue(item.key, item.value),
onClick: this.handleLabelClick,
onRemove: this.handleLabelRemove,
value: item.value,
};
return Label.create(renderLabel(item, index, defaultProps), { defaultProps });
});
};
renderOptions = () => {
const {
lazyLoad, multiple, search, noResultsMessage,
} = this.props;
const { open, selectedIndex, value } = this.state;
// lazy load, only render options when open
if (lazyLoad && !open) return null;
const options = getMenuOptions({
value: this.state.value,
options: this.props.options,
searchQuery: this.state.searchQuery,
additionLabel: this.props.additionLabel,
additionPosition: this.props.additionPosition,
allowAdditions: this.props.allowAdditions,
deburr: this.props.deburr,
multiple: this.props.multiple,
search: this.props.search,
});
if (noResultsMessage !== null && search && _.isEmpty(options)) {
return <div className="message">{noResultsMessage}</div>;
}
const isActive = multiple
? (optValue) => _.includes(value, optValue)
: (optValue) => optValue === value;
return _.map(options, (opt, i) => DropdownItem.create(
{
active: isActive(opt.value),
selected: selectedIndex === i,
...opt,
key: getKeyOrValue(opt.key, opt.value),
// Needed for handling click events on disabled items
style: { ...opt.style, pointerEvents: 'all' },
},
{
generateKey: false,
overrideProps: (predefinedProps) => ({
onClick: (e, item) => {
predefinedProps.onClick?.(e, item);
this.handleItemClick(e, item);
},
}),
},
));
};
renderMenu = () => {
const { children, direction, header } = this.props;
const { open } = this.state;
const ariaOptions = this.getDropdownMenuAriaOptions();
// single menu child
if (!(children === null
|| children === undefined
|| (Array.isArray(children) && children.length === 0))
) {
const menuChild = Children.only(children);
const className = classNames(direction, useKey(open, 'visible'), menuChild.props.className);
return cloneElement(menuChild, { className, ...ariaOptions });
}
return (
<DropdownMenu {...ariaOptions} direction={direction} open={open}>
{DropdownHeader.create(header, { autoGenerateKey: false })}
{this.renderOptions()}
</DropdownMenu>
);
};
renderPortalMenu = () => {
const {
menuPortalTarget,
error,
loading,
basic,
button,
compact,
// fluid,
floating,
inline,
labeled,
item,
multiple,
search,
selection,
simple,
scrolling,
pointing,
className,
} = this.props;
const { open, upward } = this.state;
// If ref is not set, can't portal the menu on its position.
if (!this.ref?.current) return null;
// If it's closed, we don't render it to avoid DOM's body pollution if multiple dropdowns
if (!open) return null;
// Emulate a real dropdown to avoid restyling everything.
// Expect for active and visible, which will be forced to true in this case.
const portalClassName = classNames(
'ui',
'active',
'visible',
useKey(error, 'error'),
useKey(loading, 'loading'),
useKey(basic, 'basic'),
useKey(button, 'button'),
useKey(compact, 'compact'),
// fluid est commenté car lorsque le menu est rendu dans le body
// ,il prend toute la largeur de la fenêtre
// useKey(fluid, 'fluid'),
useKey(floating, 'floating'),
useKey(inline, 'inline'),
useKey(labeled, 'labeled'),
useKey(item, 'item'),
useKey(multiple, 'multiple'),
useKey(search, 'search'),
useKey(selection, 'selection'),
useKey(simple, 'simple'),
useKey(scrolling, 'scrolling'),
useKey(upward, 'upward'),
useValue(pointing, 'pointing'),
'dropdown',
className,
);
const portalRect = this.getPortalRect();
const portalStyle = {
position: 'absolute',
minHeight: 0,
height: 0, // différent par rapport à SUIR
overflow: 'visible', // différent par rapport à SUIR
padding: 0,
zIndex: 1000,
...portalRect,
...(selection && !upward && { borderTop: 0 }),
...(selection && upward && { borderBottom: 0 }),
};
return createPortal(
<div
ref={this.handlePortalRef}
className={portalClassName}
style={portalStyle}
data-testid="menu-portal"
>
{this.renderMenu()}
</div>,
menuPortalTarget,
);
};
render() {
const {
basic,
button,
className,
compact,
disabled,
error,
fluid,
floating,
icon,
inline,
item,
labeled,
loading,
multiple,
pointing,
menuPortalTarget,
search,
selection,
scrolling,
simple,
trigger,
} = this.props;
const { focus, open, upward } = this.state;
// Classes
const classes = classNames(
'ui',
useKey(open, 'active visible'),
useKey(disabled, 'disabled'),
useKey(error, 'error'),
useKey(loading, 'loading'),
useKey(basic, 'basic'),
useKey(button, 'button'),
useKey(compact, 'compact'),
useKey(fluid, 'fluid'),
useKey(floating, 'floating'),
useKey(inline, 'inline'),
// TODO: consider augmentation to render Dropdowns as Button/Menu,
// solves icon/link item issues
// https://github.com/Semantic-Org/Semantic-UI-React/issues/401#issuecomment-240487229
// TODO: the icon class is only required when a dropdown is a button
// useKey(icon, 'icon'),
useKey(labeled, 'labeled'),
useKey(item, 'item'),
useKey(multiple, 'multiple'),
useKey(search, 'search'),
useKey(selection, 'selection'),
useKey(simple, 'simple'),
useKey(scrolling, 'scrolling'),
useKey(upward, 'upward'),
useValue(pointing, 'pointing'),
'dropdown',
className,
);
const rest = getUnhandledProps(Combobox, this.props);
// TODO : Revoir le rest pour éviter que ça spread sur les éléments HTML toutes les props...
// Donc modifier les props pour ne plus avoir le customProptypes
const ElementType = getComponentType(this.props);
const ariaOptions = this.getDropdownAriaOptions(ElementType, this.props);
return (
<ElementType
{...rest}
{...ariaOptions}
className={classes}
onBlur={this.handleBlur}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onMouseDown={this.handleMouseDown}
onFocus={this.handleFocus}
onChange={this.handleChange}
tabIndex={this.computeTabIndex()}
ref={this.handleRef}
>
{this.renderLabels()}
{this.renderSearchInput()}
{this.renderSearchSizer()}
{trigger || this.renderText()}
{Icon.create(icon, {
overrideProps: this.handleIconOverrides,
autoGenerateKey: false,
})}
{menuPortalTarget ? this.renderPortalMenu() : this.renderMenu()}
{open && <EventStack name="keydown" on={this.closeOnEscape} />}
{open && <EventStack name="click" on={this.closeOnDocumentClick} />}
{open && <EventStack name="scroll" on={this.closeOnScroll} />}
{focus && <EventStack name="keydown" on={this.removeItemOnBackspace} />}
</ElementType>
);
}
}
Combobox.propTypes = {
...Dropdown.propTypes,
/**
* Whether or not the menu should close when anything but the menu is scrolled.
* Forced to true when menuPortalTarget is set.
*/
closeOnScroll: PropTypes.bool,
/**
* DOM Target where to mount the dropdown's menu. Usually document.body
* When set, closeOnScroll is forced to true.
* When unset, menu is created within the dropdown element.
*/
menuPortalTarget: PropTypes.instanceOf(Element),
};
Combobox.displayName = 'Combobox';
DropdownInner.autoControlledProps = ['open', 'searchQuery', 'selectedLabel', 'value', 'upward'];
if (process.env.NODE_ENV !== 'production') {
DropdownInner.propTypes = Combobox.propTypes;
}
Combobox.Divider = DropdownDivider;
Combobox.Header = DropdownHeader;
Combobox.Item = DropdownItem;
Combobox.Menu = DropdownMenu;
Combobox.SearchInput = DropdownSearchInput;
Combobox.Text = DropdownText;
export default Combobox;
Combobox.d.ts
import {
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownProps,
DropdownSearchInput,
} from 'semantic-ui-react';
type ForwardRefComponent<P, T> = React.ForwardRefExoticComponent<P & React.RefAttributes<T>>
export interface ComboboxProps extends DropdownProps {
/**
* Whether or not the menu should close when anything but the menu is scrolled.
* Forced to true when menuPortalTarget is set.
*/
closeOnScroll?: boolean
/**
* DOM Target where to mount the dropdown's menu. Usually document.body
* When set, closeOnScroll is forced to true.
* When unset, menu is created within the dropdown element.
*/
menuPortalTarget?: Element
}
declare const Combobox: ForwardRefComponent<ComboboxProps, HTMLDivElement> & {
Divider: typeof DropdownDivider
Header: typeof DropdownHeader
Item: typeof DropdownItem
Menu: typeof DropdownMenu
SearchInput: typeof DropdownSearchInput
};
export default Combobox;
Have a nice day all :)
Oh there's also a fucktons of files I copied in a folder from SUIR because not accessible from outside the package (everything in utils IIRC) :
Feature Request (more like need more infos to make PR)
Problem description
When a dropdown is used within a parent, which has a scroll, the dropdown's menu interaction is weird. Due to how its done, the menu is rendered within the dropdown DOM, implying the user has to scroll the parent's scroll to access lower options. In cases where parent can't have an overflow, it's close to not ideal, but very near to unusable... If the parent's height is not enough, no matter what, it won't be correctly displayed.
Proposed solution
Basically a menuPortalTarget props. When set, the menu is rendered in the given DOM element (like document.body).
I've looked in some others libraries and that's what they propose most often of the time.
So I've done it. The code is ready, I just finished it. So far I don't see many more problems that those I saw. Menu is adapting its position if not enough space below (upward feature)... Menu is resizing correctly to fit the dropdown no matter what... Etc... Added code is somewhat not so big in my opinion...
Quick teaser, left is default, right is with menuPortalTarget={document.body} props :
Also goes around the scrolling modal content limitation :
Before sharing and making the pull request, I'd like to polish it a bit and ask if anyone has any concerns about it, or something that could go wrong with it, or you never did it on purpose because it would break something on your side, or.... anything ! So I can check in my end and fix if needed.
One thing I had to do though, implement another props "closeOnScroll" (based on other closeOnXXX props), and force it to "true" (in a way) when menuPortalTarget is set, so the menu closes when a scroll event is detected (via EventStack for now, until you remove it in v3). Because I couldn't sync the menu's position with nested scrolls within the parents :(
Also I'm trying to do some unit tests if possible.
First time I'm trying to participate to someone's else repository tho, so be kind to me 🤣 Still, don't hesitate if I'm doing wrong, tell me.