{"version":3,"file":"listNavigation.mjs","names":[],"sources":["../../../../package/common/utils/listNavigation.ts"],"sourcesContent":["/**\n * Where the highlight goes next in a list the arrow keys move through.\n *\n * Shared so the library's listboxes agree on it. They did not: Dropdown wrapped and skipped\n * disabled options, MultiSelect wrapped but stopped on them, and InputDropdown clamped at both\n * ends and cleared the highlight when ArrowUp reached the top. Moving between two fields in the\n * same form behaved differently for no reason a user could see.\n *\n * Only the arithmetic is shared. Which key means what, and what selecting does, stays with each\n * component, because those genuinely differ.\n */\n\n/** `null` is \"nothing highlighted\": the state a freshly opened list starts in. */\nexport type ActiveIndex = number | null;\n\nexport interface ListNavigationOptions {\n  /** Options that cannot be highlighted, by index. */\n  isDisabled?: (index: number) => boolean;\n  /**\n   * Whether moving past an end comes back round. `false` stops on the last selectable option,\n   * for a list whose first entry is the text the user is still typing.\n   */\n  wrap?: boolean;\n}\n\n/**\n * The next selectable index in `step` direction, or `null` when the list has nothing to\n * highlight. From `null` a forward step enters at the top and a backward step at the bottom,\n * which is what both a native `<select>` and the ARIA combobox pattern do.\n */\nexport function nextIndex(\n  from: ActiveIndex,\n  step: 1 | -1,\n  length: number,\n  { isDisabled, wrap = true }: ListNavigationOptions = {},\n): ActiveIndex {\n  if (length === 0) return null;\n  const selectable = (index: number) => !isDisabled?.(index);\n\n  // Entering the list counts as the first hop, so start one step outside it.\n  const start = from ?? (step === 1 ? -1 : length);\n\n  for (let hop = 1; hop <= length; hop++) {\n    const raw = start + step * hop;\n    if (!wrap && (raw < 0 || raw >= length)) break;\n    // `+ length * hop` keeps the operand of `%` positive without a branch; `hop` is enough\n    // because a single step never moves further than one length.\n    const index = ((raw % length) + length) % length;\n    if (selectable(index)) return index;\n  }\n\n  // Nothing selectable that way. Staying put beats jumping somewhere arbitrary, but a `from`\n  // that is itself disabled must not be reported as highlightable.\n  return from !== null && selectable(from) ? from : null;\n}\n\n/** The first or last selectable index, for Home and End. */\nexport function edgeIndex(edge: 'first' | 'last', length: number, options: ListNavigationOptions = {}): ActiveIndex {\n  return edge === 'first'\n    ? nextIndex(null, 1, length, { ...options, wrap: false })\n    : nextIndex(null, -1, length, { ...options, wrap: false });\n}\n\n/**\n * Scrolls `option` into view inside `list`, moving nothing else on the page.\n *\n * The arrow keys move a highlight, not the focus — the focus stays on the combobox, because that\n * is what `aria-activedescendant` points from. So the browser never scrolls the option into view\n * by itself, and a list taller than its panel would highlight rows the user cannot see.\n *\n * Measured with `getBoundingClientRect` rather than `offsetTop`, which is relative to the nearest\n * positioned ancestor and so silently wrong whenever that is not the scrolling element itself.\n * `scrollIntoView` would be shorter but is free to scroll every ancestor, including the page\n * behind the panel.\n *\n * Rectangles are what the screen shows, and `scrollTop` is in layout pixels, so the two disagree\n * whenever an ancestor carries a scale — which every one of these lists does, because the Popover\n * panel holding them animates in from a smaller size. Measured mid-animation, a list 260 layout\n * pixels tall reports a rectangle of 182, and scrolling by the raw difference moved 30% short\n * every step. `offsetHeight` against the rectangle's height recovers the factor.\n */\nexport function scrollOptionIntoView(list: HTMLElement | null | undefined, option: Element | null | undefined): void {\n  if (!list || !option) return;\n  const listBox = list.getBoundingClientRect();\n  const optionBox = option.getBoundingClientRect();\n  const toLayoutPixels = listBox.height > 0 ? list.offsetHeight / listBox.height : 1;\n  const above = optionBox.top - listBox.top;\n  const below = optionBox.bottom - listBox.bottom;\n  if (above < 0) list.scrollTop += above * toLayoutPixels;\n  else if (below > 0) list.scrollTop += below * toLayoutPixels;\n}\n"],"mappings":"aA8BA,SAAgB,EACd,EACA,EACA,EACA,CAAE,aAAY,OAAO,IAAgC,CAAC,EACzC,CACb,GAAI,IAAW,EAAG,OAAO,KACzB,IAAM,EAAc,GAAkB,CAAC,IAAa,CAAK,EAGnD,EAAQ,IAAS,IAAS,EAAI,GAAK,GAEzC,IAAK,IAAI,EAAM,EAAG,GAAO,EAAQ,IAAO,CACtC,IAAM,EAAM,EAAQ,EAAO,EAC3B,GAAI,CAAC,IAAS,EAAM,GAAK,GAAO,GAAS,MAGzC,IAAM,GAAU,EAAM,EAAU,GAAU,EAC1C,GAAI,EAAW,CAAK,EAAG,OAAO,CAChC,CAIA,OAAO,IAAS,MAAQ,EAAW,CAAI,EAAI,EAAO,IACpD,CAGA,SAAgB,EAAU,EAAwB,EAAgB,EAAiC,CAAC,EAAgB,CAClH,OAAO,IAAS,QACZ,EAAU,KAAM,EAAG,EAAQ,CAAE,GAAG,EAAS,KAAM,EAAM,CAAC,EACtD,EAAU,KAAM,GAAI,EAAQ,CAAE,GAAG,EAAS,KAAM,EAAM,CAAC,CAC7D,CAoBA,SAAgB,EAAqB,EAAsC,EAA0C,CACnH,GAAI,CAAC,GAAQ,CAAC,EAAQ,OACtB,IAAM,EAAU,EAAK,sBAAsB,EACrC,EAAY,EAAO,sBAAsB,EACzC,EAAiB,EAAQ,OAAS,EAAI,EAAK,aAAe,EAAQ,OAAS,EAC3E,EAAQ,EAAU,IAAM,EAAQ,IAChC,EAAQ,EAAU,OAAS,EAAQ,OACrC,EAAQ,EAAG,EAAK,WAAa,EAAQ,EAChC,EAAQ,IAAG,EAAK,WAAa,EAAQ,EAChD"}