Primitives / Interaction
Start with one: a toolbar with a roving tab stop
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.