@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
JavaScript
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 })));
};