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

347 lines (346 loc) • 15.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Combobox = exports.getTextFromElement = exports.getOptionId = exports.listBoxIdPart = void 0; const react_1 = __importStar(require("react")); const react_2 = require("@emotion/react"); const common_1 = require("@workday/canvas-kit-react/common"); const tokens_1 = require("@workday/canvas-kit-react/tokens"); const card_1 = require("@workday/canvas-kit-react/card"); const button_1 = require("@workday/canvas-kit-react/button"); const canvas_system_icons_web_1 = require("@workday/canvas-system-icons-web"); const lodash_flatten_1 = __importDefault(require("lodash.flatten")); const AutocompleteList_1 = require("./AutocompleteList"); const Status_1 = require("./Status"); const Container = (0, common_1.styled)('div')({ display: 'inline-block', }, ({ grow }) => ({ width: grow ? '100%' : 'auto', })); const InputContainer = (0, common_1.styled)('div')({ display: `flex`, alignItems: `center`, position: 'relative', }); const fadeInKeyframes = (0, react_2.keyframes)({ '0%': { opacity: 0, }, '100%': { opacity: 1, }, }); const MenuContainer = (0, common_1.styled)(card_1.Card)({ position: 'absolute', zIndex: 1, left: 0, top: '100%', borderRadius: tokens_1.borderRadius.m, background: tokens_1.commonColors.background, border: `none`, marginTop: `-${tokens_1.borderRadius.m}`, width: '100%', minWidth: 0, animation: `${fadeInKeyframes} 200ms ease-out`, maxHeight: 200, overflow: 'hidden', }); const ResetButton = (0, common_1.styled)(button_1.TertiaryButton, { shouldForwardProp: (0, common_1.filterOutProps)(['shouldShow']), })({ position: 'absolute', margin: `auto ${tokens_1.space.xxxs}`, top: 0, bottom: 0, right: 0, padding: 0, zIndex: 2, transition: 'opacity 120ms', }, ({ shouldShow }) => ({ visibility: shouldShow ? 'visible' : 'hidden', opacity: shouldShow ? 1 : 0, })); exports.listBoxIdPart = `listbox`; const optionIdPart = `option`; const getOptionId = (baseId, index) => `${baseId}-${optionIdPart}-${index}`; exports.getOptionId = getOptionId; const getTextFromElement = (children) => { let text = ''; react_1.default.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 += (0, exports.getTextFromElement)(child); } else if ('props' in child) { text += (0, exports.getTextFromElement)(child.props.children); } }); return text; }; exports.getTextFromElement = getTextFromElement; const buildStatusString = (listCount) => { return `There ${listCount === 1 ? 'is' : 'are'} ${listCount} suggestion${listCount === 1 ? '' : 's'}.`; }; const isValidSingleChild = (child) => { return react_1.default.isValidElement(child) && react_1.default.Children.only(child); }; const Combobox = ({ autocompleteItems, children, grow, initialValue, onChange, onFocus, onBlur, showClearButton, clearButtonVariant = undefined, clearButtonAriaLabel = `Reset Search Input`, labelId, getStatusText = buildStatusString, id, ...elemProps }) => { const [isOpened, setIsOpened] = (0, react_1.useState)(false); const [value, _setValue] = (0, react_1.useState)(''); // Don't call _setValue directly instead call setInputValue to make sure onChange fires correctly const [showingAutocomplete, setShowingAutocomplete] = (0, react_1.useState)(false); const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = (0, react_1.useState)(null); const [interactiveAutocompleteItems, setInteractiveAutocompleteItems] = (0, react_1.useState)([]); const [announcementText, setAnnouncementText] = (0, react_1.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 = (0, react_1.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 = (0, common_1.useForkRef)(children.ref, inputRef); const comboboxRef = (0, react_1.useRef)(null); const randomComponentId = (0, common_1.useUniqueId)(); const randomLabelId = (0, common_1.useUniqueId)(); const componentId = id || randomComponentId; const formLabelId = labelId || randomLabelId; const [showGroupText, setShowGroupText] = (0, react_1.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` (0, react_1.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 = (0, common_1.useIsRTL)(); const setInputValue = (0, react_1.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]); (0, react_1.useEffect)(() => { if (initialValue !== null && initialValue !== undefined) { setInputValue(initialValue); } }, [initialValue, setInputValue]); (0, react_1.useEffect)(() => { const getInteractiveAutocompleteItems = () => { if (autocompleteItems && autocompleteItems.length && autocompleteItems[0].hasOwnProperty('header')) { return (0, lodash_flatten_1.default)(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((0, exports.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]: tokens_1.space.xl, }; } const newTextInputProps = { type: 'text', grow: grow, value: value, ref: elementRef, 'aria-autocomplete': 'list', 'aria-activedescendant': selectedAutocompleteIndex !== null ? (0, exports.getOptionId)(componentId, selectedAutocompleteIndex) : undefined, onClick: handleClick, onChange: handleSearchInputChange, onKeyDown: handleKeyboardShortcuts, onFocus: handleFocus, onBlur: handleBlur, css: cssOverride, role: 'combobox', 'aria-owns': showingAutocomplete ? `${componentId}-${exports.listBoxIdPart}` : undefined, 'aria-haspopup': true, 'aria-expanded': showingAutocomplete, }; const cloneElement = (element, props) => (0, react_2.jsx)(element.type, { ...element.props, ...props, }); return cloneElement(inputElement, { ...inputElement.props, ...newTextInputProps }); }; return (react_1.default.createElement(Container, { grow: grow, ...elemProps }, react_1.default.createElement(InputContainer, { ref: comboboxRef }, isValidSingleChild(children) && react_1.default.Children.map(children, renderChildren), showClearButton && (react_1.default.createElement(ResetButton, { shouldShow: !!value, "aria-label": clearButtonAriaLabel, icon: canvas_system_icons_web_1.xSmallIcon, variant: clearButtonVariant, onClick: resetSearchInput, onBlur: handleBlur, size: "small", type: "button" })), showingAutocomplete && autocompleteItems && (react_1.default.createElement(MenuContainer, { padding: tokens_1.space.zero, depth: 3 }, react_1.default.createElement(card_1.Card.Body, null, react_1.default.createElement(AutocompleteList_1.AutocompleteList, { comboboxId: componentId, autocompleteItems: autocompleteItems, selectedIndex: selectedAutocompleteIndex, handleAutocompleteClick: handleAutocompleteClick, labelId: formLabelId, showGroupText: showGroupText }))))), react_1.default.createElement(Status_1.Status, { announcementText: announcementText }))); }; exports.Combobox = Combobox;