{"version":3,"file":"useRowNavigation.mjs","names":[],"sources":["../../../../package/common/virtualList/useRowNavigation.ts"],"sourcesContent":["import React, { useLayoutEffect, useRef, useState } from 'react';\n\nimport { findStartIndex, ROW_ENTER_SELECTOR, ROW_FOCUSABLE_SELECTOR } from './virtualList.utils';\nimport type { VirtualListScrollOptions } from './virtualList.types';\nimport type { VisibleSlice } from './useVirtualWindow';\n\ninterface UseRowNavigationArgs {\n  enabled: boolean;\n  tabAcrossRows: boolean;\n  rowCount: number;\n  /** Row tops, from the windowing engine, used for paging and visibility. */\n  offsets: number[];\n  rootRef: React.RefObject<HTMLDivElement | null>;\n  headerRef: React.RefObject<HTMLDivElement | null>;\n  /** Read live: a held arrow key moves the slice faster than the state commits. */\n  getVisibleSlice: () => VisibleSlice;\n  /** The whole scrollport, which is what one PageDown covers. */\n  viewportHeight: number;\n  scrollToIndex: (index: number, options?: VirtualListScrollOptions) => void;\n  /** Row index the active row starts on, normally the selected one. */\n  initialIndex: number;\n  multiSelect: boolean;\n  /** Undefined when the consumer gave the rows no activation of their own. */\n  onActivate?: (index: number) => void;\n  onToggleSelect: (index: number) => void;\n}\n\ninterface UseRowNavigationResult {\n  /** Index of the row the arrow keys are on, or -1 when navigation is off. */\n  activeIndex: number;\n  /** Keeps the arrow keys where the pointer last was. */\n  setActiveIndex: (index: number) => void;\n  /**\n   * The row currently being used, or -1. Its controls are in the tab order and\n   * it stays mounted even when it scrolls out of the window.\n   */\n  enteredIndex: number;\n  containerProps: {\n    tabIndex?: number;\n    onKeyDown?: (event: React.KeyboardEvent) => void;\n    onFocus?: (event: React.FocusEvent) => void;\n    onBlur?: (event: React.FocusEvent) => void;\n  };\n}\n\n/**\n * Arrow-key navigation over the rows, with the tab stop on the list itself.\n *\n * The stop cannot sit on the active row, the way a roving tabindex normally\n * would: a virtualized row unmounts when it scrolls out of the window, and the\n * list would be left with no tab stop at all (the same reason `Tree` puts its\n * stop on its container). Instead the list keeps the focus and points at the\n * active row with `aria-activedescendant`.\n *\n * Keys are only handled when the list element itself has the focus. A cell may\n * hold an input, a dropdown or a button, and every one of them wants the arrows,\n * Enter and Space for itself. `F2` steps into such a row and `Escape` steps back\n * out, which is the one pair of keys that does work from inside a cell.\n *\n * `tabAcrossRows` adds the second pair, Tab and Shift+Tab, which the browser\n * cannot carry across a row boundary itself: the next row may not be in the DOM\n * when it looks for the next stop.\n */\nexport function useRowNavigation({\n  enabled,\n  tabAcrossRows,\n  rowCount,\n  offsets,\n  rootRef,\n  headerRef,\n  getVisibleSlice,\n  viewportHeight,\n  scrollToIndex,\n  initialIndex,\n  multiSelect,\n  onActivate,\n  onToggleSelect,\n}: UseRowNavigationArgs): UseRowNavigationResult {\n  const [index, setIndex] = useState(initialIndex);\n  const [enteredIndex, setEnteredIndex] = useState(-1);\n  const offsetsRef = useRef(offsets);\n  offsetsRef.current = offsets;\n\n  // Rows come and go (a filter, a page, a reset), so the active index is clamped\n  // during render rather than corrected in an effect.\n  const activeIndex = enabled && rowCount > 0 ? Math.min(Math.max(index, 0), rowCount - 1) : -1;\n  if (activeIndex >= 0 && activeIndex !== index) setIndex(activeIndex);\n  const navigable = activeIndex >= 0;\n\n  /** Scrolls only when the row isn't already fully in view, so paging doesn't jump. */\n  function revealRow(target: number) {\n    const headerHeight = headerRef.current?.offsetHeight ?? 0;\n    const rowTop = offsetsRef.current[target] ?? 0;\n    const rowBottom = offsetsRef.current[target + 1] ?? rowTop;\n    const { top, height } = getVisibleSlice();\n    if (rowTop < top + headerHeight) scrollToIndex(target, { align: 'start' });\n    else if (rowBottom > top + height) scrollToIndex(target, { align: 'end' });\n  }\n\n  function moveTo(target: number) {\n    const next = Math.max(0, Math.min(target, rowCount - 1));\n    setIndex(next);\n    revealRow(next);\n  }\n\n  /** How many rows fit in the viewport below the sticky header, at least one. */\n  function pageSize(from: number): number {\n    const usable = Math.max(0, viewportHeight - (headerRef.current?.offsetHeight ?? 0));\n    const edge = findStartIndex(offsetsRef.current, (offsetsRef.current[from] ?? 0) + usable, rowCount);\n    return Math.max(1, edge - from);\n  }\n\n  function rowElement(target: number): HTMLElement | null {\n    return rootRef.current?.querySelector<HTMLElement>(`[data-index=\"${target}\"]`) ?? null;\n  }\n\n  /** Moves the focus into the row's first control. False when it has none. */\n  function enterRow(target: number): boolean {\n    const focusable = rowElement(target)?.querySelector<HTMLElement>(ROW_ENTER_SELECTOR);\n    if (!focusable) return false;\n    // The row's controls are still out of the tab order at this point, which is\n    // no obstacle to focusing one directly; the render that follows puts them\n    // back in, so Tab then moves along that row.\n    setEnteredIndex(target);\n    focusable.focus();\n    return true;\n  }\n\n  // A row reached by Tab may not be in the DOM yet: entering it is what pins it\n  // there, and that only takes effect on the next commit.\n  const pendingFocusRef = useRef<{ index: number; edge: 'first' | 'last' } | null>(null);\n  // Set when Tab ran out of rows to land in, so the next one is left to the browser.\n  const releaseTabRef = useRef(false);\n  // The chain of updates below is bounded: each pass clears the ref and moves one\n  // row closer to an end of the list.\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useLayoutEffect(() => {\n    const pending = pendingFocusRef.current;\n    if (!pending) return;\n    // Cleared whether or not the row was found: the commit that set this renders\n    // it, so a second attempt at the same row would be a later commit stealing\n    // the focus.\n    pendingFocusRef.current = null;\n    const focusables = rowElement(pending.index)?.querySelectorAll<HTMLElement>(ROW_ENTER_SELECTOR);\n    if (focusables?.length) {\n      (pending.edge === 'first' ? focusables[0] : focusables[focusables.length - 1]).focus();\n      return;\n    }\n    // The Tab that got here was already prevented, so a row with nothing to focus\n    // must not swallow it.\n    const next = pending.index + (pending.edge === 'first' ? 1 : -1);\n    if (next >= 0 && next < rowCount) {\n      pendingFocusRef.current = { index: next, edge: pending.edge };\n      setEnteredIndex(next);\n      revealRow(next);\n      return;\n    }\n    setEnteredIndex(-1);\n    // Backwards needs no release: Shift+Tab off the list's own stop already leaves.\n    releaseTabRef.current = navigable && pending.edge === 'first';\n    if (navigable) rootRef.current?.focus();\n  });\n\n  function rowIndexOf(node: EventTarget | null): number {\n    const row = (node as HTMLElement | null)?.closest?.<HTMLElement>('[data-index]');\n    const parsed = Number(row?.dataset.index);\n    return Number.isFinite(parsed) ? parsed : -1;\n  }\n\n  /** A click or a Tab landing in a row counts as entering it, same as F2. */\n  function handleFocus(event: React.FocusEvent) {\n    if (event.target === event.currentTarget) return;\n    const rowIndex = rowIndexOf(event.target);\n    if (rowIndex < 0) return;\n    setEnteredIndex(rowIndex);\n    setIndex(rowIndex);\n  }\n\n  /** Leaving the entered row puts its controls back out of the tab order. */\n  function handleBlur(event: React.FocusEvent) {\n    // A release only holds while the list keeps the focus. `contains` counts the\n    // list itself, which is where the release puts the focus in the first place.\n    if (!event.currentTarget.contains(event.relatedTarget)) releaseTabRef.current = false;\n    if (rowIndexOf(event.relatedTarget) === enteredIndex) return;\n    setEnteredIndex(-1);\n  }\n\n  /** Escape from inside a cell: hands the focus back to the list. */\n  function handleExitKey(event: React.KeyboardEvent): void {\n    // Something inside the row may own Escape, a dropdown closing for instance.\n    if (event.key !== 'Escape' || event.defaultPrevented || !navigable) return;\n    const rowIndex = rowIndexOf(event.target);\n    if (rowIndex >= 0) setIndex(rowIndex);\n    setEnteredIndex(-1);\n    rootRef.current?.focus();\n    event.preventDefault();\n  }\n\n  /**\n   * Only the row's last control (first, going back) is taken over: everywhere\n   * else the browser's order is already right, and at the ends of the list Tab\n   * is left alone so it can leave.\n   */\n  function handleTabKey(event: React.KeyboardEvent): void {\n    if (!tabAcrossRows || event.key !== 'Tab' || event.defaultPrevented) return;\n    if (event.altKey || event.ctrlKey || event.metaKey) return;\n    const rowIndex = rowIndexOf(event.target);\n    if (rowIndex < 0) return;\n    const focusables = rowElement(rowIndex)?.querySelectorAll<HTMLElement>(ROW_FOCUSABLE_SELECTOR);\n    if (!focusables?.length) return;\n    const at = Array.prototype.indexOf.call(focusables, event.target);\n    if (at < 0) return;\n    const back = event.shiftKey;\n    if (back ? at > 0 : at < focusables.length - 1) return;\n    const target = back ? rowIndex - 1 : rowIndex + 1;\n    if (target < 0 || target >= rowCount) return;\n    event.preventDefault();\n    // The active row follows the focus, through `handleFocus`, so that it stays\n    // where the user is when the focus never lands.\n    setEnteredIndex(target);\n    revealRow(target);\n    pendingFocusRef.current = { index: target, edge: back ? 'last' : 'first' };\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent) {\n    // Focus inside a cell means the row's own content is being used.\n    if (event.target !== event.currentTarget) {\n      handleTabKey(event);\n      handleExitKey(event);\n      return;\n    }\n    // The consumer's own handler ran first and may have taken the key, which is\n    // what `handleTabKey` and `handleExitKey` already check for.\n    if (!navigable || event.defaultPrevented) return;\n    const releasing = releaseTabRef.current;\n    releaseTabRef.current = false;\n    switch (event.key) {\n      case 'ArrowDown':\n        moveTo(activeIndex + 1);\n        break;\n      case 'ArrowUp':\n        moveTo(activeIndex - 1);\n        break;\n      case 'Home':\n        moveTo(0);\n        break;\n      case 'End':\n        moveTo(rowCount - 1);\n        break;\n      case 'PageDown':\n        moveTo(activeIndex + pageSize(activeIndex));\n        break;\n      case 'PageUp':\n        moveTo(activeIndex - pageSize(Math.max(0, activeIndex - 1)));\n        break;\n      case 'Enter':\n        // With no activation of its own, a row's content is the only thing\n        // Enter could mean, so it steps in rather than doing nothing.\n        if (onActivate) onActivate(activeIndex);\n        else if (!enterRow(activeIndex)) return;\n        break;\n      case 'F2':\n        if (!enterRow(activeIndex)) return;\n        break;\n      case 'Tab':\n        // The list's own tab stop is in front of rows whose controls are all\n        // parked, so without this Tab would step over every one of them. Which is\n        // the point once `releasing` says Tab has run out of rows to land in.\n        if (!tabAcrossRows || event.shiftKey || releasing || !enterRow(activeIndex)) return;\n        break;\n      case ' ':\n        if (!multiSelect) return;\n        onToggleSelect(activeIndex);\n        break;\n      default:\n        return;\n    }\n    // Reached only by a handled key, so the page never scrolls under the list.\n    event.preventDefault();\n  }\n\n  if (!navigable && !tabAcrossRows)\n    return { activeIndex, enteredIndex: -1, setActiveIndex: setIndex, containerProps: {} };\n  const containerProps: UseRowNavigationResult['containerProps'] = {\n    onKeyDown: handleKeyDown,\n    onFocus: handleFocus,\n    onBlur: handleBlur,\n  };\n  // Without arrow navigation the list is not a composite widget and must not take\n  // a stop of its own. The key is left out rather than set to `undefined`, which\n  // would erase a `tabIndex` the consumer passed.\n  if (navigable) containerProps.tabIndex = 0;\n  return { activeIndex, enteredIndex, setActiveIndex: setIndex, containerProps };\n}\n"],"mappings":"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"}