Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dropdown menuPortalTarget props #4460

Open
Reavxn opened this issue Jan 11, 2024 · 6 comments
Open

Dropdown menuPortalTarget props #4460

Reavxn opened this issue Jan 11, 2024 · 6 comments

Comments

@Reavxn
Copy link

Reavxn commented Jan 11, 2024

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 :
image

Also goes around the scrolling modal content limitation :
image

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.

Copy link

welcome bot commented Jan 11, 2024

👋 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.

@Reavxn
Copy link
Author

Reavxn commented Feb 27, 2024

No comments ?

I'll make PR end of March then, I guess

@yuan9090
Copy link

I need this, any update ?

@felipenmoura
Copy link

felipenmoura commented Oct 30, 2024

so....any update on that!
Such an important fix, specially talking about semantic and accessibility.

Did you open a Pull Request, @Reavxn ?

@Reavxn
Copy link
Author

Reavxn commented Oct 31, 2024

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 :)

@Reavxn
Copy link
Author

Reavxn commented Oct 31, 2024

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) :

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants