Skip to main content

createSelectableCollectionNavigation

Add accessible keyboard navigation (arrow keys, Home/End, RTL) for tabs, radio groups, and listboxes.

View as Markdown

createSelectableCollectionNavigation provides keyboard navigation (Arrow Up/Down/Left/Right, Home, End) and activation modes for selectable item collections like Tabs, Radio Groups, and Listboxes.

Import#

import { createSelectableCollectionNavigation } from 'moraine/utils'

Signature#

function createSelectableCollectionNavigation<TItem, TValue extends string | number>(
  options: CreateSelectableCollectionNavigationOptions<TItem, TValue>,
): {
  onNavigationKeyDown: (
    event: KeyboardEvent,
    currentValue: TValue | undefined,
    orientation: 'horizontal' | 'vertical',
  ) => void
  focusByOffset: (currentValue: TValue | undefined, offset: number) => void
  focusBoundary: (kind: 'first' | 'last') => void
  moveSelection: (currentValue: TValue, offset: number) => void
}

interface CreateSelectableCollectionNavigationOptions<TItem, TValue> {
  items: Accessor<TItem[]>
  getValue: (item: TItem) => TValue
  onSelect: (value: TValue) => void
  isDisabled?: (item: TItem) => boolean
  loop?: Accessor<boolean>
  activationMode?: Accessor<'automatic' | 'manual'>
  focusValue?: (value: TValue) => void
  getDirection?: () => 'ltr' | 'rtl'
}

Usage Example#

src/CustomTabs.tsx
import { For, createSignal } from 'solid-js'
import { createSelectableCollectionNavigation } from 'moraine/utils'

interface TabItem {
  id: string
  label: string
}

export function CustomTabs() {
  const tabs: TabItem[] = [
    { id: 'overview', label: 'Overview' },
    { id: 'settings', label: 'Settings' },
    { id: 'billing', label: 'Billing' },
  ]
  const [selectedId, setSelectedId] = createSignal('overview')
  const tabRefs = new Map<string, HTMLButtonElement>()

  const nav = createSelectableCollectionNavigation({
    items: () => tabs,
    getValue: (item) => item.id,
    onSelect: (id) => setSelectedId(id),
    focusValue: (id) => tabRefs.get(id)?.focus(),
    loop: () => true,
  })

  return (
    <div role="tablist" aria-label="Project sections" class="flex gap-2 border-b">
      <For each={tabs}>
        {(tab) => (
          <button
            ref={(element) => tabRefs.set(tab.id, element)}
            role="tab"
            aria-selected={selectedId() === tab.id}
            tabIndex={selectedId() === tab.id ? 0 : -1}
            onClick={() => setSelectedId(tab.id)}
            onKeyDown={(event) => nav.onNavigationKeyDown(event, tab.id, 'horizontal')}
            class="px-3 py-1.5 border-b-2"
          >
            {tab.label}
          </button>
        )}
      </For>
    </div>
  )
}

Key Bindings#

  • ArrowLeft / ArrowRight: Move previous / next in horizontal collections (respects RTL).
  • ArrowUp / ArrowDown: Move previous / next in vertical collections.
  • Home / End: Jump directly to first / last enabled item.
  • Automatic vs Manual: In automatic mode, navigation selects immediately. In manual mode, pressing Enter or Space confirms selection.