Utilities
Rokkit's interactivity is built on two composable primitives:
controllers (pure state machines with no DOM dependency) and
the navigator Svelte action (which binds a controller to
the DOM and adds keyboard + ARIA handling).
This split is what lets you test interaction logic without a browser and build your own accessible components by reusing the same controllers Rokkit uses internally.
Controllers
A controller owns the state of an interactive widget:
- focused / selected item.
- expanded set (for trees, accordions).
- navigation methods (
moveNext,movePrevious,moveTo,moveFirst,moveLast,expand,collapse,select). - events (
onmove,onselect,onexpand).
import { ListController } from '@rokkit/states'
const c = new ListController(items)
c.moveFirst() // focus first
c.moveNext() // focus next
c.select() // select focused, fire onselect
c.focusedKey // 'k_0'
c.value // the selected items' raw value
NestedController adds expand / collapse semantics for trees,
with tree-style behaviour built in: expand() on an
already-expanded group focuses the first child;
collapse() on a child focuses the parent.
The navigator action
use:navigator={controller} on any container element:
- adds
keydownhandlers for arrows / Home / End / Enter / Space / Escape / typeahead. - attaches
clickhandlers that readdata-pathfrom the target's ancestry. - writes
aria-activedescendant/aria-selected/aria-expandedto the relevant elements. - emits
moveandselectevents you can hook into.
The only contract on the markup side: each interactive row
carries data-path={node.key}.
Building your own component
<script>
import { ListController } from '@rokkit/states'
import { navigator } from '@rokkit/actions'
let { items, value = $bindable() } = $props()
const c = new ListController(items)
c.moveToValue(value)
function handleSelect(v) { value = v }
</script>
<ul use:navigator={c} role="listbox" tabindex="0">
{#each c.flatView as node (node.key)}
<li
data-path={node.key}
data-active={node.proxy.value === value || undefined}
role="option"
aria-selected={node.proxy.value === value}
>
{node.proxy.label}
</li>
{/each}
</ul>
That's a full keyboard-navigable, ARIA-correct list — built on
the same primitives <List> uses internally.
ProxyItem and ProxyTree
Controllers don't read your raw rows directly. They wrap each
item in a ProxyItem that exposes label, value,
get(field), disabled, children — all resolving through
the fields mapping. Snippets receive these proxies, not your
raw data.
import { ProxyItem } from '@rokkit/states'
const proxy = new ProxyItem({ name: 'Alice' }, { label: 'name' })
proxy.label // 'Alice'
proxy.get('name') // 'Alice'
ProxyTree does the same recursively for hierarchical data.
Icons
@rokkit/icons ships a curated UnoCSS icon collection plus the
DEFAULT_STATE_ICONS map components use internally for state
(opened folder, dismiss x, copy / saved, etc.). Override per
component via the icons prop, or globally via the unocss
config to swap your own collection in.