UNPKG

@workday/canvas-kit-labs-react

Version:

Canvas Kit Labs is an incubator for new and experimental components. Since we have a rather rigorous process for getting components in at a production level, it can be valuable to make them available earlier while we continuously iterate on the API/functi

315 lines (314 loc) • 13.4 kB
import React, { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import { jsx, keyframes } from '@emotion/react'; import { useForkRef, styled, useIsRTL, useUniqueId, filterOutProps, } from '@workday/canvas-kit-react/common'; import { space, commonColors, borderRadius } from '@workday/canvas-kit-react/tokens'; import { Card } from '@workday/canvas-kit-react/card'; import { TertiaryButton } from '@workday/canvas-kit-react/button'; import { xSmallIcon } from '@workday/canvas-system-icons-web'; import flatten from 'lodash.flatten'; import { AutocompleteList } from './AutocompleteList'; import { Status } from './Status'; const Container = styled('div')({ display: 'inline-block', }, ({ grow }) => ({ width: grow ? '100%' : 'auto', })); const InputContainer = styled('div')({ display: `flex`, alignItems: `center`, position: 'relative', }); const fadeInKeyframes = keyframes({ '0%': { opacity: 0, }, '100%': { opacity: 1, }, }); const MenuContainer = styled(Card)({ position: 'absolute', zIndex: 1, left: 0, top: '100%', borderRadius: borderRadius.m, background: commonColors.background, border: `none`, marginTop: `-${borderRadius.m}`, width: '100%', minWidth: 0, animation: `${fadeInKeyframes} 200ms ease-out`, maxHeight: 200, overflow: 'hidden', }); const ResetButton = styled(TertiaryButton, { shouldForwardProp: filterOutProps(['shouldShow']), })({ position: 'absolute', margin: `auto ${space.xxxs}`, top: 0, bottom: 0, right: 0, padding: 0, zIndex: 2, transition: 'opacity 120ms', }, ({ shouldShow }) => ({ visibility: shouldShow ? 'visible' : 'hidden', opacity: shouldShow ? 1 : 0, })); export const listBoxIdPart = `listbox`; const optionIdPart = `option`; export const getOptionId = (baseId, index) => `${baseId}-${optionIdPart}-${index}`; export const getTextFromElement = (children) => { let text = ''; React.Children.map(children, child => { if (child == null || typeof child === 'boolean') { text += ''; } else if (typeof child === 'string' || typeof child === 'number') { text += child.toString(); } else if (Array.isArray(child)) { text += getTextFromElement(child); } else if ('props' in child) { text += getTextFromElement(child.props.children); } }); return text; }; const buildStatusString = (listCount) => { return `There ${listCount === 1 ? 'is' : 'are'} ${listCount} suggestion${listCount === 1 ? '' : 's'}.`; }; const isValidSingleChild = (child) => { return React.isValidElement(child) && React.Children.only(child); }; export const Combobox = ({ autocompleteItems, children, grow, initialValue, onChange, onFocus, onBlur, showClearButton, clearButtonVariant = undefined, clearButtonAriaLabel = `Reset Search Input`, labelId, getStatusText = buildStatusString, id, ...elemProps }) => { const [isOpened, setIsOpened] = useState(false); const [value, _setValue] = useState(''); // Don't call _setValue directly instead call setInputValue to make sure onChange fires correctly const [showingAutocomplete, setShowingAutocomplete] = useState(false); const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(null); const [interactiveAutocompleteItems, setInteractiveAutocompleteItems] = useState([]); const [announcementText, setAnnouncementText] = useState(''); // Create a ref to the soon-to-be-created TextInput clone for internal use. // Use useForkRef to combine it with the ref already assigned to the original // TextInput (if it exists) to create a single callback ref which can be // forwarded to the TextInput clone. const inputRef = useRef(null); // We need access to the original TextInput's ref _property_ (not prop) so we // can combine it with the internal inputRef using useForkRef. ref isn't // listed in the ReactElement interface, but it's there, so we cast children // to satisfy TS. const elementRef = useForkRef(children.ref, inputRef); const comboboxRef = useRef(null); const randomComponentId = useUniqueId(); const randomLabelId = useUniqueId(); const componentId = id || randomComponentId; const formLabelId = labelId || randomLabelId; const [showGroupText, setShowGroupText] = useState(false); // We're using LayoutEffect here because of an issue with the Synthetic event system and typing a key // after the listbox has been closed. Somehow the key is ignored unless we use `useLayoutEffect` useLayoutEffect(() => { const shouldShow = interactiveAutocompleteItems.length > 0 && isOpened; setShowingAutocomplete(shouldShow); if (shouldShow) { setAnnouncementText(getStatusText(interactiveAutocompleteItems.length)); } }, [getStatusText, interactiveAutocompleteItems, isOpened]); // Used to set the position of the reset button and the padding direction inside the input container const isRTL = useIsRTL(); const setInputValue = useCallback((newValue) => { _setValue(newValue); const inputDomElement = inputRef.current; // Changing value prop programmatically doesn't fire an Synthetic event or trigger native onChange. // We can not just update .value= in setState because React library overrides input value setter // but we can call the function directly on the input as context. // This will cause onChange events to fire no matter how value is updated. if (inputDomElement) { const nativeInputValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(inputDomElement), 'value'); if (nativeInputValue && nativeInputValue.set) { nativeInputValue.set.call(inputDomElement, newValue); } let event; if (typeof Event === 'function') { // modern browsers event = new Event('input', { bubbles: true }); } else { // IE 11 event = document.createEvent('Event'); event.initEvent('input', true, true); } inputDomElement.dispatchEvent(event); } }, [inputRef]); useEffect(() => { if (initialValue !== null && initialValue !== undefined) { setInputValue(initialValue); } }, [initialValue, setInputValue]); useEffect(() => { const getInteractiveAutocompleteItems = () => { if (autocompleteItems && autocompleteItems.length && autocompleteItems[0].hasOwnProperty('header')) { return flatten(autocompleteItems.map(group => group.items)); } return autocompleteItems || []; }; setInteractiveAutocompleteItems(getInteractiveAutocompleteItems()); }, [autocompleteItems]); const handleAutocompleteClick = (event, menuItemProps) => { if (menuItemProps.isDisabled || menuItemProps['aria-disabled']) { return; } setShowingAutocomplete(false); setIsOpened(false); setInputValue(getTextFromElement(menuItemProps.children)); if (menuItemProps.onClick) { menuItemProps.onClick(event); } }; const focusInput = () => { if (inputRef.current) { inputRef.current.focus(); } }; const handleClick = (event) => { if (!showingAutocomplete) { setShowingAutocomplete(true); } }; const handleFocus = (event) => { setIsOpened(true); if (onFocus) { onFocus(event); } }; const handleBlur = (event) => { if (comboboxRef.current) { const target = event.relatedTarget; if (target && comboboxRef.current.contains(target)) { return; } } setIsOpened(false); if (onBlur) { onBlur(event); } }; const resetSearchInput = () => { setInputValue(''); focusInput(); }; const getGroupIndex = (itemIndex) => { if (itemIndex != null && autocompleteItems && autocompleteItems.length && autocompleteItems[0].hasOwnProperty('header')) { let count = 0; return autocompleteItems.findIndex(groups => { count += groups.items.length; return count > itemIndex; }); } else { return -1; } }; const handleKeyboardShortcuts = (event) => { if (event.ctrlKey || event.altKey || event.metaKey || !interactiveAutocompleteItems.length) { return; } const autoCompleteItemCount = interactiveAutocompleteItems.length; const firstItem = 0; const lastItem = autoCompleteItemCount - 1; let nextIndex = null; setIsOpened(true); switch (event.key) { case 'ArrowUp': case 'Up': // IE/Edge specific value const upIndex = selectedAutocompleteIndex !== null ? selectedAutocompleteIndex - 1 : lastItem; nextIndex = upIndex < 0 ? lastItem : upIndex; event.stopPropagation(); event.preventDefault(); break; case 'ArrowDown': case 'Down': // IE/Edge specific value const downIndex = selectedAutocompleteIndex !== null ? selectedAutocompleteIndex + 1 : firstItem; nextIndex = downIndex >= autoCompleteItemCount ? firstItem : downIndex; event.stopPropagation(); event.preventDefault(); break; case 'Escape': case 'Esc': // IE/Edge specific value resetSearchInput(); break; case 'Enter': if (selectedAutocompleteIndex != null) { const item = interactiveAutocompleteItems[selectedAutocompleteIndex]; handleAutocompleteClick(event, item.props); if (item.props.isDisabled || item.props['aria-disabled']) { nextIndex = selectedAutocompleteIndex; } event.stopPropagation(); event.preventDefault(); } break; default: } const lastGroupIndex = getGroupIndex(selectedAutocompleteIndex); const nextGroupIndex = getGroupIndex(nextIndex); setShowGroupText(lastGroupIndex !== nextGroupIndex); setSelectedAutocompleteIndex(nextIndex); }; const handleSearchInputChange = (event) => { if (onChange) { onChange(event); } _setValue(event.target.value); // Calling set value directly only for on change event }; const renderChildren = (inputElement) => { let cssOverride = { ':focus': { zIndex: 2 } }; if (showClearButton) { const paddingDirection = isRTL ? 'paddingLeft' : 'paddingRight'; cssOverride = { ...cssOverride, [paddingDirection]: space.xl, }; } const newTextInputProps = { type: 'text', grow: grow, value: value, ref: elementRef, 'aria-autocomplete': 'list', 'aria-activedescendant': selectedAutocompleteIndex !== null ? getOptionId(componentId, selectedAutocompleteIndex) : undefined, onClick: handleClick, onChange: handleSearchInputChange, onKeyDown: handleKeyboardShortcuts, onFocus: handleFocus, onBlur: handleBlur, css: cssOverride, role: 'combobox', 'aria-owns': showingAutocomplete ? `${componentId}-${listBoxIdPart}` : undefined, 'aria-haspopup': true, 'aria-expanded': showingAutocomplete, }; const cloneElement = (element, props) => jsx(element.type, { ...element.props, ...props, }); return cloneElement(inputElement, { ...inputElement.props, ...newTextInputProps }); }; return (React.createElement(Container, { grow: grow, ...elemProps }, React.createElement(InputContainer, { ref: comboboxRef }, isValidSingleChild(children) && React.Children.map(children, renderChildren), showClearButton && (React.createElement(ResetButton, { shouldShow: !!value, "aria-label": clearButtonAriaLabel, icon: xSmallIcon, variant: clearButtonVariant, onClick: resetSearchInput, onBlur: handleBlur, size: "small", type: "button" })), showingAutocomplete && autocompleteItems && (React.createElement(MenuContainer, { padding: space.zero, depth: 3 }, React.createElement(Card.Body, null, React.createElement(AutocompleteList, { comboboxId: componentId, autocompleteItems: autocompleteItems, selectedIndex: selectedAutocompleteIndex, handleAutocompleteClick: handleAutocompleteClick, labelId: formLabelId, showGroupText: showGroupText }))))), React.createElement(Status, { announcementText: announcementText }))); };