function Combobox()

in src/combobox/combobox.tsx [30:333]


function Combobox<Option>(props: ComboboxProps<Option>) {
  const {
    autocomplete = true,
    clearable = false,
    disabled = false,
    error = false,
    onBlur,
    onChange,
    onFocus,
    onSubmit,
    listBoxLabel,
    mapOptionToNode,
    mapOptionToString,
    id,
    name,
    options,
    overrides = {},
    positive = false,
    inputRef: forwardInputRef,
    size = SIZE.default,
    value,
  } = props;

  const [selectionIndex, setSelectionIndex] = React.useState(-1);
  const [tempValue, setTempValue] = React.useState(value);
  const [isOpen, setIsOpen] = React.useState(false);

  const rootRef = React.useRef(null);
  const defaultInputRef = React.useRef(null);
  const inputRef = forwardInputRef || defaultInputRef;
  const listboxRef = React.useRef(null);
  const selectedOptionRef = React.useRef(null);

  const seed = useUIDSeed();
  const activeDescendantId = seed('descendant');
  const listboxId = seed('listbox');

  // Handles case where an application wants to update the value in the input element
  // from outside of the combobox component.
  React.useEffect(() => {
    setTempValue('');
  }, [value]);

  // Changing the 'selected' option temporarily updates the visible text string
  // in the input element until the user clicks an option or presses enter.
  React.useEffect(() => {
    // If no option selected, display the most recently user-edited string.
    if (selectionIndex === -1) {
      setTempValue(value);
    } else if (selectionIndex > options.length) {
      // Handles the case where option length is variable. After user clicks an
      // option and selection index is not in option bounds, reset it to default.
      setSelectionIndex(-1);
    } else {
      if (autocomplete) {
        let selectedOption = options[selectionIndex];
        if (selectedOption) {
          setTempValue(mapOptionToString(selectedOption));
        }
      }
    }
  }, [options, selectionIndex]);

  React.useEffect(() => {
    if (isOpen && selectedOptionRef.current && listboxRef.current) {
      scrollItemIntoView(
        selectedOptionRef.current,
        listboxRef.current,
        selectionIndex === 0,
        selectionIndex === options.length - 1
      );
    }
  }, [isOpen, selectedOptionRef.current, listboxRef.current]);

  const listboxWidth = React.useMemo(() => {
    if (rootRef.current) {
      // @ts-ignore
      return `${rootRef.current.clientWidth}px`;
    }
    return null;
  }, [rootRef.current]);

  function handleOpen() {
    if (!disabled) {
      setIsOpen(true);
    }
  }

  // @ts-ignore
  function handleKeyDown(event) {
    if (event.keyCode === ARROW_DOWN) {
      event.preventDefault();
      handleOpen();
      setSelectionIndex((prev) => {
        let next = prev + 1;
        if (next > options.length - 1) {
          next = -1;
        }
        return next;
      });
    }
    if (event.keyCode === ARROW_UP) {
      event.preventDefault();
      setSelectionIndex((prev) => {
        let next = prev - 1;
        if (next < -1) {
          next = options.length - 1;
        }
        return next;
      });
    }
    if (event.keyCode === ENTER) {
      let clickedOption = options[selectionIndex];
      if (clickedOption) {
        event.preventDefault();
        setIsOpen(false);
        setSelectionIndex(-1);
        onChange(mapOptionToString(clickedOption), clickedOption);
      } else {
        if (onSubmit) {
          onSubmit({ closeListbox: () => setIsOpen(false), value });
        }
      }
    }
    if (event.keyCode === ESCAPE) {
      // NOTE(chase): aria 1.2 spec outlines a pattern where when escape is
      // pressed, it closes the listbox and further presses will clear value.
      // Google search and some other examples I've seen do not implement this,
      // but something to consider when the 1.2 spec becomes more widespread.
      setIsOpen(false);
      setSelectionIndex(-1);
      setTempValue(value);
    }
  }

  // @ts-ignore
  function handleFocus(event) {
    if (!isOpen && options.length) {
      handleOpen();
    }
    if (onFocus) onFocus(event);
  }

  // @ts-ignore
  function handleBlur(event) {
    if (
      listboxRef.current &&
      event.relatedTarget &&
      // NOTE(chase): Contains method expects a Node type, but relatedTarget is
      // EventTarget which is a super type of Node. Passing an EventTarget seems
      // to work fine, assuming the flow type is too strict.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      // @ts-ignore
      listboxRef.current.contains(event.relatedTarget as any)
    ) {
      return;
    }
    setIsOpen(false);
    setSelectionIndex(-1);
    setTempValue(value);
    if (onBlur) onBlur(event);
  }

  function handleInputClick(event) {
    if (inputRef.current) {
      // @ts-ignore
      inputRef.current.focus();
    }

    if (!isOpen && options.length) {
      handleOpen();
    }
  }

  // @ts-ignore
  function handleInputChange(event) {
    handleOpen();
    setSelectionIndex(-1);
    onChange(event.target.value, null);
    setTempValue(event.target.value);
  }

  // @ts-ignore
  function handleOptionClick(index) {
    let clickedOption = options[index];
    if (clickedOption) {
      const stringified = mapOptionToString(clickedOption);
      setIsOpen(false);
      setSelectionIndex(index);
      onChange(stringified, clickedOption);
      setTempValue(stringified);

      if (inputRef.current) {
        // @ts-ignore
        inputRef.current.focus();
      }
    }
  }

  const [Root, rootProps] = getOverrides(overrides.Root, StyledRoot);
  const [InputContainer, inputContainerProps] = getOverrides(
    overrides.InputContainer,
    StyledInputContainer
  );
  const [ListBox, listBoxProps] = getOverrides(overrides.ListBox, StyledListBox);
  const [ListItem, listItemProps] = getOverrides(overrides.ListItem, StyledListItem);
  const [OverriddenInput, { overrides: inputOverrides = {}, ...restInputProps }] = getOverrides(
    overrides.Input,
    Input
  );
  const [OverriddenPopover, { overrides: popoverOverrides = {}, ...restPopoverProps }] =
    getOverrides(overrides.Popover, Popover);

  return (
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    <Root ref={rootRef as any} {...rootProps}>
      <OverriddenPopover
        // React-focus-lock used in Popover used to skip non-tabbable elements (`tabIndex=-1`) elements for focus, we had ListBox with tabIndex to disable focus on
        // the ListBox, but we can just disable autoFocus (as ListBox / ListItem should not be focusable) (and input is also not autoFocused).
        // Select Component does the same thing
        autoFocus={false}
        isOpen={isOpen}
        overrides={popoverOverrides}
        placement={PLACEMENT.bottomLeft}
        onClick={handleInputClick}
        content={
          <ListBox
            // TabIndex attribute exists to exclude option clicks from triggering onBlur event actions.
            tabIndex="-1"
            id={listboxId}
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            ref={listboxRef as any}
            role="listbox"
            aria-label={listBoxLabel}
            $width={listboxWidth}
            {...listBoxProps}
          >
            {options.map((option, index) => {
              const isSelected = selectionIndex === index;
              const ReplacementNode = mapOptionToNode;
              return (
                // List items are not focusable, therefore will never trigger a key event from it.
                // Secondly, key events are handled from the input element.
                // eslint-disable-next-line jsx-a11y/click-events-have-key-events
                <ListItem
                  aria-selected={isSelected}
                  id={isSelected ? activeDescendantId : null}
                  key={index}
                  onClick={() => handleOptionClick(index)}
                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
                  ref={isSelected ? (selectedOptionRef as any) : null}
                  role="option"
                  $isSelected={isSelected}
                  $size={size}
                  {...listItemProps}
                >
                  {ReplacementNode ? (
                    <ReplacementNode isSelected={isSelected} option={option} />
                  ) : (
                    mapOptionToString(option)
                  )}
                </ListItem>
              );
            })}
          </ListBox>
        }
        {...restPopoverProps}
      >
        <InputContainer
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-owns={listboxId}
          // a11y linter implements the older 1.0 spec, suppressing to use updated 1.1
          // https://github.com/A11yance/aria-query/issues/43
          // https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/442
          // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
          role="combobox"
          {...inputContainerProps}
        >
          <OverriddenInput
            inputRef={inputRef}
            aria-activedescendant={isOpen && selectionIndex >= 0 ? activeDescendantId : undefined}
            aria-autocomplete="list"
            clearable={clearable}
            disabled={disabled}
            error={error}
            name={name}
            id={id}
            onBlur={handleBlur}
            onChange={handleInputChange}
            onFocus={handleFocus}
            onKeyDown={handleKeyDown}
            overrides={inputOverrides}
            positive={positive}
            size={size}
            value={tempValue ? tempValue : value}
            {...(isOpen ? { 'aria-controls': listboxId } : {})}
            {...restInputProps}
          />
        </InputContainer>
      </OverriddenPopover>
    </Root>
  );
}