UNPKG

braid-design-system

Version:
632 lines (631 loc) • 22.1 kB
import { jsxs, Fragment, jsx } from "react/jsx-runtime"; import matchHighlights from "autosuggest-highlight/match/index.js"; import parseHighlights from "autosuggest-highlight/parse/index.js"; import dedent from "dedent"; import { forwardRef, useRef, useCallback, useReducer, useEffect, Fragment as Fragment$1 } from "react"; import { RemoveScroll } from "react-remove-scroll"; import { textStyles } from "../../css/typography.mjs"; import { useFallbackId } from "../../hooks/useFallbackId.mjs"; import { autosuggest } from "../../translations/en.mjs"; import { Box } from "../Box/Box.mjs"; import { ButtonIcon } from "../ButtonIcon/ButtonIcon.mjs"; import { HiddenVisually } from "../HiddenVisually/HiddenVisually.mjs"; import { Strong } from "../Strong/Strong.mjs"; import { Text } from "../Text/Text.mjs"; import { Announcement } from "../private/Announcement/Announcement.mjs"; import { ClearField } from "../private/Field/ClearField.mjs"; import { Field } from "../private/Field/Field.mjs"; import { Popover } from "../private/Popover/Popover.mjs"; import { getNextIndex } from "../private/getNextIndex.mjs"; import { normalizeKey } from "../private/normalizeKey.mjs"; import { smoothScroll } from "../private/smoothScroll.mjs"; import { useResponsiveValue } from "../useResponsiveValue/useResponsiveValue.mjs"; import { getItemId, createAccessibilityProps } from "./createAccessibilityProps.mjs"; import { reverseMatches } from "./reverseMatches.mjs"; import { backdrop, backdropVisible, menu } from "./Autosuggest.css.mjs"; import { touchableText } from "../../css/typography.css.mjs"; import { IconClear } from "../icons/IconClear/IconClear.mjs"; const INPUT_FOCUS = 0; const INPUT_BLUR = 1; const INPUT_CHANGE = 2; const INPUT_ARROW_UP = 3; const INPUT_ARROW_DOWN = 4; const INPUT_ESCAPE = 5; const INPUT_ENTER = 6; const SUGGESTION_MOUSE_CLICK = 7; const SUGGESTION_MOUSE_ENTER = 8; const HAS_SUGGESTIONS_CHANGED = 9; function SuggestionItem({ suggestion, highlighted, selected, onHover, id, ...restProps }) { const { highlights = [], onClear, clearLabel } = suggestion; const label = suggestion.label ?? suggestion.text; const suggestionParts = parseHighlights( label, highlights.map(({ start, end }) => [start, end]) ); return /* @__PURE__ */ jsx( Box, { component: "li", cursor: "pointer", onMouseDown: (event) => { event.preventDefault(); }, onMouseMove: onHover, onTouchStart: onHover, id, ...restProps, children: /* @__PURE__ */ jsxs( Box, { component: "span", display: "flex", justifyContent: "spaceBetween", background: highlighted ? "formAccentSoft" : void 0, paddingX: "small", paddingRight: onClear ? "none" : void 0, children: [ /* @__PURE__ */ jsxs(Box, { className: touchableText.standard, children: [ /* @__PURE__ */ jsx(Text, { baseline: false, children: suggestionParts.map( ({ highlight, text }, index) => selected || highlight ? /* @__PURE__ */ jsx(Strong, { children: text }, index) : /* @__PURE__ */ jsx(Fragment$1, { children: text }, index) ) }), suggestion.description ? /* @__PURE__ */ jsx(Text, { size: "small", tone: "secondary", baseline: false, children: suggestion.description }) : null ] }), typeof onClear === "function" ? /* @__PURE__ */ jsx( Box, { display: "flex", alignItems: "center", justifyContent: "center", width: "touchable", height: "touchable", children: /* @__PURE__ */ jsx( ButtonIcon, { icon: /* @__PURE__ */ jsx(IconClear, { tone: "secondary" }), tabIndex: -1, size: "small", label: clearLabel || "Clear suggestion", onClick: (event) => { event.preventDefault(); event.stopPropagation(); onClear(valueFromSuggestion(suggestion)); } } ) } ) : null ] } ) } ); } function GroupHeading({ children }) { return /* @__PURE__ */ jsx( Box, { paddingX: "small", className: [ touchableText.xsmall, textStyles({ size: "small", baseline: true, weight: "strong", tone: "secondary" }) ], "data-testid": process.env.NODE_ENV !== "production" ? `group-heading-${children}` : void 0, children } ); } function normaliseSuggestions(suggestions) { let index = 0; const normalisedSuggestions = []; const groupHeadingIndexes = /* @__PURE__ */ new Map(); const groupHeadingForSuggestion = /* @__PURE__ */ new Map(); for (const item of suggestions) { if ("suggestions" in item) { groupHeadingIndexes.set(index, item.label); item.suggestions.forEach((suggestion) => { groupHeadingForSuggestion.set(suggestion, item.label); }); index = normalisedSuggestions.push(...item.suggestions); } else { index = normalisedSuggestions.push(item); } } return { normalisedSuggestions, groupHeadingIndexes, groupHeadingForSuggestion }; } function valueFromSuggestion(suggestion) { return "value" in suggestion ? { text: suggestion.text, value: suggestion.value } : { text: suggestion.text }; } const noop = () => { }; const fallbackValue = { text: "" }; const fallbackSuggestions = []; function normaliseNoSuggestionMessage(noSuggestionsMessage) { return typeof noSuggestionsMessage === "string" ? { description: noSuggestionsMessage } : noSuggestionsMessage; } function highlightSuggestions(suggestion, value, variant = "matching") { const matchedHighlights = matchHighlights(suggestion, value); const matches = variant === "matching" ? matchedHighlights : reverseMatches(suggestion, matchedHighlights); return matches.map(([start, end]) => ({ start, end })); } const Autosuggest = forwardRef(function({ id, value = fallbackValue, suggestions: suggestionsProp = fallbackSuggestions, noSuggestionsMessage: noSuggestionsMessageProp, onChange = noop, automaticSelection = false, suggestionHighlight, showMobileBackdrop = false, scrollToTopOnMobile = true, hideSuggestionsOnSelection = true, onFocus = noop, onBlur = noop, placeholder, type = "text", clearLabel, onClear, translations = autosuggest, ...restProps }, forwardedRef) { const suggestionsPropValue = typeof suggestionsProp === "function" ? suggestionsProp(value) : suggestionsProp; const suggestions = Array.isArray(suggestionsPropValue) ? suggestionsPropValue : []; const noSuggestionsMessage = normaliseNoSuggestionMessage( noSuggestionsMessageProp ); const hasItems = suggestions.length > 0 || Boolean(noSuggestionsMessage); const hasExplicitHighlights = suggestions.some( (suggestion) => "highlights" in suggestion ); if (process.env.NODE_ENV !== "production") { if (suggestionHighlight && hasExplicitHighlights) { console.warn( dedent` In Autosuggest, you are using the "suggestionHighlight" prop with suggestions that have individual highlight ranges. Your provided highlight ranges will be overridden. If you want to use your own highlight ranges, remove the "suggestionHighlight" prop. ` ); } } const resolvedId = useFallbackId(id); const defaultRef = useRef(null); const inputRef = forwardedRef || defaultRef; const fireChange = useCallback( (suggestion) => onChange(valueFromSuggestion(suggestion)), [onChange] ); const fieldRef = useRef(null); const rootRef = useRef(null); const menuRef = useRef(null); const justPressedArrowRef = useRef(false); const { normalisedSuggestions, groupHeadingIndexes, groupHeadingForSuggestion } = normaliseSuggestions(suggestions); const suggestionCount = normalisedSuggestions.length; const hasSuggestions = suggestionCount > 0; const reducer = (state, action) => { switch (action.type) { case INPUT_ARROW_DOWN: { if (hasSuggestions) { const nextIndex = getNextIndex( 1, state.highlightedIndex, suggestionCount ); return { ...state, showSuggestionsIfAvailable: true, previewValue: normalisedSuggestions[nextIndex], highlightedIndex: nextIndex }; } return state; } case INPUT_ARROW_UP: { if (hasSuggestions) { const nextIndex = getNextIndex( -1, state.highlightedIndex, suggestionCount ); return { ...state, showSuggestionsIfAvailable: true, previewValue: normalisedSuggestions[nextIndex], highlightedIndex: nextIndex }; } return state; } case INPUT_CHANGE: { return { ...state, showSuggestionsIfAvailable: true, inputChangedSinceFocus: true, previewValue: null, highlightedIndex: hasSuggestions && automaticSelection && value.text.length > 0 ? 0 : null }; } case INPUT_FOCUS: { return { ...state, showSuggestionsIfAvailable: true, inputChangedSinceFocus: false, isFocused: true }; } case INPUT_BLUR: { return { ...state, showSuggestionsIfAvailable: false, previewValue: null, highlightedIndex: null, isFocused: false }; } case INPUT_ESCAPE: { if (value.text) { return { ...state, showSuggestionsIfAvailable: false, previewValue: null, highlightedIndex: null }; } else if (hasItems) { return { ...state, showSuggestionsIfAvailable: !state.showSuggestionsIfAvailable, previewValue: null }; } return state; } case INPUT_ENTER: case SUGGESTION_MOUSE_CLICK: { return { ...state, showSuggestionsIfAvailable: !hideSuggestionsOnSelection, previewValue: null, highlightedIndex: null }; } case SUGGESTION_MOUSE_ENTER: { return { ...state, highlightedIndex: action.value }; } case HAS_SUGGESTIONS_CHANGED: { return automaticSelection ? { ...state, highlightedIndex: hasSuggestions && value.text.length > 0 ? 0 : null } : state; } default: { console.error(new Error(`Invalid Autosuggest action: ${action}`)); return state; } } }; const [ { showSuggestionsIfAvailable, inputChangedSinceFocus, previewValue, highlightedIndex, isFocused }, dispatch ] = useReducer(reducer, { showSuggestionsIfAvailable: false, inputChangedSinceFocus: false, previewValue: null, highlightedIndex: null, isFocused: false }); const isOpen = showSuggestionsIfAvailable && hasItems; const highlightedItem = typeof highlightedIndex === "number" ? document.getElementById(getItemId(resolvedId, highlightedIndex)) : null; highlightedItem == null ? void 0 : highlightedItem.scrollIntoView({ block: "nearest" }); useEffect(() => { dispatch({ type: HAS_SUGGESTIONS_CHANGED }); }, [hasSuggestions]); const isMobile = useResponsiveValue()({ mobile: true, tablet: false }); useEffect(() => { if (menuRef.current && isOpen && !isMobile) { const { bottom: menuBottom } = menuRef.current.getBoundingClientRect(); const viewportHeight = document.documentElement.clientHeight; if (menuBottom > viewportHeight) { menuRef.current.scrollIntoView(false); } } }, [isOpen, isMobile, suggestionCount]); const inputProps = { value: previewValue ? previewValue.text : value.text, type: type === "search" ? type : "text", placeholder: !restProps.disabled ? placeholder : void 0, onChange: (e) => { const inputValue = e.target.value; dispatch({ type: INPUT_CHANGE }); fireChange({ text: inputValue }); }, onFocus: () => { if (rootRef.current && scrollToTopOnMobile && isMobile) { smoothScroll(rootRef.current); } dispatch({ type: INPUT_FOCUS }); onFocus(); }, onBlur: () => { if (justPressedArrowRef.current === true) { return; } if (previewValue) { fireChange(previewValue); } else if (isOpen && automaticSelection && inputChangedSinceFocus && value.text.length > 0 && suggestionCount > 0) { fireChange(normalisedSuggestions[0]); } dispatch({ type: INPUT_BLUR }); onBlur(); }, onKeyDown: (event) => { const targetKey = normalizeKey(event); if (/^Arrow(Up|Down$)/.test(targetKey)) { justPressedArrowRef.current = true; setTimeout(() => { justPressedArrowRef.current = false; }, 150); } switch (targetKey) { case "ArrowDown": { event.preventDefault(); dispatch({ type: INPUT_ARROW_DOWN }); return; } case "ArrowUp": { event.preventDefault(); dispatch({ type: INPUT_ARROW_UP }); return; } case "Escape": { event.preventDefault(); if (previewValue === null && value.text) { fireChange({ text: "" }); } dispatch({ type: INPUT_ESCAPE }); return; } case "Enter": { if (typeof highlightedIndex === "number") { event.preventDefault(); fireChange(normalisedSuggestions[highlightedIndex]); } dispatch({ type: INPUT_ENTER }); return; } default: { return; } } } }; const a11y = createAccessibilityProps({ id: resolvedId, isOpen, highlightedIndex }); const clearable = Boolean( typeof onClear !== "undefined" && !restProps.disabled && typeof value !== "undefined" && value.text.length > 0 ); const announcements = []; const hasAutomaticSelection = automaticSelection && previewValue === null && highlightedIndex === 0; if (isFocused && isOpen && (highlightedIndex === null || hasAutomaticSelection)) { if (hasSuggestions) { announcements.push( translations.suggestionsAvailableAnnouncement(suggestionCount) ); if (hasAutomaticSelection) { announcements.push( translations.suggestionAutoSelectedAnnouncement( normalisedSuggestions[0].text ) ); } announcements.push(translations.suggestionInstructions); } else if (noSuggestionsMessage) { if (noSuggestionsMessage.title) { announcements.push(noSuggestionsMessage.title); } announcements.push(noSuggestionsMessage.description); } else { announcements.push(translations.noSuggestionsAvailableAnnouncement); } } return /* @__PURE__ */ jsxs(Fragment, { children: [ showMobileBackdrop ? /* @__PURE__ */ jsx( Box, { position: "fixed", zIndex: "dropdownBackdrop", transition: "fast", display: ["block", "none"], pointerEvents: isOpen ? void 0 : "none", top: 0, left: 0, opacity: !isOpen ? 0 : void 0, className: [ backdrop, isOpen ? backdropVisible : void 0 ] } ) : null, /* @__PURE__ */ jsxs( Box, { ...showMobileBackdrop && isOpen ? { position: "relative", zIndex: "dropdown" } : null, children: [ /* @__PURE__ */ jsxs(Box, { ref: rootRef, children: [ /* @__PURE__ */ jsx( Field, { ...restProps, componentName: "Autosuggest", id: resolvedId, value: value.text, prefix: void 0, secondaryIcon: onClear ? /* @__PURE__ */ jsx( ClearField, { id: `${resolvedId}-clearfield`, hide: !clearable, onClear, label: clearLabel, inputRef } ) : null, children: (overlays, fieldProps, icon, secondaryIcon) => /* @__PURE__ */ jsxs(Box, { width: "full", ref: fieldRef, children: [ /* @__PURE__ */ jsx( Box, { component: "input", ...fieldProps, ...a11y.inputProps, ...inputProps, position: "relative", ref: inputRef } ), icon, overlays, secondaryIcon ] }) } ), /* @__PURE__ */ jsx( Popover, { triggerRef: fieldRef, open: isOpen, width: "full", lockPlacement: true, offsetSpace: "xxsmall", modal: false, role: false, children: /* @__PURE__ */ jsx( RemoveScroll, { noRelative: true, ref: menuRef, forwardProps: true, children: /* @__PURE__ */ jsxs( Box, { textAlign: "left", component: "ul", background: !hasSuggestions && noSuggestionsMessage ? { lightMode: "neutralSoft", darkMode: "neutral" } : "surface", borderRadius: "standard", boxShadow: "medium", width: "full", paddingY: "xxsmall", className: menu, ...a11y.menuProps, children: [ !hasSuggestions && noSuggestionsMessage ? /* @__PURE__ */ jsxs( Box, { component: "li", paddingX: "small", className: touchableText.standard, children: [ noSuggestionsMessage.title ? /* @__PURE__ */ jsx(Text, { tone: "secondary", weight: "medium", baseline: false, children: noSuggestionsMessage.title }) : null, /* @__PURE__ */ jsx(Text, { tone: "secondary", baseline: false, children: noSuggestionsMessage.description }) ] } ) : null, hasSuggestions ? normalisedSuggestions.map((suggestion, index) => { const { text } = suggestion; const groupHeading = groupHeadingIndexes.get(index); const highlights = suggestionHighlight ? highlightSuggestions( suggestion.text, value.text, suggestionHighlight ) : suggestion.highlights; return /* @__PURE__ */ jsxs(Fragment$1, { children: [ groupHeading ? /* @__PURE__ */ jsx(GroupHeading, { children: groupHeading }) : null, /* @__PURE__ */ jsx( SuggestionItem, { suggestion: { ...suggestion, highlights }, highlighted: highlightedIndex === index, selected: value === suggestion, onClick: () => { fireChange(suggestion); dispatch({ type: SUGGESTION_MOUSE_CLICK }); }, onHover: () => { dispatch({ type: SUGGESTION_MOUSE_ENTER, value: index }); }, ...a11y.getItemProps({ index, label: suggestion.label ?? suggestion.text, description: suggestion.description, groupHeading: groupHeadingForSuggestion.get(suggestion) }) } ) ] }, index + text); }) : null ] } ) } ) } ) ] }), /* @__PURE__ */ jsx(HiddenVisually, { ...a11y.assistiveDescriptionProps, children: translations.assistiveDescription }), /* @__PURE__ */ jsx(Announcement, { children: announcements.join(". ") }) ] } ) ] }); }); Autosuggest.displayName = "Autosuggest"; export { Autosuggest, highlightSuggestions };