{"protocol":"clank-doc/1","frameworkVersion":"0.8.0","slug":"ui","title":"Headless UI behavior","description":"Clank provides small state machines for behavior that is deceptively difficult to implement correctly. They return ordinary HTML props and directives rather than styled components, so Tailwind and application markup remain fully under your ","group":{"id":"framework","title":"Framework"},"url":"https://docs.clank.run/docs/ui","source":"docs/ui.md","headings":["Disclosure","Modal dialog","Tabs","Pagination","Directives","Accessibility rules"],"tableOfContents":[{"id":"disclosure","title":"Disclosure","level":2},{"id":"modal-dialog","title":"Modal dialog","level":2},{"id":"tabs","title":"Tabs","level":2},{"id":"pagination","title":"Pagination","level":2},{"id":"directives","title":"Directives","level":2},{"id":"accessibility-rules","title":"Accessibility rules","level":2}],"markdown":"# Headless UI behavior\n\nClank provides small state machines for behavior that is deceptively difficult to implement correctly. They return ordinary HTML props and directives rather than styled components, so Tailwind and application markup remain fully under your control.\n\n## Disclosure\n\nUse one disclosure for an accordion item, filter panel, navigation drawer, or expandable summary:\n\n```tsx\nconst filters = createDisclosure({\n  id: \"product-filters\",\n  initialOpen: false,\n});\n\n<button {...filters.trigger()}>Filters</button>\n<section {...filters.panel({ role: \"region\" })}>\n  Filter controls\n</section>\n```\n\nThe trigger receives `aria-controls`, a reactive boolean `aria-expanded`, native button type, and disabled state. The panel receives a deterministic ID, label relationship, and reactive `hidden`.\n\nMethods are `show()`, `hide()`, and `toggle()`.\n\n## Modal dialog\n\n```tsx\nconst invite = createDialog({ id: \"invite-dialog\" });\n\n<button {...invite.trigger()}>Invite teammate</button>\n<div {...invite.backdrop()} class=\"fixed inset-0 bg-black/40\" />\n<section {...invite.dialog()} class=\"fixed rounded-2xl bg-white\">\n  <h2 {...invite.title()}>Invite teammate</h2>\n  <p {...invite.description()}>Send a secure workspace invitation.</p>\n  <button onClick={invite.hide}>Close</button>\n</section>\n```\n\nThe dialog behavior includes:\n\n- `role=\"dialog\"` and `aria-modal=\"true\"`;\n- deterministic title and description relationships;\n- initial focus on the first usable control;\n- Tab and Shift+Tab focus wrapping;\n- Escape dismissal;\n- optional backdrop dismissal;\n- body scroll locking;\n- focus restoration to the opening control.\n\nConfigure `closeOnEscape`, `closeOnBackdrop`, `restoreFocus`, and `lockScroll` when a workflow needs different behavior.\n\nThe dialog does not impose a portal. Place its markup where it is structurally understandable and use CSS positioning when it should visually escape the page flow.\n\n## Tabs\n\n```tsx\nconst settings = createTabs({\n  id: \"settings\",\n  tabs: [\n    { value: \"profile\" },\n    { value: \"billing\" },\n    { value: \"danger\", disabled: true },\n  ] as const,\n});\n\n<nav {...settings.list()}>\n  <button {...settings.tab(\"profile\")}>Profile</button>\n  <button {...settings.tab(\"billing\")}>Billing</button>\n</nav>\n\n<section {...settings.panel(\"profile\")}>…</section>\n<section {...settings.panel(\"billing\")}>…</section>\n```\n\nTabs provide roles, selection state, roving tab index, panel relationships, disabled state, and keyboard navigation:\n\n- horizontal: Left/Right;\n- vertical: Up/Down;\n- Home/End;\n- Enter/Space in manual activation mode.\n\nTab values are inferred by TypeScript and must produce unique DOM-safe IDs.\n\n## Pagination\n\n```tsx\nconst page = createPagination({\n  total: computed(() => filteredRows.value.length),\n  pageSize: 20,\n  siblingCount: 1,\n});\n\nconst visible = computed(() =>\n  filteredRows.value.slice(page.start.value - 1, page.end.value)\n);\n```\n\nThe controller exposes `page`, `pageSize`, `total`, `pageCount`, `start`, `end`, `canPrevious`, `canNext`, and a compact `pages` array containing page numbers and `\"ellipsis\"`.\n\nMethods are `setPage`, `setPageSize`, `previous`, `next`, and `dispose`. Changing totals automatically clamps the active page.\n\n## Directives\n\n`clickOutside(handler)` listens for captured pointer activity outside an element:\n\n```tsx\n<div use={clickOutside(() => menu.hide())}>…</div>\n```\n\n`autoFocus` focuses a connected element in the next microtask:\n\n```tsx\n<input use={autoFocus} />\n```\n\nDirectives return cleanup functions and are disposed with their component.\n\n## Accessibility rules\n\nHeadless helpers make the relationships correct, but application markup still matters:\n\n- use visible labels for form controls;\n- preserve meaningful heading order;\n- do not make noninteractive elements clickable;\n- keep focus indicators visible;\n- use `agentLabel` when the visible name is ambiguous, not as a substitute for human text;\n- test keyboard operation and narrow viewports.\n\nBoolean ARIA states are emitted explicitly as `\"true\"` and `\"false\"` in both DOM rendering and SSR.\n"}