{"version":3,"file":"inputDropdown.mjs","names":[],"sources":["../../../../package/common/inputDropdown/inputDropdown.tsx"],"sourcesContent":["import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport cx from 'classnames';\nimport styles from './inputDropdown.module.pcss';\nimport Input from '../input';\nimport Popover from '../popover';\nimport { PopoverAlign, PopoverPlacement } from '../popover/popover.types';\nimport { useComponentDefaults, useLabels } from '../configProvider';\nimport { Presentation } from '../configProvider/presentation.types';\nimport type { InputDropdownItem, InputDropdownLabels, InputDropdownProps } from './inputDropdown.types';\nimport { nextIndex, scrollOptionIntoView } from '../utils/listNavigation';\n\nfunction getKeyFromProps(value: string | undefined, formValidator: InputDropdownProps['formValidator']): string {\n  return formValidator ? ((formValidator.value as string) ?? '') : (value ?? '');\n}\n\nfunction labelForKey(key: string, items: InputDropdownItem[]): string {\n  const match = items.find((item) => item.value === key);\n  return match ? (match.label ?? match.value) : key;\n}\n\n/**\n * The suggestions do not wrap: the list is filtered by what is being typed, so running off one\n * end and reappearing at the other reads as the list having changed under the caret.\n */\nconst NO_WRAP = { wrap: false };\n\nconst DEFAULT_LABELS: Required<InputDropdownLabels> = { empty: 'No matches' };\n\nexport default function InputDropdown(props: InputDropdownProps) {\n  const {\n    label = '',\n    value = '',\n    formValidator = null,\n    error,\n    isRequired = false,\n    items = [],\n    allowCustomValue = false,\n    maxItems = 100,\n    className,\n    style,\n    classNames,\n    styles: slotStyles,\n    onSelect = () => null,\n    onChange,\n    placeholder = '',\n    readOnly = false,\n    labels,\n    ...rest\n  } = useComponentDefaults('InputDropdown', props);\n  const text = useLabels('InputDropdown', labels, DEFAULT_LABELS);\n  const currentKey = getKeyFromProps(value, formValidator);\n  const [, forceUpdate] = useState(0);\n  const fvRef = useRef(formValidator);\n\n  useEffect(() => {\n    fvRef.current = formValidator;\n    if (!formValidator) return;\n    const listener = () => forceUpdate((n) => n + 1);\n    formValidator.registerOnUpdateListener(listener);\n    return () => {\n      formValidator.removeOnUpdateListener(listener);\n    };\n  }, [formValidator]);\n\n  const computedIsRequired =\n    isRequired || !!formValidator?.validators.find((v) => (v as { validatorId?: string }).validatorId === 'required');\n\n  const [inputText, setInputText] = useState(() => labelForKey(currentKey, items));\n  const [prevKey, setPrevKey] = useState(currentKey);\n  const [isMenuShown, setIsMenuShown] = useState(false);\n  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);\n  const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  // Sync display text when the controlled key changes externally (e.g. parent reset).\n  if (currentKey !== prevKey) {\n    setPrevKey(currentKey);\n    const match = items.find((item) => item.value === currentKey);\n    if (match) setInputText(match.label ?? match.value);\n    else if (allowCustomValue) setInputText(currentKey);\n  }\n\n  // `items` often arrives after the value, leaving the raw key on display. Not while open, or a key\n  // being typed would turn into its label under the caret.\n  if (!isMenuShown && !allowCustomValue && inputText === currentKey) {\n    const selectedLabel = labelForKey(currentKey, items);\n    if (selectedLabel !== inputText) setInputText(selectedLabel);\n  }\n\n  const currItems = useMemo(() => {\n    const needle = inputText.toLowerCase();\n    const matches: InputDropdownItem[] = [];\n    for (const item of items) {\n      if (!(item.label ?? item.value).toLowerCase().includes(needle)) continue;\n      matches.push(item);\n      if (maxItems > 0 && matches.length >= maxItems) break;\n    }\n    return matches;\n  }, [items, inputText, maxItems]);\n\n  // Only text that blur swaps for the selection's label needs a warning. Comparing with the label\n  // rather than '' keeps a preset value missing from `items` from showing \"No matches\" unprompted.\n  const isDiscardedOnBlur = !allowCustomValue && inputText !== '' && inputText !== labelForKey(currentKey, items);\n  const showEmpty = currItems.length === 0 && isDiscardedOnBlur;\n  // Closed rather than hidden when empty: a mounted panel stays on the overlay stack, so inside a\n  // Modal an invisible list would take the Escape meant for the modal.\n  const isOpen = isMenuShown && !readOnly && (currItems.length > 0 || showEmpty);\n\n  // A ref callback rather than a layout effect: the list is portaled out of the field's font size,\n  // and Popover positions it in its own layout effect, which runs before this component's would.\n  const menuFontSizeOverride = slotStyles?.dropdown?.fontSize;\n  const menuRef = useCallback(\n    (menu: HTMLDivElement | null) => {\n      const trigger = triggerRef.current;\n      if (!menu || !trigger || menuFontSizeOverride != null) return;\n      menu.style.fontSize = window.getComputedStyle(trigger).fontSize;\n    },\n    [menuFontSizeOverride],\n  );\n\n  const handleFocus = () => {\n    if (readOnly) return;\n    if (hideTimeoutRef.current) {\n      clearTimeout(hideTimeoutRef.current);\n      hideTimeoutRef.current = null;\n    }\n    setIsMenuShown(true);\n  };\n\n  const handleBlur = () => {\n    if (fvRef.current) fvRef.current.touched = true;\n    hideTimeoutRef.current = setTimeout(() => {\n      setIsMenuShown(false);\n      setHighlightedIndex(null);\n      hideTimeoutRef.current = null;\n      // Without custom values, uncommitted typed text is invalid — snap the\n      // display back to the currently selected item's label.\n      if (!allowCustomValue) setInputText(labelForKey(currentKey, items));\n    }, 100);\n  };\n\n  useEffect(() => {\n    if (!isOpen || highlightedIndex === null) return;\n    scrollOptionIntoView(scrollRef.current, scrollRef.current?.children[highlightedIndex]);\n  }, [isOpen, highlightedIndex]);\n\n  const handleSelect = useCallback(\n    (item: InputDropdownItem) => {\n      setInputText(item.label ?? item.value);\n      setIsMenuShown(false);\n      setHighlightedIndex(null);\n      if (formValidator) formValidator.set(item.value);\n      if (onChange) onChange(item.value);\n      onSelect(item.value);\n    },\n    [formValidator, onChange, onSelect],\n  );\n\n  const onKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (readOnly) return;\n      const isArrow = event.key === 'ArrowUp' || event.key === 'ArrowDown';\n      // Enter belongs to the form while closed, or it would pick a suggestion the user never saw.\n      if (!isOpen) {\n        if (isArrow) {\n          event.preventDefault();\n          setIsMenuShown(true);\n        }\n        return;\n      }\n      if (isArrow) {\n        event.preventDefault();\n        const step = event.key === 'ArrowDown' ? 1 : -1;\n        setHighlightedIndex((idx) => {\n          // The text being typed is part of the cycle, so both ends lead back to it and on round\n          // to the far end: last → text → first, first → text → last. That is how a browser's own\n          // address bar behaves, and it is why this list does not wrap straight around the way\n          // Dropdown and MultiSelect do — they have no typed text to pass through.\n          const atFarEnd = idx !== null && idx === (step === 1 ? currItems.length - 1 : 0);\n          return atFarEnd ? null : nextIndex(idx, step, currItems.length, NO_WRAP);\n        });\n      } else if (event.key === 'Enter') {\n        if (highlightedIndex !== null && currItems[highlightedIndex]) {\n          // This Enter picked a suggestion, so it must not also reach the form around the field.\n          // Without this the browser's implicit submission runs as well, and choosing from the\n          // list submitted the form underneath the user. The blank state deliberately does not\n          // do this: Enter there means the text as typed, and submitting is a fair reading of it.\n          event.preventDefault();\n          handleSelect(currItems[highlightedIndex]);\n        } else if (allowCustomValue) {\n          setIsMenuShown(false);\n          setHighlightedIndex(null);\n        }\n      }\n    },\n    [currItems, highlightedIndex, handleSelect, allowCustomValue, readOnly, isOpen],\n  );\n\n  const menu = (\n    <div ref={menuRef} className={cx(styles.dropdown, classNames?.dropdown)} style={slotStyles?.dropdown}>\n      {showEmpty && (\n        <div\n          className={cx(styles.dropdownEmpty, classNames?.empty)}\n          style={slotStyles?.empty}\n          // A blur would throw the typed text away.\n          onMouseDown={(e) => e.preventDefault()}\n        >\n          {text.empty}\n        </div>\n      )}\n      <div ref={scrollRef} className={styles.dropdownScroll}>\n        {currItems.map((item, key) => (\n          <div\n            key={`${key}_${item.label}_${item.value}`}\n            className={cx(\n              styles.dropdownItem,\n              highlightedIndex === key && styles.dropdownItemHighlighted,\n              classNames?.item,\n            )}\n            style={slotStyles?.item}\n            // Prevent the input from blurring before the click resolves.\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={() => handleSelect(item)}\n          >\n            {item.label ?? item.value}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n\n  return (\n    <div\n      {...rest}\n      ref={triggerRef}\n      className={cx(styles.inputDropdown, classNames?.root, className)}\n      style={{ ...slotStyles?.root, ...style }}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      onKeyDown={onKeyDown}\n    >\n      <Popover\n        // The field owns the open state: it opens on typing and closes on blur, on\n        // a pick, or on Escape. Only a close is taken from Popover, which is the\n        // outside click its portaled panel would otherwise not see.\n        isOpen={isOpen}\n        onOpenChange={(next) => {\n          if (next) return;\n          setIsMenuShown(false);\n          setHighlightedIndex(null);\n        }}\n        // Never a sheet, whatever the app-wide `presentation`: its backdrop would cover the field\n        // being typed into.\n        presentation={Presentation.Default}\n        placement={PopoverPlacement.Bottom}\n        align={PopoverAlign.Start}\n        offset={0}\n        withArrow={false}\n        constrainWidth={false}\n        closeOnTriggerClick={false}\n        focusOnOpen={false}\n        classNames={{ panel: styles.inputDropdownPanel }}\n        content={menu}\n      >\n        <Input\n          // A combobox renders its own suggestion list; the browser's autofill dropdown\n          // opens on top of it, so two lists compete for the same space.\n          autoComplete='off'\n          label={label}\n          value={inputText}\n          readOnly={readOnly}\n          formValidator={null}\n          isRequired={computedIsRequired}\n          isValid={formValidator ? !formValidator.shouldShowError : !error}\n          errorMessage={\n            formValidator\n              ? formValidator.shouldShowError\n                ? (formValidator.getCurrentErrorMessage() ?? '')\n                : ''\n              : (error ?? '')\n          }\n          onChange={(typed) => {\n            setInputText(typed);\n            setHighlightedIndex(null);\n            setIsMenuShown(true);\n            if (allowCustomValue) {\n              if (formValidator) formValidator.set(typed);\n              if (onChange) onChange(typed);\n            } else if (typed === '') {\n              if (formValidator) formValidator.set('');\n              if (onChange) onChange('');\n              onSelect('');\n            } else {\n              // Emit when the typed text exactly matches an item's label/value.\n              const match = items.find((item) => (item.label ?? item.value).toLowerCase() === typed.toLowerCase());\n              if (match) {\n                if (formValidator) formValidator.set(match.value);\n                if (onChange) onChange(match.value);\n                onSelect(match.value);\n              }\n            }\n          }}\n          placeholder={placeholder}\n        />\n      </Popover>\n      {/* Outside the portal and always mounted: a live region that mounts already filled is not\n          reliably announced. */}\n      <span className={styles.inputDropdownStatus} role='status' aria-live='polite'>\n        {isOpen && showEmpty ? text.empty : ''}\n      </span>\n    </div>\n  );\n}\n"],"mappings":"iqBAWA,SAAS,GAAgB,EAA2B,EAA4D,CAC9G,OAAO,EAAkB,EAAc,OAAoB,GAAO,GAAS,EAC7E,CAEA,SAAS,EAAY,EAAa,EAAoC,CACpE,IAAM,EAAQ,EAAM,KAAM,GAAS,EAAK,QAAU,CAAG,EACrD,OAAO,EAAS,EAAM,OAAS,EAAM,MAAS,CAChD,CAMA,IAAM,GAAU,CAAE,KAAM,EAAM,EAExB,GAAgD,CAAE,MAAO,YAAa,EAE5E,SAAwB,EAAc,EAA2B,CAC/D,GAAM,CACJ,SAAQ,GACR,QAAQ,GACR,gBAAgB,KAChB,QACA,aAAa,GACb,QAAQ,CAAC,EACT,mBAAmB,GACnB,WAAW,IACX,YACA,QACA,aACA,OAAQ,EACR,eAAiB,KACjB,WACA,eAAc,GACd,WAAW,GACX,SACA,GAAG,IACD,EAAqB,gBAAiB,CAAK,EACzC,EAAO,EAAU,gBAAiB,EAAQ,EAAc,EACxD,EAAa,GAAgB,EAAO,CAAa,EACjD,EAAG,GAAe,EAAS,CAAC,EAC5B,EAAQ,EAAO,CAAa,EAElC,MAAgB,CAEd,GADA,EAAM,QAAU,EACZ,CAAC,EAAe,OACpB,IAAM,MAAiB,EAAa,GAAM,EAAI,CAAC,EAE/C,OADA,EAAc,yBAAyB,CAAQ,MAClC,CACX,EAAc,uBAAuB,CAAQ,CAC/C,CACF,EAAG,CAAC,CAAa,CAAC,EAElB,IAAM,EACJ,GAAc,CAAC,CAAC,GAAe,WAAW,KAAM,GAAO,EAA+B,cAAgB,UAAU,EAE5G,CAAC,EAAW,GAAgB,MAAe,EAAY,EAAY,CAAK,CAAC,EACzE,CAAC,EAAS,GAAc,EAAS,CAAU,EAC3C,CAAC,EAAa,GAAkB,EAAS,EAAK,EAC9C,CAAC,EAAkB,GAAuB,EAAwB,IAAI,EACtE,EAAiB,EAA6C,IAAI,EAClE,EAAa,EAAuB,IAAI,EACxC,EAAY,EAAuB,IAAI,EAG7C,GAAI,IAAe,EAAS,CAC1B,EAAW,CAAU,EACrB,IAAM,EAAQ,EAAM,KAAM,GAAS,EAAK,QAAU,CAAU,EACxD,EAAO,EAAa,EAAM,OAAS,EAAM,KAAK,EACzC,GAAkB,EAAa,CAAU,CACpD,CAIA,GAAI,CAAC,GAAe,CAAC,GAAoB,IAAc,EAAY,CACjE,IAAM,EAAgB,EAAY,EAAY,CAAK,EAC/C,IAAkB,GAAW,EAAa,CAAa,CAC7D,CAEA,IAAM,EAAY,OAAc,CAC9B,IAAM,EAAS,EAAU,YAAY,EAC/B,EAA+B,CAAC,EACtC,IAAK,IAAM,KAAQ,EACb,IAAE,EAAK,OAAS,EAAK,MAAA,CAAO,YAAY,CAAC,CAAC,SAAS,CAAM,IAC7D,EAAQ,KAAK,CAAI,EACb,EAAW,GAAK,EAAQ,QAAU,GAAU,MAElD,OAAO,CACT,EAAG,CAAC,EAAO,EAAW,CAAQ,CAAC,EAIzB,GAAoB,CAAC,GAAoB,IAAc,IAAM,IAAc,EAAY,EAAY,CAAK,EACxG,EAAY,EAAU,SAAW,GAAK,GAGtC,EAAS,GAAe,CAAC,IAAa,EAAU,OAAS,GAAK,GAI9D,EAAuB,GAAY,UAAU,SAC7C,GAAU,EACb,GAAgC,CAC/B,IAAM,EAAU,EAAW,QACtB,GAAS,GAAW,GAAwB,OACjD,EAAK,MAAM,SAAW,OAAO,iBAAiB,CAAO,CAAC,CAAC,SACzD,EACA,CAAC,CAAoB,CACvB,EAEM,OAAoB,CACpB,IACJ,AAEE,EAAe,WADf,aAAa,EAAe,OAAO,EACV,MAE3B,EAAe,EAAI,EACrB,EAEM,OAAmB,CACnB,EAAM,UAAS,EAAM,QAAQ,QAAU,IAC3C,EAAe,QAAU,eAAiB,CACxC,EAAe,EAAK,EACpB,EAAoB,IAAI,EACxB,EAAe,QAAU,KAGpB,GAAkB,EAAa,EAAY,EAAY,CAAK,CAAC,CACpE,EAAG,GAAG,CACR,EAEA,MAAgB,CACT,GAAU,IAAqB,MACpC,EAAqB,EAAU,QAAS,EAAU,SAAS,SAAS,EAAiB,CACvF,EAAG,CAAC,EAAQ,CAAgB,CAAC,EAE7B,IAAM,EAAe,EAClB,GAA4B,CAC3B,EAAa,EAAK,OAAS,EAAK,KAAK,EACrC,EAAe,EAAK,EACpB,EAAoB,IAAI,EACpB,GAAe,EAAc,IAAI,EAAK,KAAK,EAC3C,GAAU,EAAS,EAAK,KAAK,EACjC,EAAS,EAAK,KAAK,CACrB,EACA,CAAC,EAAe,EAAU,CAAQ,CACpC,EAEM,GAAY,EACf,GAA+B,CAC9B,GAAI,EAAU,OACd,IAAM,EAAU,EAAM,MAAQ,WAAa,EAAM,MAAQ,YAEzD,GAAI,CAAC,EAAQ,CACP,IACF,EAAM,eAAe,EACrB,EAAe,EAAI,GAErB,MACF,CACA,GAAI,EAAS,CACX,EAAM,eAAe,EACrB,IAAM,EAAO,EAAM,MAAQ,YAAc,EAAI,GAC7C,EAAqB,GAKF,IAAQ,MAAQ,KAAS,IAAS,EAAI,EAAU,OAAS,EAAI,GAC5D,KAAO,EAAU,EAAK,EAAM,EAAU,OAAQ,EAAO,CACxE,CACH,MAAW,EAAM,MAAQ,UACnB,IAAqB,MAAQ,EAAU,IAKzC,EAAM,eAAe,EACrB,EAAa,EAAU,EAAiB,GAC/B,IACT,EAAe,EAAK,EACpB,EAAoB,IAAI,GAG9B,EACA,CAAC,EAAW,EAAkB,EAAc,EAAkB,EAAU,CAAM,CAChF,EAEM,GACJ,EAAC,MAAD,CAAK,IAAK,GAAS,UAAW,EAAG,EAAO,SAAU,GAAY,QAAQ,EAAG,MAAO,GAAY,SAA5F,SAAA,CACG,GACC,EAAC,MAAD,CACE,UAAW,EAAG,EAAO,cAAe,GAAY,KAAK,EACrD,MAAO,GAAY,MAEnB,YAAc,GAAM,EAAE,eAAe,EAEpC,SAAA,EAAK,KACH,CAAA,EAEP,EAAC,MAAD,CAAK,IAAK,EAAW,UAAW,EAAO,eACpC,SAAA,EAAU,KAAK,EAAM,IACpB,EAAC,MAAD,CAEE,UAAW,EACT,EAAO,aACP,IAAqB,GAAO,EAAO,wBACnC,GAAY,IACd,EACA,MAAO,GAAY,KAEnB,YAAc,GAAM,EAAE,eAAe,EACrC,YAAe,EAAa,CAAI,EAE/B,SAAA,EAAK,OAAS,EAAK,KACjB,EAZE,GAAG,EAAI,GAAG,EAAK,MAAM,GAAG,EAAK,OAY/B,CACN,CACE,CAAA,CACF,IAGP,OACE,EAAC,MAAD,CACE,GAAI,GACJ,IAAK,EACL,UAAW,EAAG,EAAO,cAAe,GAAY,KAAM,CAAS,EAC/D,MAAO,CAAE,GAAG,GAAY,KAAM,GAAG,CAAM,EACvC,QAAS,GACT,OAAQ,GACG,aAPb,SAAA,CASE,EAAC,GAAD,CAIU,SACR,aAAe,GAAS,CAClB,IACJ,EAAe,EAAK,EACpB,EAAoB,IAAI,EAC1B,EAGA,aAAc,EAAa,QAC3B,UAAW,EAAiB,OAC5B,MAAO,EAAa,MACpB,OAAQ,EACR,UAAW,GACX,eAAgB,GAChB,oBAAqB,GACrB,YAAa,GACb,WAAY,CAAE,MAAO,EAAO,kBAAmB,EAC/C,QAAS,GAET,SAAA,EAAC,EAAD,CAGE,aAAa,MACN,SACP,MAAO,EACG,WACV,cAAe,KACf,WAAY,EACZ,QAAS,EAAgB,CAAC,EAAc,gBAAkB,CAAC,EAC3D,aACE,EACI,EAAc,gBACX,EAAc,uBAAuB,GAAK,GAC3C,GACD,GAAS,GAEhB,SAAW,GAAU,CAInB,GAHA,EAAa,CAAK,EAClB,EAAoB,IAAI,EACxB,EAAe,EAAI,EACf,EACE,GAAe,EAAc,IAAI,CAAK,EACtC,GAAU,EAAS,CAAK,OACvB,GAAI,IAAU,GACf,GAAe,EAAc,IAAI,EAAE,EACnC,GAAU,EAAS,EAAE,EACzB,EAAS,EAAE,MACN,CAEL,IAAM,EAAQ,EAAM,KAAM,IAAU,EAAK,OAAS,EAAK,MAAA,CAAO,YAAY,IAAM,EAAM,YAAY,CAAC,EAC/F,IACE,GAAe,EAAc,IAAI,EAAM,KAAK,EAC5C,GAAU,EAAS,EAAM,KAAK,EAClC,EAAS,EAAM,KAAK,EAExB,CACF,EACa,cACd,CAAA,CACM,CAAA,EAGT,EAAC,OAAD,CAAM,UAAW,EAAO,oBAAqB,KAAK,SAAS,YAAU,SAClE,SAAA,GAAU,EAAY,EAAK,MAAQ,EAChC,CAAA,CACH,GAET"}