All files / src/ui tabWalk.ts

100% Statements 25/25
90% Branches 18/20
100% Functions 4/4
100% Lines 25/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77                                          2x     62x 62x 62x           2x                             2x 12x 12x 6x 6x 6x 6x     6x 6x 6x 6x 4x 4x 4x 4x               2x 12x 12x 12x 12x  
/**
 * Tab between the fields of one kind (§26.8): a track's name to the next
 * track's, one end of a range to the other and on to the next range, a map's
 * name to the next map's.
 *
 * A field edited in place — a caret laid over a lane's name, a colour key's
 * number, a heading that reads as text until it is clicked — is one of a run
 * of fields of its kind, and the run is what a reader working through a figure
 * is going down: naming four replicates, setting six tracks to one ceiling.
 * The browser's own Tab knows nothing of that run. It takes the next focusable
 * thing in the document, which beside a field laid over a canvas is a button
 * the reader never meant, and in a field the next frame repaints is nothing at
 * all. So Tab here takes what was typed, as Enter does, and puts the caret in
 * the next field of the same kind — Shift+Tab the one before — and past either
 * end of the run lets the caret go, as Enter does: Tab is Enter that goes on.
 *
 * A value a field refuses keeps the caret where it is, as Enter does, so
 * nothing moves on from a number that was not taken.
 */
 
/** Which way a key walks the fields of a run, or `null` for any other key. */
export function tabStep(event: KeyboardEvent): 1 | -1 | null {
  // Not with a modifier, which is the browser's or the system's — Ctrl+Tab
  // changes the browser's tab — nor mid-composition, where Tab is the IME's.
  if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey || event.isComposing) return null
  return event.shiftKey ? -1 : 1
}
 
/**
 * The marker a field carries when it walks Tab itself, read by the dialog's
 * focus trap (`ui/Modal.ts`) so the two do not both answer one key.
 */
export const TAB_WALKS = 'data-tab-walk'
 
/**
 * Tab in one of a run of fields that are always on the page — the names on a
 * card, the headings over a deck's cells: take what was typed, then put the
 * caret in the field `step` along the run, or let it go past either end.
 *
 * The run is every field of `selector` in the nearest `scope` round the field
 * — one deck's headings, one card's names — found once, before the commit,
 * and asked for its fields again after it, by position: a commit may rebuild
 * the rows the field is in — a rename repaints a card's list — and both the
 * field and the next one taken before it would be fields no longer on the
 * page. The scope itself stays. `true` where the key was a Tab and has been
 * answered.
 */
export function tabAmong(event: KeyboardEvent, field: HTMLInputElement, scope: string, selector: string): boolean {
  const step = tabStep(event)
  if (step === null) return false
  event.preventDefault()
  const root = field.closest(scope)
  const run = (): HTMLInputElement[] => (root === null ? [field] : editableIn(root, selector))
  const at = run().indexOf(field)
  // Through the field's own commit: these take what was typed on `change`,
  // which a blur fires for a value that changed and only then.
  field.blur()
  if (at < 0) return true
  const next = run()[at + step]
  if (next === undefined) return true
  next.focus()
  next.select()
  return true
}
 
/**
 * The fields of a run as the page holds them now: in document order, and only
 * the ones a reader could type into — on the page, not hidden, enabled and not
 * read-only. Hidden as this app hides things, by a `hidden` class or attribute
 * on the field or round it, which needs no layout to answer.
 */
export function editableIn(scope: ParentNode, selector: string): HTMLInputElement[] {
  return [...scope.querySelectorAll<HTMLInputElement>(selector)].filter(
    (field) => field.isConnected && !field.disabled && !field.readOnly && field.closest('.hidden, [hidden]') === null,
  )
}