UNPKG

@sitecore/sc-contenthub-blok

Version:

A UI library for [Sitecore Content Hub](https://doc.sitecore.com/ch/).

416 lines 18.3 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { css, cx } from "@emotion/css"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { aiGradientColor1, aiGradientColor2, baseSpacing, blackAlpha, bodyBackgroundColor, borderRadiusMd, focusColor, fontFamilyBase, fontSizeBase, fontSizeSmall, inputBorderColor, inputBorderHoveColor, semanticTokens, shadowSm, textColorDefault, textColorDisabled, } from "../../Foundations"; import { isFunction } from "../../Foundations/utils"; import { BlokIcon } from "../BlokIcon/BlokIcon"; import { BlokIconButton } from "../BlokIconButton/BlokIconButton"; import { BlokTooltip } from "../BlokTooltip/BlokTooltip"; export const SEARCH_BOX_MAX_WIDTH = 500; export const SEARCH_BOX_MULTI_LINE_MAX_HEIGHT = 130; export const DEFAULT_SEARCH_BOX_HEIGHT = "36px"; const classes = { scrolled: css({ backgroundColor: "white", boxShadow: shadowSm, outline: "none !important", }), searchBoxActive: css({ backgroundColor: "white", boxShadow: `inset 0 0 0 1px ${blackAlpha[400]}`, outline: "none !important", "&:hover": { backgroundColor: "white", boxShadow: `inset 0 0 0 1px ${blackAlpha[500]}`, }, }), searchBoxInDropdown: css({ margin: "0 0.5rem 0.5rem 0.5rem", position: "sticky !important", top: 0, zIndex: 100, backgroundColor: "transparent", }), gutterBottom: css({ marginBottom: baseSpacing * 2, }), searchBoxOuterWrapper: css({ position: "relative", minHeight: "41px", maxWidth: "100%", width: "100%", }), withMaxWidth: css({ width: `${SEARCH_BOX_MAX_WIDTH}px`, maxWidth: "100%" }), searchBoxInnerWrapper: css({ display: "flex", flexDirection: "row", }), searchBoxWrapper: css({ borderRadius: `${borderRadiusMd} !important`, width: "100%", maxWidth: "100%", zIndex: 10, border: `1px solid ${inputBorderColor}`, backgroundColor: "white", "&:hover": { backgroundColor: "white", border: `1px solid ${inputBorderHoveColor}`, color: textColorDefault, }, "&:focus, &:focus-visible, &:focus-within": { backgroundColor: "white !important", border: `1px solid ${focusColor} !important`, color: textColorDefault, outline: 0, boxShadow: "0px 1px 3px 0px rgba(0, 0, 0, 0.10), 0px 1px 2px 0px rgba(0, 0, 0, 0.06)", }, }), aiSearchBoxWrapper: css({ "&:hover": { background: `linear-gradient(white, white) padding-box, linear-gradient(to top right, ${aiGradientColor1[500]}, ${aiGradientColor2[500]}) border-box`, border: `1px solid transparent !important`, }, "&:focus, &:focus-visible, &:focus-within": { background: `linear-gradient(white, white) padding-box, linear-gradient(to top right, ${aiGradientColor1[600]}, ${aiGradientColor2[600]}) border-box`, border: `1px solid transparent !important`, }, }), searchBoxFixedHeight: css({ height: DEFAULT_SEARCH_BOX_HEIGHT, }), searchBox: css({ padding: "9.5px 0", margin: "0 6px", border: "none", outline: "none", lineHeight: 1.3, width: "100%", minHeight: DEFAULT_SEARCH_BOX_HEIGHT, resize: "none", overflow: "hidden", color: textColorDefault, fontSize: fontSizeBase, fontFamily: fontFamilyBase, boxShadow: "none !important", "&::placeholder": { color: textColorDisabled, }, }), searchBoxSingleLine: css({ whiteSpace: "nowrap", textOverflow: "ellipsis", overflow: "hidden", }), absolute: css({ position: "absolute" }), insideDropdown: css({ "&:focus, &:focus-visible": { "& ~[class*=textHider]": { display: "block !important", }, }, }), disabled: css({ cursor: "not-allowed", pointerEvents: "none", "*": { cursor: "not-allowed", pointerEvents: "none", }, }), button: css({ margin: "2px 3.5px", }), aiIcon: css({ margin: "6.5px 8px 0 8px", width: 24, height: 24, }), searchIconImage: css({ width: "22px", height: "22px" }), counter: css({ fontSize: fontSizeSmall, color: semanticTokens.subtleText.default, whiteSpace: "nowrap", marginTop: "8px", }), hidden: css({ visibility: "hidden", pointerEvents: "none" }), }; const stickySearchBoxWrapper = (stickyTopPosition, stickySearchBoxBackground) => { return css({ position: "sticky", top: stickyTopPosition, marginTop: baseSpacing, marginBottom: baseSpacing * 2, zIndex: 2, borderRadius: `${borderRadiusMd}px !important`, backgroundColor: stickySearchBoxBackground, maxWidth: "100%", "&:before": { content: "''", display: "block", background: stickySearchBoxBackground, zIndex: -1, opacity: 1, position: "absolute", top: "0", bottom: "0", left: "-16px", right: "-16px", }, "&:after": { content: "''", display: "block", height: "24px", background: `linear-gradient(to bottom, ${stickySearchBoxBackground} 0%,rgba(255,255,255,0) 100%)`, opacity: 1, position: "absolute", bottom: "-24px", left: "-16px", right: "-16px", zIndex: -1, }, }); }; // Helper function to adjust the height of the textarea. const adjustTextareaHeight = (textareaElement, containerElement, shouldApplyHeightLimit) => { textareaElement.style.height = DEFAULT_SEARCH_BOX_HEIGHT; if (shouldApplyHeightLimit) { containerElement.style.width = `${containerElement.clientWidth}px`; } else { containerElement.style.removeProperty("width"); } const newHeight = shouldApplyHeightLimit ? Math.min(textareaElement.scrollHeight - 1, SEARCH_BOX_MULTI_LINE_MAX_HEIGHT) : textareaElement.scrollHeight - 1; textareaElement.style.height = `${newHeight}px`; textareaElement.style.overflow = newHeight === SEARCH_BOX_MULTI_LINE_MAX_HEIGHT ? "auto" : "hidden"; }; // Helper function to reset the textarea styles. const resetTextareaStyles = (textarea, wrapper) => { textarea.style.height = DEFAULT_SEARCH_BOX_HEIGHT; textarea.style.overflow = "hidden"; wrapper.style.removeProperty("width"); }; export const truncateString = (str, maxChars, maxWords) => { let result = str; const words = result.trim().split(/\s+/); if (maxWords && words.length > maxWords) { result = `${words.slice(0, maxWords).join(" ")} `; } if (maxChars && result.length > maxChars) { result = result.slice(0, maxChars); } return result; }; export const BlokSearchBox = ({ ref, onSearch, onClearSearch, onEnterPress, value: externalValue, className, placeholder, cancelOnEscape = true, isSticky = false, stickyTopPosition = "0", isInsideDropdown = false, isDisabled = false, hasGutterBottom = true, stickySearchBoxBackground = bodyBackgroundColor, shouldShowScrolledStatus = false, clearButtonClassName, allowKeyPressPropagation = false, handleKeyPress, onPaste, maxNumberOfCharacters, maxNumberOfWords, clearMessage = "Clear", searchMessage = "Search", allowMultipleLines = false, hasLimitedWidth = true, expandOverContent = true, slots, searchIcon = "m-icon-magnify", searchIconImage, applyAiStyles = false, handlePastedText, showCounter = false, ignoreLimitationsRegex, transformTypedText, }) => { const [value, setValue] = useState(externalValue); const [isActive, setIsActive] = useState(false); const searchBoxRef = useRef(null); const searchBoxWrapperRef = useRef(null); const [allowMultiLine, setAllowMultiLine] = useState(allowMultipleLines && !expandOverContent); const [isFocused, setIsFocused] = useState(false); // Call search callback when typing into the searchBox. const onChange = useCallback((e) => { const searchText = typeof transformTypedText === "function" ? transformTypedText(e.target.value) : e.target.value; const shouldIgnoreLimitations = !ignoreLimitationsRegex ? false : !!searchText.match(ignoreLimitationsRegex); const searchValue = shouldIgnoreLimitations ? searchText : truncateString(searchText, maxNumberOfCharacters, maxNumberOfWords); setValue(searchValue); onSearch?.(searchValue); }, [ ignoreLimitationsRegex, maxNumberOfCharacters, maxNumberOfWords, onSearch, transformTypedText, ]); const processAndInsertPastedText = useCallback((pastedText, currentValue, cursorPos, selectionEnd) => { let textToInsert = pastedText; if (typeof transformTypedText === "function") { textToInsert = transformTypedText(pastedText); } if (handlePastedText && typeof handlePastedText === "function") { textToInsert = handlePastedText(pastedText, value); } const textBeforeCursor = currentValue.substring(0, cursorPos); const textAfterCursor = currentValue.substring(selectionEnd); const fullText = textBeforeCursor + textToInsert + textAfterCursor; const shouldIgnoreLimitations = !ignoreLimitationsRegex ? false : !!fullText.match(ignoreLimitationsRegex); return shouldIgnoreLimitations ? fullText : truncateString(fullText, maxNumberOfCharacters, maxNumberOfWords); }, [ handlePastedText, ignoreLimitationsRegex, maxNumberOfCharacters, maxNumberOfWords, transformTypedText, value, ]); const handlePaste = useCallback((e) => { e.preventDefault(); // Get pasted text from clipboard const clipboardData = e.clipboardData; const pastedText = clipboardData.getData("text"); // Get current input value and cursor position const currentValue = e.currentTarget.value; const cursorPos = e.currentTarget.selectionStart || 0; const selectionEnd = e.currentTarget.selectionEnd || cursorPos; const newValue = processAndInsertPastedText(pastedText, currentValue, cursorPos, selectionEnd); // Update the input value setValue(newValue); // Call onSearch with the new value onSearch?.(newValue); // If there's a custom onPaste handler, call it if (onPaste) { onPaste(e); } }, [onPaste, onSearch, processAndInsertPastedText]); // Clear search value and call search callback. const clearSearch = useCallback(() => { setValue(""); setIsActive(false); onClearSearch?.(); }, [onClearSearch]); const onKeyPress = useCallback((e) => { if (!allowKeyPressPropagation) { e.stopPropagation(); } // Run enter press callback when enter key is pressed if (e.key === "Enter") { // Allow newline with Shift+Enter if multiple lines are enabled if (e.shiftKey && allowMultipleLines) { return; } e.preventDefault(); if (isFunction(onEnterPress)) { onEnterPress(); searchBoxRef?.current?.blur(); setIsActive(true); } } // Clear the search when the escape key is pressed if (e.key === "Escape" && cancelOnEscape) { clearSearch(); } if (e.key === "ArrowDown" && isInsideDropdown) { // Focus the next item in the dropdown e.currentTarget.parentElement?.parentElement ?.nextElementSibling?.focus(); } // Pass the event down to the handleKeyPress method (suggestions). if (isFunction(handleKeyPress)) { handleKeyPress(e); } }, [ allowKeyPressPropagation, allowMultipleLines, cancelOnEscape, clearSearch, handleKeyPress, isInsideDropdown, onEnterPress, ]); // When the searchBox is used inside the select menu there are some actions to do when pressing certain keys const checkKeyDown = useCallback((e) => { e.stopPropagation(); if (e.key === "ArrowDown") { // Focus the next item in the dropdown e.currentTarget.nextElementSibling?.focus(); } if (e.key === "Enter") { // Focus the searchBox ref?.current?.focus(); } }, [ref]); const searchButton = useMemo(() => (_jsx(BlokTooltip, { title: searchMessage, children: _jsx(BlokIconButton, { id: "searchbox-search", onClick: () => { if (isFunction(onEnterPress)) { onEnterPress(); } }, size: "small", className: cx(classes.button), colorScheme: applyAiStyles && value ? "ai" : "neutral", children: searchIconImage ? (_jsx("img", { alt: "search-icon", src: searchIconImage, className: classes.searchIconImage })) : (_jsx(BlokIcon, { icon: searchIcon })) }, "search") })), [ searchMessage, applyAiStyles, value, searchIconImage, searchIcon, onEnterPress, ]); const handleFocus = useCallback(() => { setIsFocused(true); if (!isInsideDropdown && allowMultipleLines) { setAllowMultiLine(true); } }, [allowMultipleLines, isInsideDropdown]); const handleBlur = useCallback(() => { setIsFocused(false); if (expandOverContent) { setAllowMultiLine(false); } }, [expandOverContent]); const searchInput = useMemo(() => (_jsx("textarea", { placeholder: placeholder || searchMessage, onChange: onChange, onBlur: handleBlur, onFocus: handleFocus, className: cx(classes.searchBox, expandOverContent ? classes.searchBoxFixedHeight : null, isActive ? classes.searchBoxActive : null, shouldShowScrolledStatus ? classes.scrolled : null, !allowMultiLine ? classes.searchBoxSingleLine : null, isInsideDropdown ? classes.insideDropdown : null, "custom-scrollbars"), "data-testid": "searchbox", ref: ref ?? searchBoxRef, value: value, tabIndex: 0, onKeyDown: onKeyPress, autoComplete: "off", disabled: isDisabled, onPaste: handlePaste, role: "search", spellCheck: true })), [ allowMultiLine, handleBlur, handleFocus, handlePaste, isActive, isDisabled, isInsideDropdown, onChange, onKeyPress, expandOverContent, placeholder, ref, searchMessage, shouldShowScrolledStatus, value, ]); const clearButton = useMemo(() => (_jsx(BlokTooltip, { title: clearMessage, children: _jsx(BlokIconButton, { id: "clear-select-search", onClick: clearSearch, className: cx(classes.button, clearButtonClassName, value === "" ? classes.hidden : undefined), size: "small", children: _jsx(BlokIcon, { icon: "m-icon-close" }) }, "clear") })), [clearButtonClassName, clearSearch, clearMessage, value]); useEffect(() => { if (externalValue === "") { setValue(""); setIsActive(false); } else { setValue(externalValue); } }, [externalValue]); // Adjust the height of the textarea based on its content. useEffect(() => { if (searchBoxRef.current && searchBoxWrapperRef.current) { if (allowMultiLine) { adjustTextareaHeight(searchBoxRef.current, searchBoxWrapperRef.current, expandOverContent); } else { resetTextareaStyles(searchBoxRef.current, searchBoxWrapperRef.current); } } }, [value, allowMultiLine, expandOverContent]); const counter = useMemo(() => { if (showCounter && maxNumberOfCharacters && isFocused) { return (_jsx("div", { className: cx(classes.counter), children: `${value?.length ?? 0} / ${maxNumberOfCharacters}` })); } return null; }, [isFocused, maxNumberOfCharacters, showCounter, value?.length]); const searchBoxContent = useMemo(() => (_jsxs("div", { className: classes.searchBoxInnerWrapper, children: [applyAiStyles && (_jsx(BlokIcon, { icon: "star-four-points", classes: [classes.aiIcon], iconColor: "neutral", iconColorShade: 600 })), !applyAiStyles && searchButton, slots?.slot1, searchInput, counter, clearButton, applyAiStyles && searchButton] })), [ applyAiStyles, clearButton, counter, searchButton, searchInput, slots?.slot1, ]); return isInsideDropdown ? (_jsx("li", { onKeyDown: checkKeyDown, onMouseUp: (e) => e.stopPropagation(), onClick: (e) => e.stopPropagation(), role: "menuitem", tabIndex: 0, className: cx(classes.searchBoxInDropdown), children: _jsx("div", { role: "search", ref: searchBoxWrapperRef, className: cx(classes.searchBoxWrapper, classes.withMaxWidth, hasGutterBottom ? classes.gutterBottom : null), children: searchBoxContent }) })) : (_jsx("div", { className: cx(classes.searchBoxOuterWrapper, isSticky ? stickySearchBoxWrapper(stickyTopPosition, stickySearchBoxBackground) : null, hasLimitedWidth ? classes.withMaxWidth : null, hasGutterBottom ? classes.gutterBottom : null, className), ref: searchBoxWrapperRef, children: _jsxs("div", { className: cx(classes.searchBoxWrapper, applyAiStyles ? classes.aiSearchBoxWrapper : null, allowMultiLine && !isSticky && expandOverContent ? classes.absolute : null, isDisabled ? classes.disabled : null, isActive ? "searchBoxActive" : undefined), role: "search", children: [searchBoxContent, _jsx("div", { "data-testid": "slot2", children: slots?.slot2 })] }) })); }; //# sourceMappingURL=BlokSearchBox.js.map