import React, { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react' import { flushSync } from 'react-dom' import { cleanValue, getHonestValue, isFn, alphanumericKeys, getEmailData } from './utils' import { Events, OnSelectData, Elements, Maybe, EmailProps } from './types' import { Input } from './components/ui/input' /** * Controlled email input component. * * Read the documentation at: https://github.com/smastrom/react-email-autocomplete. */ export const Email = forwardRef( ( { /* Core - Required */ onChange: setEmail, value: _email, baseList: _baseList, /* Core - Optional */ refineList = [], maxResults: _maxResults = 6, minChars: _minChars = 2, className, classNames, onSelect = () => {}, children, activeDataAttr, /* User events */ onFocus: userOnFocus, onBlur: userOnBlur, onInput: userOnInput, onKeyDown: userOnKeyDown = () => {}, /* ARIA */ dropdownAriaLabel = 'Suggestions', /* HTML */ ...inputAttrs }: EmailProps, externalRef ) => { /* User settings */ const isRefineMode = refineList?.length > 0 const maxResults = getHonestValue(_maxResults, 8, 6) const minChars = getHonestValue(_minChars, 8, 2) const baseList = _baseList.slice(0, maxResults) /* Refs */ const isTouched = useRef(false) const listId = useId() const wrapperRef = useRef>(null) const inputRef = useRef>(null) const dropdownRef = useRef>(null) const liRefs = useRef[] | []>([]) /* State */ const [inputType, setInputType] = useState<'text' | 'email'>('email') const [suggestions, setSuggestions] = useState(baseList) /** * 'focusedIndex' is used to trigger suggestions focus * and can only be set by keyboard events. * * 'hoveredIndex' is used to keep track of both focused/hovered * suggestion in order to set 'data-active-email="true"'. * * When focusedIndex is set, hoveredIndex is set to the same value. * When hoveredIndex is set by pointer events, focusedIndex is set to -1. * * Keyboard handlers are able to set the new focus by 'resuming' from * any eventual 'hoveredIndex' triggered by pointer events and viceversa. */ const [activeSuggestion, _setActiveSuggestion] = useState({ focusedIndex: -1, hoveredIndex: -1, }) function setActiveSuggestion(focusedIndex: number, hoveredIndex: number) { _setActiveSuggestion({ focusedIndex, hoveredIndex }) } /** * Resumes keyboard focus from an eventual hovered suggestion. */ function setActiveSuggestionFromHover({ isDecrement }: { isDecrement: boolean }) { const i = isDecrement ? -1 : 1 _setActiveSuggestion((prevState) => ({ hoveredIndex: prevState.hoveredIndex + i, focusedIndex: prevState.hoveredIndex + i, })) } /* Reactive helpers */ const email = typeof _email !== 'string' ? '' : cleanValue(_email) const [username] = email.split('@') /** * 'isOpen' conditionally renders the dropdown, we simply let the * results length decide if it should be mounted or not. */ const isOpen = isTouched.current && suggestions.length > 0 && username.length >= minChars /* Callbacks */ const clearResults = useCallback(() => { setSuggestions([]) setActiveSuggestion(-1, -1) }, []) /* Effects */ useEffect(() => { if (activeSuggestion.focusedIndex >= 0) { liRefs?.current[activeSuggestion.focusedIndex]?.focus() } }, [activeSuggestion.focusedIndex]) useEffect(() => { function handleOutsideClick(e: MouseEvent) { if (isOpen && !wrapperRef.current?.contains(e.target as Node)) { clearResults() } } if (!isOpen) setActiveSuggestion(-1, -1) document.addEventListener('mousedown', handleOutsideClick) window.addEventListener('blur', clearResults) return () => { document.removeEventListener('mousedown', handleOutsideClick) window.removeEventListener('blur', clearResults) } }, [isOpen, clearResults]) /* Event utils */ function handleCursorFocus() { if (inputRef.current) { flushSync(() => { setInputType('text') }) inputRef.current.setSelectionRange(email.length, email.length) flushSync(() => { setInputType('email') }) inputRef.current.focus() } } /* Value handlers */ function handleEmailChange(e: React.ChangeEvent) { /** * On first mount/change, suggestions state is init with baseList. * As soon as the username is longer than minChars, the dropdown * is immediately mounted as such domains should be displayed * without any further condition by both modes. * * We also want the dropdown to be mounted exclusively * when users type so we set this ref to true. */ isTouched.current = true const cleanEmail = cleanValue(e.target.value) const { hasUsername, hasAt, hasDomain, domain: _domain } = getEmailData(cleanEmail, minChars) if (hasUsername) { if (!isRefineMode) { hasAt ? clearResults() : setSuggestions(baseList) } else { if (hasDomain) { const _suggestions = refineList .filter((_suggestion) => _suggestion.startsWith(_domain)) .slice(0, maxResults) if (_suggestions.length > 0) { /** * We also want to close the dropdown if users type exactly * the same domain of the first/only suggestion. * * This will also unmount the dropdown after selecting a suggestion. */ _suggestions[0] === _domain ? clearResults() : setSuggestions(_suggestions) } else { clearResults() } } else { setSuggestions(baseList) } } } setEmail(cleanEmail) } function dispatchSelect( value: OnSelectData['value'], keyboard: OnSelectData['keyboard'], position: OnSelectData['position'] ) { onSelect({ value, keyboard, position }) } function handleSelect( e: React.MouseEvent | React.KeyboardEvent, activeSuggestion: number, { isKeyboard, isInput }: { isKeyboard: boolean; isInput: boolean } = { isKeyboard: false, isInput: false } ) { e.preventDefault() e.stopPropagation() flushSync(() => { let selectedEmail = '' if (isInput) { selectedEmail = liRefs.current[activeSuggestion]?.textContent as string } else { selectedEmail = e.currentTarget.textContent as string } selectedEmail = cleanValue(selectedEmail) setEmail(selectedEmail) dispatchSelect(selectedEmail, isKeyboard, activeSuggestion + 1) clearResults() }) inputRef.current?.focus() } /* Keyboard events */ function handleInputKeyDown(e: React.KeyboardEvent) { switch (e.code) { case 'Tab': case 'Escape': e.stopPropagation() return clearResults() /** * The conditions inside the following clauses allow the user to 'resume' and set the new focus * from an eventual hovered item. * * Since the input is focused when first hovering suggestions, * we must handle here the beginning of 'resume-from-hovered' logic here. */ case 'ArrowUp': e.preventDefault() e.stopPropagation() if (activeSuggestion.hoveredIndex >= 0) setActiveSuggestionFromHover({ isDecrement: true }) break case 'ArrowDown': e.preventDefault() e.stopPropagation() if (activeSuggestion.hoveredIndex >= 0) setActiveSuggestionFromHover({ isDecrement: false }) if (activeSuggestion.hoveredIndex < 0) setActiveSuggestion(0, 0) break case 'Enter': e.preventDefault() e.stopPropagation() if (activeSuggestion.hoveredIndex >= 0) handleSelect(e, activeSuggestion.hoveredIndex, { isKeyboard: true, isInput: true, }) break } } function handleListKeyDown(e: React.KeyboardEvent) { if (alphanumericKeys.test(e.key)) { e.stopPropagation() return inputRef?.current?.focus() } switch (e.code) { case 'Tab': e.stopPropagation() return clearResults() case 'Escape': e.preventDefault() e.stopPropagation() clearResults() return handleCursorFocus() case 'Enter': case 'Space': e.preventDefault() e.stopPropagation() return handleSelect(e, activeSuggestion.focusedIndex, { isKeyboard: true, isInput: false, }) case 'Backspace': case 'ArrowLeft': case 'ArrowRight': e.stopPropagation() return inputRef?.current?.focus() /** * Same for the input handler, the conditions inside the * clauses allow users to set the new focus from * an eventual hovered item. * * Since we know that hoveredIndex is always set along with * focusedIndex, we are sure that the condition executes * also when no item was hovered using the pointer. */ case 'ArrowUp': e.preventDefault() e.stopPropagation() setActiveSuggestionFromHover({ isDecrement: true }) if (activeSuggestion.hoveredIndex === 0) inputRef?.current?.focus() break case 'ArrowDown': e.preventDefault() e.stopPropagation() if (activeSuggestion.hoveredIndex < suggestions.length - 1) setActiveSuggestionFromHover({ isDecrement: false }) if (activeSuggestion.hoveredIndex === suggestions.length - 1) setActiveSuggestion(0, 0) break } } /* User Events */ /** * User's focus/blur should be triggered only when the related * target is not a suggestion, this will ensure proper behavior * with external input validation. */ function handleExternal( e: React.FocusEvent, eventHandler: React.FocusEventHandler ) { const isInternal = liRefs.current.some((li) => li === e.relatedTarget) if (!isInternal || e.relatedTarget == null) eventHandler(e) } function getEvents(): Events { return { onKeyDown(e) { handleInputKeyDown(e) userOnKeyDown(e) }, ...(isFn(userOnInput) ? { onInput: userOnInput } : {}), ...(isFn(userOnBlur) ? { onBlur: (e) => handleExternal(e, userOnBlur!) } : {}), ...(isFn(userOnFocus) ? { onFocus: (e) => handleExternal(e, userOnFocus!) } : {}), } } /* Props */ function mergeRefs(inputElement: HTMLInputElement) { inputRef.current = inputElement if (externalRef) { // eslint-disable-next-line no-extra-semi ;(externalRef as React.MutableRefObject>).current = inputElement } } function getWrapperClass() { return { className: `${className || ''} ${classNames?.wrapper || ''}`.trim() || undefined } } function getClasses(elementName: Elements) { if (classNames?.[elementName]) { return { className: classNames[elementName] } } return {} } return (
mergeRefs(input as HTMLInputElement)} onChange={(e) => handleEmailChange(e)} aria-expanded={isOpen} value={email} type={inputType} role={suggestions.length > 0 ? 'combobox' : ''} autoComplete="email" autoCapitalize="none" autoCorrect="off" aria-autocomplete="list" {...(isOpen ? { 'aria-controls': listId } : {})} {...getClasses(Elements.Input)} {...getEvents()} /> {isOpen && (
    {suggestions.map((domain, i) => (
  • (liRefs.current[i] = li)} onPointerMove={() => setActiveSuggestion(-1, i)} onPointerLeave={() => setActiveSuggestion(-1, -1)} onClick={(e) => handleSelect(e, i, { isKeyboard: false, isInput: false })} onKeyDown={handleListKeyDown} key={domain} aria-posinset={i + 1} aria-setsize={suggestions.length} tabIndex={-1} // This must always be false as no option can be already selected aria-selected="false" {...getClasses(Elements.Suggestion)} {...{ [activeDataAttr ? activeDataAttr : 'data-active-email']: i === activeSuggestion.hoveredIndex, }} > {username} @{domain}
  • ))}
)} {children}
) } ) Email.displayName = 'Email'