Primitives / Interaction

Start with one: a toolbar with a roving tab stop

ts
import { Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import { Bundle } from 'foldkit-bundle'
import { Behavior, Behaviors, Capability, Slot, Slots, SlotView } from 'foldkit-mixins'
import { RovingTabindex } from 'foldkit-primitives/interaction'

const Tool = Schema.Struct({ id: Schema.String, label: Schema.String, disabled: Schema.Boolean })
type Tool = typeof Tool.Type

const Roving = Bundle.declare(RovingTabindex.bundle, 'toolbarFocus')
const Model = Schema.Struct({ ...Roving.fields, tools: Schema.Array(Tool) })
type Model = typeof Model.Type
const Message = defineMessageUnion({ ...Roving.cases })
type Message = typeof Message.Type
const Page = Bundle.parent({ Model, Message })
const args = { orientation: 'horizontal', loop: true, virtual: false } as const
const placements = Page.assemble(Page.at(Roving, { args }))

const ToolbarSlots = Slots.define({
  root: Slot.make({ capability: Capability.Container }),
  tool: Slot.make({ capability: Capability.Focusable }),
})
const describeTools = (tools: ReadonlyArray<Tool>) =>
  Behaviors.Collection.of(tools, { id: tool => tool.id, disabled: tool => tool.disabled })

// Collection writes the ids on each item; RovingTabindex focuses by them.
const Ids = Behaviors.Collection.behavior(ToolbarSlots)<Model, Message>({
  item: 'tool',
  items: model => describeTools(model.tools),
})
const Focus = RovingTabindex.behavior(Roving, args)(ToolbarSlots)<Model, Message>({
  container: 'root',
  item: 'tool',
  items: model => describeTools(model.tools),
})

const Toolbar = SlotView.forMessages<Message>()
  .define(ToolbarSlots, (model: Model, slots, h) => {
    const items = describeTools(model.tools)
    return h.div(
      slots.root.attrs([h.Role('toolbar')]),
      model.tools.map((tool, index) =>
        h.button(slots.tool.attrs([h.Key(tool.id)], items.slotItem(index)), [tool.label]),
      ),
    )
  })
  .pipe(Behavior.attach(Ids), Behavior.attach(Focus))

The Bundle's Model slice is { current }, the current item's id, so a reorder keeps the same item current and a resumed page knows where focus was. The placement is written in foldkit-bundle's explicit form here because the Behavior takes the declaration (Roving) and the same args.

What each half of the Behavior does: the container gets OnKeyDownFocus, which on an arrow, Home, or End focuses the next enabled item synchronously by its id, prevents the default, and dispatches Focused { id }. Each item gets tabindex 0 when it is the tab stop and -1 otherwise, and OnFocus reporting Focused, so a click makes an item current too. Before anything is current, or when the current item is gone or disabled, the first enabled item is the tab stop. Keys with ctrl, alt, or meta held are left alone; direction: model => 'rtl' swaps left and right. Nothing is written on dispose: the attributes are data, so a view that no longer attaches the Behavior leaves no tabindex behind.

Args: orientation ('vertical' | 'horizontal' | 'both'), loop, and virtual. Under virtual the items get no tabindex, the container gets aria-activedescendant, and a key keeps DOM focus where it is and only moves the pointer. The pure move(enabled, current, key, modifiers, options) and tabStop(items, current) are exported. PageUp and PageDown are handled when move is given a page, which ListNavigation does.