@zeptonow/input-typeahead
Version:
An input typeahead component for react applications, convert any input into a typeahead with a dropdown of suggestions.
1 lines • 37.5 kB
Source Map (JSON)
{"version":3,"sources":["../src/package/components/Typeahead.tsx","../src/package/hooks/useOutsideClick.ts","../src/package/utils/typeaheadUtils.ts","../src/package/utils/positionUtils.ts","../src/package/index.tsx"],"names":["useOutsideClick","refs","handler","isActive","useEffect","handleClickOutside","event","ref","clearTextAfterTrigger","inputRef","triggerChar","input","value","cursorPosition","searchStart","newValue","newCursorPosition","getSearchText","getCaretCoordinates","clone","styles","prop","Typeahead"],"mappings":"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","file":"/Users/rohit/zepto-2/input-typeahead/dist/index.cjs","sourcesContent":["import React, { useState, useEffect, useRef, useCallback } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ZeptoTypeAhead,\n ZeptoTypeAheadOption,\n ZeptoTypeAheadWidgetState,\n} from \"../types\";\nimport { useOutsideClick } from \"../hooks/useOutsideClick\";\nimport { clearTextAfterTrigger, getSearchText } from \"../utils/typeaheadUtils\";\nimport {\n getCaretCoordinates,\n updatePosition as updatePositionUtil,\n} from \"../utils/positionUtils\";\nimport { defaultContainerStyles } from \"../styles/typeaheadStyles\";\nimport TypeaheadHeader from \"./TypeaheadHeader\";\nimport { TypeaheadOption } from \"./TypeaheadOption\";\n\ninterface TypeaheadProps extends Omit<ZeptoTypeAhead, \"onWidgetStateChange\"> {\n inputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;\n onWidgetStateChange?: (state: ZeptoTypeAheadWidgetState) => void;\n}\n\nexport const Typeahead: React.FC<TypeaheadProps> = ({\n options,\n triggerChar = \"/\",\n position = \"bottom\",\n activateMode = \"multiple\",\n maxVisibleOptions = 5,\n typeAheadContainerStyles,\n typeAheadOptionStyles,\n typeAheadActiveOptionStyle,\n typeAheadOptionValueStyles,\n onSelect,\n searchCallback,\n renderOption,\n renderHeader,\n inputRef,\n onWidgetStateChange,\n}) => {\n // Core state management - formerly in useTypeahead hook\n const [isActive, setIsActive] = useState(false);\n const [filteredOptions, setFilteredOptions] = useState<\n ZeptoTypeAheadOption[]\n >([]);\n const [currentOptions, setCurrentOptions] =\n useState<ZeptoTypeAheadOption[]>(options);\n\n // Component specific state\n const [activeIndex, setActiveIndex] = useState(0);\n const [nestedPath, setNestedPath] = useState<ZeptoTypeAheadOption[]>([]);\n const containerRef = useRef<HTMLDivElement>(null);\n const portalRef = useRef<HTMLDivElement | null>(null);\n\n // Refs map to store references to option elements\n const optionRefsMapRef = useRef<Map<number, HTMLDivElement>>(new Map());\n\n // Track whether keyboard navigation is active to prevent mouse hover conflicts\n const isKeyboardNavActive = useRef(false);\n\n // Clear refs map when filtered options change\n useEffect(() => {\n optionRefsMapRef.current.clear();\n }, [filteredOptions]);\n\n // Update currentOptions when options prop changes\n useEffect(() => {\n if (nestedPath.length === 0) {\n setCurrentOptions(options);\n }\n }, [options, nestedPath.length]);\n\n // Get caret coordinates using a more reliable method\n const getCaretCoords = useCallback(() => {\n return getCaretCoordinates(inputRef);\n }, [inputRef]);\n\n const updatePosition = useCallback(() => {\n updatePositionUtil({\n position,\n maxVisibleOptions,\n currentOptionsLength: currentOptions.length,\n containerRef,\n inputRef,\n getCaretCoordinates: getCaretCoords,\n });\n }, [\n position,\n maxVisibleOptions,\n currentOptions.length,\n containerRef,\n inputRef,\n getCaretCoords,\n ]);\n\n const resetTypeahead = useCallback(\n (preserveNesting = false) => {\n setIsActive(false);\n setActiveIndex(0);\n\n // Only reset nested path if not preserving nesting\n if (!preserveNesting) {\n setNestedPath([]);\n setCurrentOptions(options);\n }\n\n setFilteredOptions([]);\n // Reset keyboard navigation state\n isKeyboardNavActive.current = false;\n\n // Focus the input after reset\n if (inputRef.current) {\n inputRef.current.focus();\n }\n },\n [options, inputRef],\n );\n\n const filterOptions = useCallback(\n (searchQuery: string, optionsToFilter: ZeptoTypeAheadOption[]) => {\n // If search is empty, return all options\n if (!searchQuery) return optionsToFilter;\n\n // Apply the filtering logic consistently\n return optionsToFilter.filter((option) => {\n // Use custom search callback if provided\n if (searchCallback) {\n return searchCallback(option, searchQuery);\n }\n\n // Otherwise compare with both label and value\n const labelMatch = option.label\n .toLowerCase()\n .startsWith(searchQuery.toLowerCase());\n const valueMatch = option.value\n ? option.value.toLowerCase().startsWith(searchQuery.toLowerCase())\n : false;\n\n return labelMatch || valueMatch;\n });\n },\n [searchCallback],\n );\n\n const handleSearchInput = useCallback(\n (searchInfo: { searchStart: number; currentSearch: string }) => {\n if (!inputRef.current) return false; // Return false if unable to process\n\n const input = inputRef.current;\n const { searchStart, currentSearch } = searchInfo;\n\n // Check if the trigger character is standalone (preceded by space or start of line)\n const isStandaloneTrigger =\n searchStart === 0 ||\n (searchStart > 0 && /\\s/.test(input.value.charAt(searchStart - 1)));\n\n if (!isStandaloneTrigger) {\n return false;\n }\n\n // For single mode, only allow the typeahead if trigger is the first non-whitespace character\n if (activateMode === \"single\") {\n const textBeforeTrigger = input.value.substring(0, searchStart).trim();\n if (textBeforeTrigger.length > 0) {\n return false;\n }\n }\n\n // Check if there's a space after the trigger character, if so close the typeahead\n if (currentSearch.includes(\" \")) {\n return false;\n }\n\n // Filter options based on current path and search text\n let newFilteredOptions: ZeptoTypeAheadOption[] = [];\n\n if (nestedPath.length > 0) {\n // Get the options at the current nested level\n const currentNestedOptions =\n nestedPath?.[nestedPath.length - 1]?.children || [];\n\n // Apply filtering to the current nested level\n if (currentSearch === \"\") {\n // If search is empty (after backspacing), show all options at current level\n newFilteredOptions = currentNestedOptions;\n } else {\n // Filter based on search text\n newFilteredOptions = filterOptions(\n currentSearch,\n currentNestedOptions,\n );\n }\n } else {\n // Not in nested mode, filter the top-level options\n if (currentSearch === \"\") {\n newFilteredOptions = options;\n } else {\n newFilteredOptions = filterOptions(currentSearch, options);\n }\n }\n\n // Update filtered options\n setFilteredOptions(newFilteredOptions);\n\n // Always return true for nested searches to keep the widget visible and retain the current level\n // This ensures we never reset back to level 1 during active searching\n return nestedPath.length > 0 || newFilteredOptions.length > 0;\n },\n [filterOptions, inputRef, nestedPath, options, activateMode],\n );\n\n const handleSelect = useCallback(\n (option: ZeptoTypeAheadOption) => {\n if (option.children && option.children.length > 0) {\n setNestedPath((prevPath) => [...prevPath, option]);\n setCurrentOptions(option.children);\n setFilteredOptions(option.children);\n setActiveIndex(0);\n clearTextAfterTrigger(inputRef, triggerChar);\n } else {\n if (inputRef.current) {\n const input = inputRef.current;\n const value = input.value;\n const cursorPosition = input.selectionStart || 0;\n const searchStart = value.lastIndexOf(\n triggerChar,\n cursorPosition - 1,\n );\n\n if (searchStart >= 0) {\n // Replace only the trigger character and search text with the selected value\n const newValue =\n value.slice(0, searchStart) +\n (option.value || option.label) +\n value.slice(cursorPosition);\n input.value = newValue;\n\n // Set cursor position after the inserted value\n const newCursorPosition =\n searchStart + (option.value || option.label).length;\n input.setSelectionRange(newCursorPosition, newCursorPosition);\n\n // Focus the input after selection\n input.focus();\n }\n }\n onSelect?.(option);\n resetTypeahead();\n }\n },\n [inputRef, onSelect, resetTypeahead, triggerChar],\n );\n\n const handleBack = useCallback(() => {\n if (nestedPath.length > 0) {\n const newPath = [...nestedPath];\n newPath.pop();\n\n if (newPath.length === 0) {\n setNestedPath([]);\n setCurrentOptions(options);\n setFilteredOptions(options);\n } else {\n setNestedPath(newPath);\n const parentChildren = newPath?.[newPath.length - 1]?.children || [];\n setCurrentOptions(parentChildren);\n setFilteredOptions(parentChildren);\n }\n setActiveIndex(0);\n updatePosition();\n clearTextAfterTrigger(inputRef, triggerChar);\n }\n }, [nestedPath, options, updatePosition, inputRef, triggerChar]);\n\n // Use the outside click hook\n useOutsideClick(\n [containerRef, inputRef],\n () => {\n resetTypeahead();\n // Ensure input maintains focus after clicking outside\n if (inputRef.current) {\n inputRef.current.focus();\n }\n },\n isActive,\n );\n\n const handleNestedNavigation = useCallback(\n (option: ZeptoTypeAheadOption) => {\n if (option.children && option.children.length > 0) {\n setNestedPath((prevPath) => [...prevPath, option]);\n setCurrentOptions(option.children);\n setFilteredOptions(option.children);\n setActiveIndex(0);\n updatePosition();\n clearTextAfterTrigger(inputRef, triggerChar);\n // Ensure input maintains focus after mouse interaction\n if (inputRef.current) {\n inputRef.current.focus();\n }\n } else {\n handleSelect(option);\n }\n },\n [handleSelect, updatePosition, inputRef, triggerChar],\n );\n\n const handleClose = useCallback(() => {\n resetTypeahead();\n // Ensure input maintains focus after closing\n if (inputRef.current) {\n inputRef.current.focus();\n }\n }, [resetTypeahead, inputRef]);\n\n // Helper function to scroll the active option into view using the refs map\n const scrollActiveOptionIntoView = useCallback((index: number) => {\n const optionElement = optionRefsMapRef.current.get(index);\n\n if (optionElement && containerRef.current) {\n const containerRect = containerRef.current.getBoundingClientRect();\n const optionRect = optionElement.getBoundingClientRect();\n\n if (optionRect.bottom > containerRect.bottom) {\n // Option is below view, scroll down\n optionElement.scrollIntoView({\n behavior: \"smooth\",\n block: \"end\",\n });\n } else if (optionRect.top < containerRect.top) {\n // Option is above view, scroll up\n optionElement.scrollIntoView({\n behavior: \"smooth\",\n block: \"end\",\n });\n }\n }\n }, []);\n\n // Create portal container on mount\n useEffect(() => {\n if (!portalRef.current) {\n portalRef.current = document.createElement(\"div\");\n document.body.appendChild(portalRef.current);\n }\n return () => {\n if (portalRef.current) {\n document.body.removeChild(portalRef.current);\n portalRef.current = null;\n }\n };\n }, []);\n\n // Handle input changes and filtering\n useEffect(() => {\n if (!inputRef.current) return;\n\n const input = inputRef.current;\n\n const handleInput = () => {\n const searchInfo = getSearchText(inputRef, triggerChar);\n\n // If we can't get search info, hide the widget\n if (!searchInfo) {\n if (isActive) {\n resetTypeahead();\n }\n return;\n }\n\n // Process the search\n const shouldShowWidget = handleSearchInput(searchInfo);\n\n // If we should show widget or the search is empty, ensure the widget is visible\n if (shouldShowWidget || searchInfo.currentSearch === \"\") {\n if (!isActive) {\n // If widget was hidden, show it again\n setIsActive(true);\n\n // We need to wait for the state to update before positioning\n setTimeout(() => {\n updatePosition();\n }, 0);\n } else {\n // Widget is already visible, just update position\n updatePosition();\n }\n } else if (\n isActive &&\n searchInfo.currentSearch !== \"\" &&\n nestedPath.length === 0\n ) {\n // Hide widget only if:\n // 1. Widget is active\n // 2. We have a non-empty query with no matches\n // 3. We are NOT in a nested level (this is the key change)\n resetTypeahead();\n }\n };\n\n // Only attach the listener when we need it\n if (isActive) {\n input.addEventListener(\"input\", handleInput);\n return () => input.removeEventListener(\"input\", handleInput);\n } else {\n // This creates a more responsive experience by constantly checking for matches\n // even when the widget is hidden\n const checkForMatches = () => {\n const searchInfo = getSearchText(inputRef, triggerChar);\n if (searchInfo) {\n const hasMatches = handleSearchInput(searchInfo);\n if (hasMatches) {\n setIsActive(true);\n setTimeout(() => updatePosition(), 0);\n }\n }\n };\n\n input.addEventListener(\"input\", checkForMatches);\n return () => input.removeEventListener(\"input\", checkForMatches);\n }\n }, [\n isActive,\n inputRef,\n triggerChar,\n updatePosition,\n resetTypeahead,\n handleSearchInput,\n nestedPath.length,\n ]);\n\n // Track cursor movement and trigger character\n useEffect(() => {\n if (!inputRef.current) return;\n\n const input = inputRef.current;\n const handleKeyDown = (e: Event) => {\n const keyEvent = e as KeyboardEvent;\n if (keyEvent.key === triggerChar) {\n // Check if trigger character would be standalone (preceded by space or start of line)\n const value = input.value;\n const cursorPosition = input.selectionStart || 0;\n\n const isStandaloneTrigger =\n cursorPosition === 0 ||\n (cursorPosition > 0 && /\\s/.test(value.charAt(cursorPosition - 1)));\n\n if (!isStandaloneTrigger) {\n return;\n }\n\n // For single mode, only activate if this would be the first non-whitespace character\n if (activateMode === \"single\") {\n const textBeforeCursor = value.substring(0, cursorPosition).trim();\n if (textBeforeCursor.length > 0) {\n return;\n }\n }\n\n // Activate typeahead when trigger character is typed\n setIsActive(true);\n setCurrentOptions(options);\n setFilteredOptions(options);\n\n // We need to wait for the character to be added to the input before positioning\n setTimeout(() => {\n updatePosition();\n }, 0);\n } else if (keyEvent.key === \"Escape\") {\n resetTypeahead();\n }\n };\n\n input.addEventListener(\"keydown\", handleKeyDown);\n return () => input.removeEventListener(\"keydown\", handleKeyDown);\n }, [\n triggerChar,\n options,\n updatePosition,\n resetTypeahead,\n inputRef,\n activateMode,\n ]);\n\n // Track cursor movement with click and selection but avoid re-rendering on arrow keys\n useEffect(() => {\n if (!inputRef.current || !isActive) return;\n\n const input = inputRef.current;\n\n const handleCursorMove = (e: Event) => {\n const keyEvent = e as KeyboardEvent;\n // Only update position for non-arrow key events to prevent blinking\n const isArrowKey =\n keyEvent.key === \"ArrowUp\" ||\n keyEvent.key === \"ArrowDown\" ||\n keyEvent.key === \"ArrowLeft\" ||\n keyEvent.key === \"ArrowRight\";\n\n if (e.type !== \"keyup\" || !isArrowKey) {\n updatePosition();\n }\n };\n\n // Track all events that might move the cursor\n input.addEventListener(\"click\", handleCursorMove);\n input.addEventListener(\"keyup\", handleCursorMove);\n input.addEventListener(\"mouseup\", handleCursorMove);\n\n return () => {\n input.removeEventListener(\"click\", handleCursorMove);\n input.removeEventListener(\"keyup\", handleCursorMove);\n input.removeEventListener(\"mouseup\", handleCursorMove);\n };\n }, [isActive, updatePosition, inputRef]);\n\n // Track widget state changes\n useEffect(() => {\n if (!onWidgetStateChange) return;\n\n const searchInfo = getSearchText(inputRef, triggerChar);\n onWidgetStateChange({\n isActive,\n currentSearch: searchInfo?.currentSearch || \"\",\n selectedOption: filteredOptions[activeIndex],\n });\n }, [\n isActive,\n filteredOptions,\n activeIndex,\n inputRef,\n triggerChar,\n onWidgetStateChange,\n ]);\n\n // Update keyboard navigation to use just Alt/Option key\n useEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n if (!isActive || !filteredOptions.length) return;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n // Set keyboard navigation as active\n isKeyboardNavActive.current = true;\n\n setActiveIndex((prev) => {\n const newIndex = (prev + 1) % filteredOptions.length;\n // Scroll the newly active option into view\n setTimeout(() => scrollActiveOptionIntoView(newIndex), 0);\n return newIndex;\n });\n break;\n case \"ArrowUp\":\n e.preventDefault();\n // Set keyboard navigation as active\n isKeyboardNavActive.current = true;\n\n setActiveIndex((prev) => {\n const newIndex =\n (prev - 1 + filteredOptions.length) % filteredOptions.length;\n // Scroll the newly active option into view\n setTimeout(() => scrollActiveOptionIntoView(newIndex), 0);\n return newIndex;\n });\n break;\n case \"Enter\":\n if (isActive) {\n e.preventDefault();\n handleSelect(\n filteredOptions?.[activeIndex] as ZeptoTypeAheadOption,\n );\n }\n break;\n case \"Escape\":\n if (isActive) {\n e.preventDefault();\n resetTypeahead();\n }\n break;\n case \"Alt\":\n if (isActive) {\n e.preventDefault();\n handleBack();\n }\n break;\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [\n isActive,\n filteredOptions,\n activeIndex,\n handleSelect,\n resetTypeahead,\n handleBack,\n scrollActiveOptionIntoView,\n ]);\n\n // Reset keyboard navigation flag on mouse movement\n useEffect(() => {\n const handleMouseMove = () => {\n isKeyboardNavActive.current = false;\n };\n\n window.addEventListener(\"mousemove\", handleMouseMove);\n return () => window.removeEventListener(\"mousemove\", handleMouseMove);\n }, []);\n\n useEffect(() => {\n if (isActive) {\n updatePosition();\n window.addEventListener(\"resize\", updatePosition);\n window.addEventListener(\"scroll\", updatePosition);\n return () => {\n window.removeEventListener(\"resize\", updatePosition);\n window.removeEventListener(\"scroll\", updatePosition);\n };\n }\n }, [isActive, updatePosition]);\n\n if (!isActive || !portalRef.current || !filteredOptions.length) return null;\n\n return createPortal(\n <div\n ref={containerRef}\n style={{\n maxHeight: `${maxVisibleOptions * 36}px`,\n ...defaultContainerStyles,\n ...typeAheadContainerStyles,\n }}\n >\n <style>\n {`\n @keyframes typeaheadFadeIn {\n 0% {\n opacity: 0;\n transform: translateY(-8px);\n }\n 100% {\n opacity: 1;\n transform: translateY(0);\n }\n }\n `}\n </style>\n\n {renderHeader ? (\n renderHeader({\n onClose: handleClose,\n onBack: handleBack,\n currentLevel: nestedPath[nestedPath.length - 1],\n nestedPath,\n })\n ) : (\n <TypeaheadHeader\n onClose={handleClose}\n onBack={handleBack}\n currentLevel={nestedPath[nestedPath.length - 1]}\n nestedPath={nestedPath}\n />\n )}\n\n {filteredOptions.map((option, index) => {\n const isActive = index === activeIndex;\n\n if (renderOption) {\n // If using a custom renderer, we still need to associate a key with the index\n const customOption = renderOption({\n option,\n isActive,\n onClick: () => {\n if (option.children && option.children.length > 0) {\n handleNestedNavigation(option);\n } else {\n handleSelect(option);\n }\n },\n });\n\n // Just add the key to the custom element to avoid type issues\n return React.isValidElement(customOption)\n ? React.cloneElement(customOption, {\n key: `${option.label}-${index}`,\n })\n : customOption;\n }\n\n return (\n <div\n key={`${option.label}-${index}`}\n ref={(node) => {\n if (node) {\n optionRefsMapRef.current.set(index, node);\n } else {\n optionRefsMapRef.current.delete(index);\n }\n }}\n onMouseEnter={() => {\n // Only update active index if keyboard navigation is not active\n if (!isKeyboardNavActive.current) {\n setActiveIndex(index);\n scrollActiveOptionIntoView(index);\n }\n }}\n >\n <TypeaheadOption\n option={option}\n isActive={isActive}\n customStyles={typeAheadOptionStyles}\n activeStyles={typeAheadActiveOptionStyle}\n valueStyles={typeAheadOptionValueStyles}\n onClick={() => {\n if (option.children && option.children.length > 0) {\n handleNestedNavigation(option);\n } else {\n handleSelect(option);\n }\n }}\n />\n </div>\n );\n })}\n </div>,\n portalRef.current,\n );\n};\n","import { useEffect, RefObject } from 'react';\n\nexport const useOutsideClick = (\n refs: RefObject<HTMLElement | null>[],\n handler: (event: MouseEvent) => void,\n isActive: boolean\n) => {\n useEffect(() => {\n if (!isActive) return;\n\n const handleClickOutside = (event: MouseEvent) => {\n // Check if click is outside all provided refs\n const isOutside = refs.every(\n ref => ref.current && !ref.current.contains(event.target as Node)\n );\n\n if (isOutside) {\n handler(event);\n }\n };\n\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, [refs, handler, isActive]);\n}; ","import { RefObject } from \"react\";\n\nexport const clearTextAfterTrigger = (\n inputRef: RefObject<HTMLInputElement | HTMLTextAreaElement>,\n triggerChar: string\n): void => {\n if (!inputRef.current) return;\n\n const input = inputRef.current;\n const value = input.value;\n const cursorPosition = input.selectionStart || 0;\n\n // Find the last occurrence of the trigger character before the cursor\n const searchStart = value.lastIndexOf(triggerChar, cursorPosition - 1);\n\n if (searchStart >= 0) {\n // Only clear the text from the trigger character up to the cursor\n const newValue = value.slice(0, searchStart) + triggerChar;\n input.value = newValue;\n\n // Set cursor position right after the trigger character\n const newCursorPosition = searchStart + triggerChar.length;\n input.setSelectionRange(newCursorPosition, newCursorPosition);\n }\n};\n\nexport const getSearchText = (\n inputRef: RefObject<HTMLInputElement | HTMLTextAreaElement>,\n triggerChar: string\n): { searchStart: number; currentSearch: string } | null => {\n if (!inputRef.current) return null;\n\n const input = inputRef.current;\n const value = input.value;\n const cursorPosition = input.selectionStart || 0;\n const searchStart = value.lastIndexOf(triggerChar, cursorPosition - 1);\n\n if (searchStart === -1) return null;\n\n return {\n searchStart,\n currentSearch: value.slice(searchStart + 1, cursorPosition),\n };\n}; ","import { RefObject } from \"react\";\n\nexport interface CaretCoordinates {\n left: number;\n top: number;\n}\n\nexport const getCaretCoordinates = (\n inputRef: RefObject<HTMLInputElement | HTMLTextAreaElement>,\n): CaretCoordinates => {\n if (!inputRef.current) return { top: 0, left: 0 };\n\n const input = inputRef.current;\n\n if (input instanceof HTMLTextAreaElement) {\n // Create a clone of the textarea with exact same styles and dimensions\n const clone = document.createElement(\"div\");\n\n // Copy all computed styles\n const styles = window.getComputedStyle(input);\n Array.from(styles).forEach((prop) => {\n clone.style.setProperty(prop, styles.getPropertyValue(prop));\n });\n\n // Match dimensions of the textarea content area exactly\n clone.style.position = \"absolute\";\n clone.style.top = \"0\";\n clone.style.left = \"0\";\n clone.style.visibility = \"hidden\";\n clone.style.overflow = \"hidden\";\n clone.style.height = \"auto\";\n clone.style.width = `${input.clientWidth}px`;\n clone.style.boxSizing = \"border-box\";\n\n // Copy padding explicitly\n clone.style.paddingTop = styles.paddingTop;\n clone.style.paddingRight = styles.paddingRight;\n clone.style.paddingBottom = styles.paddingBottom;\n clone.style.paddingLeft = styles.paddingLeft;\n\n // Copy text-related styles\n clone.style.whiteSpace = styles.whiteSpace;\n clone.style.wordWrap = styles.wordWrap;\n clone.style.lineHeight = styles.lineHeight;\n clone.style.fontFamily = styles.fontFamily;\n clone.style.fontSize = styles.fontSize;\n clone.style.fontWeight = styles.fontWeight;\n\n // Get text up to cursor position\n const selectionStart = input.selectionStart || 0;\n const textBeforeCursor = input.value.substring(0, selectionStart);\n\n // Split by newlines and preserve them\n const textWithBreaks = textBeforeCursor.replace(/\\n/g, \"<br>\");\n clone.innerHTML = textWithBreaks;\n\n // Add a span at the end to represent cursor position\n const cursorSpan = document.createElement(\"span\");\n cursorSpan.textContent = \"|\";\n cursorSpan.style.display = \"inline\";\n cursorSpan.style.width = \"0\";\n cursorSpan.style.overflow = \"hidden\";\n clone.appendChild(cursorSpan);\n\n // Add to DOM, get position, then remove\n document.body.appendChild(clone);\n\n // Get client rect of cursor position\n const cursorRect = cursorSpan.getBoundingClientRect();\n const cloneRect = clone.getBoundingClientRect();\n\n document.body.removeChild(clone);\n\n // Apply padding offsets (since the clone might not include them in its position)\n const paddingLeft = parseInt(styles.paddingLeft) || 0;\n\n // Calculate position relative to input, accounting for scroll\n return {\n left: cursorRect.left - cloneRect.left + paddingLeft,\n top: cursorRect.top - cloneRect.top - input.scrollTop,\n };\n } else {\n // For single-line inputs\n const textBeforeCursor = input.value.substring(\n 0,\n input.selectionStart || 0,\n );\n const span = document.createElement(\"span\");\n span.style.font = window.getComputedStyle(input).font;\n span.style.position = \"absolute\";\n span.style.visibility = \"hidden\";\n span.style.whiteSpace = \"pre\";\n span.textContent = textBeforeCursor || \"\";\n document.body.appendChild(span);\n\n const width = span.getBoundingClientRect().width;\n document.body.removeChild(span);\n\n // Account for padding and scrolling\n const paddingLeft =\n parseInt(window.getComputedStyle(input).paddingLeft) || 0;\n\n return {\n left: width + paddingLeft - input.scrollLeft,\n top: 0,\n };\n }\n};\n\nexport interface PositionConfig {\n position: \"top\" | \"bottom\" | \"cursor\";\n maxVisibleOptions: number;\n currentOptionsLength: number;\n containerRef: RefObject<HTMLDivElement | null>;\n inputRef: RefObject<HTMLInputElement | HTMLTextAreaElement>;\n getCaretCoordinates: () => CaretCoordinates;\n}\n\nexport const updatePosition = (config: PositionConfig) => {\n const {\n position,\n maxVisibleOptions,\n currentOptionsLength,\n containerRef,\n inputRef,\n getCaretCoordinates,\n } = config;\n\n if (!containerRef.current || !inputRef.current) return;\n\n const container = containerRef.current;\n const input = inputRef.current;\n const inputRect = input.getBoundingClientRect();\n\n // Keep container invisible until positioned correctly\n container.style.visibility = \"hidden\";\n container.style.opacity = \"0\";\n\n // Calculate container height based on options and max visible\n const optionCount = Math.min(currentOptionsLength, maxVisibleOptions);\n const optionHeight = 36; // Estimated height of an option\n const containerHeight = optionCount * optionHeight;\n const containerWidth = container.offsetWidth;\n\n // Constants for positioning\n const VERTICAL_SPACING = 8;\n const HORIZONTAL_OFFSET = -4;\n\n // Variables for positioning\n let leftPositionValue = \"0px\";\n let topPositionValue = \"0px\";\n let bottomPositionValue = \"\";\n\n if (position === \"cursor\") {\n // Get caret coordinates\n const caretCoords = getCaretCoordinates();\n\n // Calculate absolute position\n const cursorLeft = inputRect.left + caretCoords.left;\n const cursorTop = inputRect.top + caretCoords.top;\n\n // Account for line height\n const lineHeight =\n parseInt(window.getComputedStyle(input).lineHeight) ||\n parseInt(window.getComputedStyle(input).fontSize) ||\n 16;\n\n // Check if there's enough space below the cursor\n const spaceBelow = window.innerHeight - (cursorTop + lineHeight);\n const spaceAbove = cursorTop;\n\n // Determine the best position based on available space\n const shouldPositionAbove =\n spaceBelow < containerHeight + VERTICAL_SPACING &&\n spaceAbove >= containerHeight + VERTICAL_SPACING;\n\n if (shouldPositionAbove) {\n // Position above the cursor\n topPositionValue = \"\";\n bottomPositionValue = `${window.innerHeight - cursorTop + VERTICAL_SPACING}px`;\n } else {\n // Position below the cursor\n topPositionValue = `${cursorTop + lineHeight + VERTICAL_SPACING}px`;\n bottomPositionValue = \"\";\n }\n\n // Determine horizontal position based on available space\n const leftPosition = Math.max(0, cursorLeft - HORIZONTAL_OFFSET);\n const rightOverflow = leftPosition + containerWidth > window.innerWidth;\n\n if (rightOverflow) {\n // Not enough space to the right, try positioning aligned to the right edge of the screen\n leftPositionValue = `${Math.max(0, window.innerWidth - containerWidth - 10)}px`;\n } else {\n // Enough space, position to the left of cursor\n leftPositionValue = `${leftPosition}px`;\n }\n } else {\n // For top/bottom positioning (relative to the input)\n const spaceAbove = inputRect.top;\n const spaceBelow = window.innerHeight - inputRect.bottom;\n\n // Determine the best position based on available space\n const effectivePosition =\n (position === \"top\" && spaceAbove < containerHeight + VERTICAL_SPACING) ||\n (position === \"bottom\" && spaceBelow < containerHeight + VERTICAL_SPACING)\n ? position === \"top\"\n ? \"bottom\"\n : \"top\"\n : position;\n\n // Horizontal positioning for input\n const leftPosition = inputRect.left;\n const rightOverflow = leftPosition + containerWidth > window.innerWidth;\n\n if (rightOverflow) {\n // Align to right edge of input\n leftPositionValue = `${Math.max(0, inputRect.right - containerWidth)}px`;\n } else {\n // Align to left edge of input\n leftPositionValue = `${leftPosition}px`;\n }\n\n // Vertical positioning - set either top or bottom but not both\n if (effectivePosition === \"top\") {\n topPositionValue = \"\";\n bottomPositionValue = `${window.innerHeight - inputRect.top + VERTICAL_SPACING}px`;\n } else {\n topPositionValue = `${inputRect.bottom + VERTICAL_SPACING}px`;\n bottomPositionValue = \"\";\n }\n }\n\n // Apply all positioning at once to prevent jitter\n container.style.left = leftPositionValue;\n container.style.top = topPositionValue;\n container.style.bottom = bottomPositionValue;\n\n // Make visible after all positioning is done\n // This prevents visible repositioning that causes jitter\n requestAnimationFrame(() => {\n if (container) {\n container.style.visibility = \"visible\";\n container.style.opacity = \"1\";\n }\n });\n};\n","export type {\n ZeptoTypeAhead,\n ZeptoTypeAheadOption,\n ZeptoTypeAheadHeaderProps,\n ZeptoTypeAheadActivateMode,\n ZeptoTypeAheadPosition,\n ZeptoTypeAheadWidgetState,\n} from \"./types\";\n\nimport { Typeahead } from \"./components/Typeahead\";\nexport default Typeahead;\n"]}