@kerfjs/ui 4.4.1 → 5.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/README.md +240 -10
  2. package/ai/component-catalog.json +4766 -0
  3. package/ai/component-catalog.schema.json +79 -0
  4. package/ai/public-api-signatures-v1.md +1327 -0
  5. package/ai/skill.md +73 -11
  6. package/ai/webawesome-jsx-signatures-v1.md +106 -0
  7. package/dist/app-tab.d.ts +14 -2
  8. package/dist/app-tab.js +3 -1
  9. package/dist/browser/app-tab.js +3 -2
  10. package/dist/browser/disclosure-arrow.js +4 -0
  11. package/dist/browser/empty-state.js +3 -3
  12. package/dist/browser/list-action-row.js +4 -0
  13. package/dist/browser/list-header.js +6 -0
  14. package/dist/browser/list-item.js +4 -0
  15. package/dist/browser/loading-spinner.js +2 -2
  16. package/dist/browser/lucide-icon.js +2 -2
  17. package/dist/browser/panel-header.js +7 -0
  18. package/dist/browser/resizable-region.js +2 -2
  19. package/dist/browser/segmented-control.js +3 -2
  20. package/dist/browser/select.js +4 -3
  21. package/dist/browser/skeleton.js +3 -0
  22. package/dist/browser/state-banner.js +3 -2
  23. package/dist/browser/tab-bar.js +2 -2
  24. package/dist/browser/token-search-field.js +4 -0
  25. package/dist/browser/toolbar-control-group.js +2 -2
  26. package/dist/browser/toolbar-text.js +3 -2
  27. package/dist/browser/toolbar.js +2 -2
  28. package/dist/browser/value-table.js +3 -2
  29. package/dist/catalog.d.ts +119 -0
  30. package/dist/catalog.js +143 -0
  31. package/dist/catalog.js.map +1 -0
  32. package/dist/chunk-2Z5TWHKQ.js +34 -0
  33. package/dist/chunk-2Z5TWHKQ.js.map +1 -0
  34. package/dist/chunk-37T3VNIZ.js +19 -0
  35. package/dist/chunk-37T3VNIZ.js.map +1 -0
  36. package/dist/chunk-56RJXPHG.js +43 -0
  37. package/dist/chunk-56RJXPHG.js.map +1 -0
  38. package/dist/chunk-7BR3DO2A.js +31 -0
  39. package/dist/chunk-7BR3DO2A.js.map +1 -0
  40. package/dist/chunk-7JB7VPRI.js +34 -0
  41. package/dist/chunk-7JB7VPRI.js.map +1 -0
  42. package/dist/chunk-7LDCTU7D.js +134 -0
  43. package/dist/chunk-7LDCTU7D.js.map +1 -0
  44. package/dist/{chunk-4HHCYSGT.js → chunk-AQNKJRFH.js} +2 -2
  45. package/dist/{chunk-4HHCYSGT.js.map → chunk-AQNKJRFH.js.map} +1 -1
  46. package/dist/{chunk-MBR3TYNL.js → chunk-AVZHJN6C.js} +3 -3
  47. package/dist/chunk-AVZHJN6C.js.map +1 -0
  48. package/dist/chunk-B24C4A6T.js +22 -0
  49. package/dist/chunk-B24C4A6T.js.map +1 -0
  50. package/dist/chunk-BLK3UEEI.js +23 -0
  51. package/dist/chunk-BLK3UEEI.js.map +1 -0
  52. package/dist/chunk-CVZYNJJ5.js +265 -0
  53. package/dist/chunk-CVZYNJJ5.js.map +1 -0
  54. package/dist/chunk-I7INRJO4.js +17 -0
  55. package/dist/chunk-I7INRJO4.js.map +1 -0
  56. package/dist/chunk-L4OKFDJY.js +24 -0
  57. package/dist/chunk-L4OKFDJY.js.map +1 -0
  58. package/dist/chunk-LC3YSAZC.js +39 -0
  59. package/dist/chunk-LC3YSAZC.js.map +1 -0
  60. package/dist/{chunk-3O7ZEUW2.js → chunk-M5JRI6MN.js} +3 -3
  61. package/dist/chunk-M5JRI6MN.js.map +1 -0
  62. package/dist/chunk-RUNWEPR4.js +10 -0
  63. package/dist/chunk-RUNWEPR4.js.map +1 -0
  64. package/dist/chunk-W5EQ3HYQ.js +42 -0
  65. package/dist/chunk-W5EQ3HYQ.js.map +1 -0
  66. package/dist/{chunk-MQIZAD4A.js → chunk-XXMOARTH.js} +5 -3
  67. package/dist/chunk-XXMOARTH.js.map +1 -0
  68. package/dist/chunk-Y5MRDTLC.js +20 -0
  69. package/dist/chunk-Y5MRDTLC.js.map +1 -0
  70. package/dist/{chunk-TRNBHPNW.js → chunk-YGN7OMLT.js} +11 -8
  71. package/dist/chunk-YGN7OMLT.js.map +1 -0
  72. package/dist/collapsible-panel.d.ts +66 -0
  73. package/dist/collapsible-panel.js +22 -0
  74. package/dist/collapsible-panel.js.map +1 -0
  75. package/dist/device-class.d.ts +62 -0
  76. package/dist/device-class.js +75 -0
  77. package/dist/device-class.js.map +1 -0
  78. package/dist/disclosure-arrow.d.ts +14 -0
  79. package/dist/disclosure-arrow.js +4 -0
  80. package/dist/disclosure-arrow.js.map +1 -0
  81. package/dist/index.d.ts +10 -6
  82. package/dist/index.js +21 -16
  83. package/dist/list-action-row.d.ts +52 -0
  84. package/dist/list-action-row.js +5 -0
  85. package/dist/list-action-row.js.map +1 -0
  86. package/dist/list-header.d.ts +43 -0
  87. package/dist/list-header.js +7 -0
  88. package/dist/list-header.js.map +1 -0
  89. package/dist/list-item.d.ts +33 -0
  90. package/dist/list-item.js +5 -0
  91. package/dist/list-item.js.map +1 -0
  92. package/dist/nav-stack.d.ts +38 -0
  93. package/dist/nav-stack.js +5 -0
  94. package/dist/nav-stack.js.map +1 -0
  95. package/dist/panel-header.d.ts +36 -0
  96. package/dist/panel-header.js +7 -0
  97. package/dist/panel-header.js.map +1 -0
  98. package/dist/resizable-region.d.ts +3 -1
  99. package/dist/resizable-region.js +1 -1
  100. package/dist/segmented-control.d.ts +3 -1
  101. package/dist/segmented-control.js +2 -1
  102. package/dist/select-register.d.ts +5 -1
  103. package/dist/select-register.js +3 -0
  104. package/dist/select-register.js.map +1 -1
  105. package/dist/select.d.ts +5 -2
  106. package/dist/select.js +2 -1
  107. package/dist/skeleton.d.ts +24 -0
  108. package/dist/skeleton.js +3 -0
  109. package/dist/skeleton.js.map +1 -0
  110. package/dist/split-view.d.ts +42 -0
  111. package/dist/split-view.js +21 -0
  112. package/dist/split-view.js.map +1 -0
  113. package/dist/state-banner.d.ts +3 -1
  114. package/dist/state-banner.js +2 -1
  115. package/dist/styles/app-tab.css +152 -0
  116. package/dist/styles/catalog.css +498 -0
  117. package/dist/styles/collapsible-panel.css +146 -0
  118. package/dist/styles/disclosure-arrow.css +23 -0
  119. package/dist/styles/empty-state.css +47 -0
  120. package/dist/styles/foundation.css +347 -0
  121. package/dist/styles/layout.css +73 -0
  122. package/dist/styles/list-action-row.css +160 -0
  123. package/dist/styles/list-header.css +160 -0
  124. package/dist/styles/list-item.css +123 -0
  125. package/dist/styles/loading-spinner.css +21 -0
  126. package/dist/styles/lucide-icon.css +6 -0
  127. package/dist/styles/nav-stack.css +111 -0
  128. package/dist/styles/panel-header.css +64 -0
  129. package/dist/styles/resizable-region.css +111 -0
  130. package/{src → dist/styles}/segmented-control.css +62 -31
  131. package/dist/styles/select.css +138 -0
  132. package/dist/styles/skeleton.css +31 -0
  133. package/dist/styles/split-view.css +35 -0
  134. package/dist/styles/state-banner.css +173 -0
  135. package/dist/styles/styles.css +23 -0
  136. package/dist/styles/tab-bar.css +45 -0
  137. package/dist/styles/tab-scaffold.css +84 -0
  138. package/dist/styles/token-search-field.css +289 -0
  139. package/dist/styles/toolbar-control-group.css +211 -0
  140. package/dist/styles/toolbar-text.css +40 -0
  141. package/dist/styles/toolbar.css +61 -0
  142. package/dist/styles/value-table.css +75 -0
  143. package/{src → dist/styles}/webawesome.css +119 -29
  144. package/dist/styles/workbench.css +94 -0
  145. package/dist/tab-bar.d.ts +8 -1
  146. package/dist/tab-bar.js +1 -1
  147. package/dist/tab-scaffold.d.ts +30 -0
  148. package/dist/tab-scaffold.js +16 -0
  149. package/dist/tab-scaffold.js.map +1 -0
  150. package/dist/token-search-field.d.ts +43 -0
  151. package/dist/token-search-field.js +4 -0
  152. package/dist/token-search-field.js.map +1 -0
  153. package/dist/toolbar-text.d.ts +15 -3
  154. package/dist/toolbar-text.js +2 -1
  155. package/dist/value-table.d.ts +12 -2
  156. package/dist/value-table.js +2 -1
  157. package/dist/webawesome.d.ts +100 -0
  158. package/dist/webawesome.js +3 -0
  159. package/dist/webawesome.js.map +1 -0
  160. package/dist/wire-catalog.d.ts +26 -0
  161. package/dist/wire-catalog.js +48 -0
  162. package/dist/wire-catalog.js.map +1 -0
  163. package/dist/wire-nav-stack.d.ts +15 -0
  164. package/dist/wire-nav-stack.js +88 -0
  165. package/dist/wire-nav-stack.js.map +1 -0
  166. package/dist/wire-resizable-regions.js +1 -1
  167. package/dist/wire-sidebar.d.ts +43 -0
  168. package/dist/wire-sidebar.js +117 -0
  169. package/dist/wire-sidebar.js.map +1 -0
  170. package/dist/wire-tab-bars.d.ts +15 -2
  171. package/dist/wire-tab-bars.js +1 -1
  172. package/dist/wire-tab-scaffold.d.ts +11 -0
  173. package/dist/wire-tab-scaffold.js +16 -0
  174. package/dist/wire-tab-scaffold.js.map +1 -0
  175. package/dist/wire-token-search-fields.d.ts +97 -0
  176. package/dist/wire-token-search-fields.js +5 -0
  177. package/dist/wire-token-search-fields.js.map +1 -0
  178. package/dist/workbench.d.ts +33 -0
  179. package/dist/workbench.js +17 -0
  180. package/dist/workbench.js.map +1 -0
  181. package/docs/accessibility.md +101 -3
  182. package/docs/app-layouts.md +61 -0
  183. package/docs/catalog.md +155 -0
  184. package/docs/collapsible-panel.md +102 -0
  185. package/docs/component-contract.md +169 -4
  186. package/docs/component-selection.md +185 -0
  187. package/docs/design/templates/panel-header/icon-actions-dark.svg +1 -0
  188. package/docs/design/templates/panel-header/icon-actions.svg +1 -0
  189. package/docs/design/templates/panel-header/icon-summary-actions-dark.svg +1 -0
  190. package/docs/design/templates/panel-header/icon-summary-actions.svg +1 -0
  191. package/docs/design/templates/panel-header/no-icon-dark.svg +1 -0
  192. package/docs/design/templates/panel-header/no-icon.svg +1 -0
  193. package/docs/design/templates/panel-header/page-heading-dark.svg +1 -0
  194. package/docs/design/templates/panel-header/page-heading.svg +1 -0
  195. package/docs/design/templates/panel-header/title-only-dark.svg +1 -0
  196. package/docs/design/templates/panel-header/title-only.svg +1 -0
  197. package/docs/design/templates/panel-header-dark.svg +13 -0
  198. package/docs/design/templates/panel-header.svg +13 -0
  199. package/docs/design/templates/toolbar-control-group/borderless-single-dark.svg +1 -0
  200. package/docs/design/templates/toolbar-control-group/borderless-single.svg +1 -0
  201. package/docs/design/templates/toolbar-control-group/icon-buttons-dark.svg +1 -0
  202. package/docs/design/templates/toolbar-control-group/icon-buttons.svg +1 -0
  203. package/docs/design/templates/toolbar-control-group/push-buttons-dark.svg +1 -0
  204. package/docs/design/templates/toolbar-control-group/push-buttons.svg +1 -0
  205. package/docs/design/templates/toolbar-control-group/with-text-dark.svg +1 -0
  206. package/docs/design/templates/toolbar-control-group/with-text.svg +1 -0
  207. package/docs/design/templates/toolbar-control-group-dark.svg +11 -0
  208. package/docs/design/templates/toolbar-control-group.svg +11 -0
  209. package/docs/design/templates.md +83 -0
  210. package/docs/design-philosophy.md +61 -1
  211. package/docs/device-class.md +54 -0
  212. package/docs/examples/command-palette-adapter.tsx +70 -0
  213. package/docs/layout.md +131 -0
  214. package/docs/nav-stack.md +47 -0
  215. package/docs/recipes.md +167 -0
  216. package/docs/split-view.md +49 -0
  217. package/docs/tab-scaffold.md +41 -0
  218. package/docs/ux-demo.md +23 -9
  219. package/docs/webawesome-theme.md +15 -0
  220. package/docs/workbench.md +47 -0
  221. package/llms.txt +123 -14
  222. package/package.json +139 -44
  223. package/ux-demo/recipes/app-shell.tsx +32 -0
  224. package/ux-demo/recipes/collapsible-sidebar.tsx +100 -0
  225. package/ux-demo/recipes/compact-toolbar.tsx +43 -0
  226. package/ux-demo/recipes/composer-form.tsx +63 -0
  227. package/ux-demo/recipes/list-detail-dialog.tsx +45 -0
  228. package/ux-demo/recipes/list-workspace-states.tsx +26 -0
  229. package/ux-demo/recipes/loaders.ts +33 -0
  230. package/ux-demo/recipes/loading-inspector.tsx +81 -0
  231. package/ux-demo/recipes/mount-recipe.ts +43 -0
  232. package/ux-demo/recipes/navigation-sidebar.tsx +26 -0
  233. package/ux-demo/recipes/navigation-stack.tsx +76 -0
  234. package/ux-demo/recipes/recipes.css +448 -0
  235. package/ux-demo/recipes/types.ts +17 -0
  236. package/ux-demo/recipes/workspace-header.tsx +12 -0
  237. package/dist/browser/dialog-header.js +0 -3
  238. package/dist/browser/menu-header.js +0 -3
  239. package/dist/browser/menu-item.js +0 -3
  240. package/dist/browser/page-header.js +0 -3
  241. package/dist/chunk-2PES33HS.js +0 -13
  242. package/dist/chunk-2PES33HS.js.map +0 -1
  243. package/dist/chunk-3O7ZEUW2.js.map +0 -1
  244. package/dist/chunk-723YNJBH.js +0 -17
  245. package/dist/chunk-723YNJBH.js.map +0 -1
  246. package/dist/chunk-GY5WH7TO.js +0 -10
  247. package/dist/chunk-GY5WH7TO.js.map +0 -1
  248. package/dist/chunk-MBR3TYNL.js.map +0 -1
  249. package/dist/chunk-MQIZAD4A.js.map +0 -1
  250. package/dist/chunk-NHXX4JI5.js +0 -28
  251. package/dist/chunk-NHXX4JI5.js.map +0 -1
  252. package/dist/chunk-OPCJRCYO.js +0 -19
  253. package/dist/chunk-OPCJRCYO.js.map +0 -1
  254. package/dist/chunk-T5XNMVOI.js +0 -24
  255. package/dist/chunk-T5XNMVOI.js.map +0 -1
  256. package/dist/chunk-TRNBHPNW.js.map +0 -1
  257. package/dist/chunk-VHW5YNIB.js +0 -17
  258. package/dist/chunk-VHW5YNIB.js.map +0 -1
  259. package/dist/chunk-VKH2JLDZ.js +0 -10
  260. package/dist/chunk-VKH2JLDZ.js.map +0 -1
  261. package/dist/chunk-WY4CAEPG.js +0 -14
  262. package/dist/chunk-WY4CAEPG.js.map +0 -1
  263. package/dist/dialog-header.d.ts +0 -14
  264. package/dist/dialog-header.js +0 -3
  265. package/dist/dialog-header.js.map +0 -1
  266. package/dist/menu-header.d.ts +0 -15
  267. package/dist/menu-header.js +0 -3
  268. package/dist/menu-header.js.map +0 -1
  269. package/dist/menu-item.d.ts +0 -22
  270. package/dist/menu-item.js +0 -3
  271. package/dist/menu-item.js.map +0 -1
  272. package/dist/page-header.d.ts +0 -9
  273. package/dist/page-header.js +0 -3
  274. package/dist/page-header.js.map +0 -1
  275. package/src/app-tab.css +0 -20
  276. package/src/dialog-header.css +0 -9
  277. package/src/empty-state.css +0 -6
  278. package/src/foundation.css +0 -106
  279. package/src/loading-spinner.css +0 -4
  280. package/src/lucide-icon.css +0 -1
  281. package/src/menu-header.css +0 -13
  282. package/src/menu-item.css +0 -12
  283. package/src/page-header.css +0 -3
  284. package/src/resizable-region.css +0 -19
  285. package/src/select.css +0 -18
  286. package/src/state-banner.css +0 -31
  287. package/src/styles.css +0 -18
  288. package/src/tab-bar.css +0 -4
  289. package/src/toolbar-control-group.css +0 -25
  290. package/src/toolbar-text.css +0 -4
  291. package/src/toolbar.css +0 -9
  292. package/src/value-table.css +0 -5
@@ -0,0 +1,97 @@
1
+ import { Signal } from 'kerfjs';
2
+
3
+ interface TokenSearchSubmit {
4
+ id: string;
5
+ editor: HTMLElement;
6
+ }
7
+ /** Reported when adjacent-token keyboard deletion asks the app to drop a chip. */
8
+ interface TokenSearchTokenRemoval {
9
+ id: string;
10
+ /** The `data-token-value` of the token the app should remove from its state. */
11
+ value: string;
12
+ editor: HTMLElement;
13
+ /** `'backward'` = the token before the caret (Backspace); `'forward'` = after (Delete). */
14
+ direction: 'backward' | 'forward';
15
+ }
16
+ /**
17
+ * Opt-in keyboard behavior for the atomic token chips. Off unless `keyboard` is
18
+ * set; each piece defaults on once opted in. The helper never mutates app state:
19
+ * a removal is reported through {@link TokenSearchKeyboardOptions.onRemoveToken}
20
+ * for the caller to apply, while caret movement past a chip is a pure ephemeral
21
+ * mechanic the helper performs itself.
22
+ */
23
+ interface TokenSearchKeyboardOptions {
24
+ /**
25
+ * From a collapsed caret with no selection, Backspace removes the token
26
+ * immediately before it and Delete the token immediately after — reported via
27
+ * `onRemoveToken` — instead of deleting a character. Default: true.
28
+ */
29
+ removeAdjacentToken?: boolean;
30
+ /**
31
+ * ArrowRight moves the caret past a trailing atomic token so text typed next
32
+ * lands after the chip. Default: true.
33
+ */
34
+ moveCaretPastToken?: boolean;
35
+ /** Apply the reported removal to your controlled state, then re-render. */
36
+ onRemoveToken?: (removal: TokenSearchTokenRemoval) => void;
37
+ }
38
+ /**
39
+ * Managed collapsible behavior for the iconic TokenSearchField. Every piece is on
40
+ * by default; disable a specific one to own it in the app. Provide `signals` to
41
+ * drive app-owned `expanded` signals per field id instead of helper-created ones.
42
+ */
43
+ interface TokenSearchCollapsibleOptions {
44
+ /** Expand the field and focus its editor when the iconic trigger is activated. Default: true. */
45
+ expandOnActivate?: boolean;
46
+ /** Collapse the field when focus leaves it while it is empty. Default: true. */
47
+ collapseOnEmptyBlur?: boolean;
48
+ /** Collapse an empty field on Escape and restore focus to its trigger. Default: true. */
49
+ collapseOnEscape?: boolean;
50
+ /** Focus the editor on expand and the trigger on Escape-collapse. Default: true. */
51
+ manageFocus?: boolean;
52
+ /**
53
+ * Keep an empty field expanded when focus moves to a caller-owned surface
54
+ * rendered outside the field — a suggestions dropdown, date picker, or help
55
+ * popover shown beside it. Return true for any focus target that must NOT
56
+ * trigger collapse-on-empty-blur. An element carrying `data-token-search-keep-open`
57
+ * (or any node inside one) is always exempt, so this predicate is only needed
58
+ * for surfaces you cannot mark declaratively.
59
+ */
60
+ keepOpenOn?: (target: Node | null) => boolean;
61
+ /** App-owned `expanded` signals keyed by field id; adopted instead of helper-created. */
62
+ signals?: Readonly<Record<string, Signal<boolean>>>;
63
+ }
64
+ interface WireTokenSearchFieldsOptions {
65
+ onSubmit?: (submission: TokenSearchSubmit) => void;
66
+ /** Fired on every editor `input`, after the browser mutates it, so a caller can drop its own `input` listener. */
67
+ onEdit?: (edit: TokenSearchSubmit) => void;
68
+ /** Managed collapsible transient behavior. `true`/omitted = on with defaults; `false` = fully off. */
69
+ collapsible?: boolean | TokenSearchCollapsibleOptions;
70
+ /** Opt-in atomic-chip keyboard behavior (off by default). `true` = on with defaults. */
71
+ keyboard?: boolean | TokenSearchKeyboardOptions;
72
+ }
73
+ /**
74
+ * The value returned from {@link wireTokenSearchFields}: call it (or `dispose()`) to
75
+ * tear down. When collapsible behavior is managed, it also exposes the transient
76
+ * `expanded` state per field id so the app can read it in render, hand in its own
77
+ * signal, or drive it imperatively.
78
+ */
79
+ interface TokenSearchFieldsHandle {
80
+ (): void;
81
+ dispose(): void;
82
+ /** The managed `expanded` signal for a field id (adopted or helper-created); undefined when unmanaged. */
83
+ expanded(id: string): Signal<boolean> | undefined;
84
+ /** Expand the field (and, when focus is managed, focus its editor). */
85
+ open(id: string): void;
86
+ /** Collapse the field (and, when focus is managed, restore focus to its trigger). */
87
+ close(id: string): void;
88
+ }
89
+ /**
90
+ * Wire every TokenSearchField under `root`: submit on Enter, preserve the caret across
91
+ * controlled token deletion, and (by default) manage the collapsible field's transient
92
+ * expand/collapse/focus. Returns a {@link TokenSearchFieldsHandle} — a disposer that also
93
+ * exposes the managed `expanded` state per field id.
94
+ */
95
+ declare function wireTokenSearchFields(root: HTMLElement, { onSubmit, onEdit, collapsible, keyboard }?: WireTokenSearchFieldsOptions): TokenSearchFieldsHandle;
96
+
97
+ export { type TokenSearchCollapsibleOptions, type TokenSearchFieldsHandle, type TokenSearchKeyboardOptions, type TokenSearchSubmit, type TokenSearchTokenRemoval, type WireTokenSearchFieldsOptions, wireTokenSearchFields };
@@ -0,0 +1,5 @@
1
+ export { wireTokenSearchFields } from './chunk-CVZYNJJ5.js';
2
+
3
+
4
+
5
+ //# sourceMappingURL=wire-token-search-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"wire-token-search-fields.js"}
@@ -0,0 +1,33 @@
1
+ import { SafeHtml } from 'kerfjs';
2
+
3
+ /** A collapsible Workbench panel — a side rail or the bottom drawer. */
4
+ interface WorkbenchPanel {
5
+ content: SafeHtml;
6
+ /** Whether the panel is currently collapsed (the app owns this). */
7
+ collapsed?: boolean;
8
+ /** Rail width, or drawer height, in px. Overrides the CSS default. */
9
+ size?: number;
10
+ /** Accessible name for the panel region. */
11
+ label?: string;
12
+ }
13
+ interface WorkbenchProps {
14
+ id: string;
15
+ label: string;
16
+ /** The central work area. */
17
+ main: SafeHtml;
18
+ leftRail?: WorkbenchPanel;
19
+ rightRail?: WorkbenchPanel;
20
+ bottomDrawer?: WorkbenchPanel;
21
+ className?: string;
22
+ }
23
+ /**
24
+ * The Xcode-like multi-panel workspace: a collapsible left rail, right rail, and
25
+ * bottom drawer around a central work area (any absent). Collapsing snaps the
26
+ * panel's track to zero in one reflow while its fixed-size content slides out via
27
+ * a composited transform — the instant-width / sliding-content technique, so the
28
+ * work area relayouts once, not per frame. The app owns each `collapsed` flag;
29
+ * the collapse is pure CSS (no wire). See `docs/23-app-layouts.md` §3.3.
30
+ */
31
+ declare function Workbench({ id, label, main, leftRail, rightRail, bottomDrawer, className }: WorkbenchProps): SafeHtml;
32
+
33
+ export { Workbench, type WorkbenchPanel, type WorkbenchProps };
@@ -0,0 +1,17 @@
1
+ import { jsxs, jsx } from 'kerfjs/jsx-runtime';
2
+
3
+ // src/workbench.tsx
4
+ function Workbench({ id, label, main, leftRail, rightRail, bottomDrawer, className = "" }) {
5
+ return /* @__PURE__ */ jsxs("section", { class: `kui-workbench ${className}`.trim(), id, "data-component": "workbench", "aria-label": label, children: [
6
+ leftRail && /* @__PURE__ */ jsx("aside", { class: "kui-workbench__rail kui-workbench__rail--left", "data-workbench-rail": "left", "data-collapsed": String(leftRail.collapsed ?? false), "aria-label": leftRail.label || void 0, style: leftRail.size ? `--kui-workbench-rail-width: ${leftRail.size}px` : void 0, children: /* @__PURE__ */ jsx("div", { class: "kui-workbench__panel-content", children: leftRail.content }) }),
7
+ /* @__PURE__ */ jsxs("div", { class: "kui-workbench__center", children: [
8
+ /* @__PURE__ */ jsx("div", { class: "kui-workbench__main", "data-workbench-main": true, children: main }),
9
+ bottomDrawer && /* @__PURE__ */ jsx("section", { class: "kui-workbench__drawer", "data-workbench-drawer": true, "data-collapsed": String(bottomDrawer.collapsed ?? false), "aria-label": bottomDrawer.label || void 0, style: bottomDrawer.size ? `--kui-workbench-drawer-height: ${bottomDrawer.size}px` : void 0, children: /* @__PURE__ */ jsx("div", { class: "kui-workbench__panel-content", children: bottomDrawer.content }) })
10
+ ] }),
11
+ rightRail && /* @__PURE__ */ jsx("aside", { class: "kui-workbench__rail kui-workbench__rail--right", "data-workbench-rail": "right", "data-collapsed": String(rightRail.collapsed ?? false), "aria-label": rightRail.label || void 0, style: rightRail.size ? `--kui-workbench-rail-width: ${rightRail.size}px` : void 0, children: /* @__PURE__ */ jsx("div", { class: "kui-workbench__panel-content", children: rightRail.content }) })
12
+ ] });
13
+ }
14
+
15
+ export { Workbench };
16
+
17
+ //# sourceMappingURL=workbench.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/workbench.tsx"],"names":[],"mappings":";;;AAgCO,SAAS,SAAA,CAAU,EAAE,EAAA,EAAI,KAAA,EAAO,IAAA,EAAM,UAAU,SAAA,EAAW,YAAA,EAAc,SAAA,GAAY,EAAA,EAAG,EAAmB;AAChH,EAAA,uBAAO,IAAA,CAAC,SAAA,EAAA,EAAQ,KAAA,EAAO,CAAA,cAAA,EAAiB,SAAS,CAAA,CAAA,CAAG,IAAA,EAAK,EAAG,EAAA,EAAQ,gBAAA,EAAe,WAAA,EAAY,YAAA,EAAY,KAAA,EACxG,QAAA,EAAA;AAAA,IAAA,QAAA,oBAAY,GAAA,CAAC,OAAA,EAAA,EAAM,KAAA,EAAM,+CAAA,EAAgD,qBAAA,EAAoB,MAAA,EAAO,gBAAA,EAAgB,MAAA,CAAO,QAAA,CAAS,SAAA,IAAa,KAAK,CAAA,EAAG,cAAY,QAAA,CAAS,KAAA,IAAS,MAAA,EAAW,KAAA,EAAO,QAAA,CAAS,IAAA,GAAO,CAAA,4BAAA,EAA+B,QAAA,CAAS,IAAI,CAAA,EAAA,CAAA,GAAO,MAAA,EAC3Q,QAAA,kBAAA,GAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,8BAAA,EAAgC,QAAA,EAAA,QAAA,CAAS,SAAQ,CAAA,EAC9D,CAAA;AAAA,oBACA,IAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,uBAAA,EACT,QAAA,EAAA;AAAA,sBAAA,GAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,qBAAA,EAAsB,qBAAA,EAAmB,MAAE,QAAA,EAAA,IAAA,EAAK,CAAA;AAAA,MAC1D,YAAA,oBAAgB,GAAA,CAAC,SAAA,EAAA,EAAQ,KAAA,EAAM,uBAAA,EAAwB,uBAAA,EAAqB,IAAA,EAAC,gBAAA,EAAgB,MAAA,CAAO,YAAA,CAAa,SAAA,IAAa,KAAK,GAAG,YAAA,EAAY,YAAA,CAAa,KAAA,IAAS,MAAA,EAAW,KAAA,EAAO,YAAA,CAAa,IAAA,GAAO,CAAA,+BAAA,EAAkC,aAAa,IAAI,CAAA,EAAA,CAAA,GAAO,MAAA,EACvQ,QAAA,kBAAA,GAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,8BAAA,EAAgC,QAAA,EAAA,YAAA,CAAa,SAAQ,CAAA,EAClE;AAAA,KAAA,EACF,CAAA;AAAA,IACC,SAAA,oBAAa,GAAA,CAAC,OAAA,EAAA,EAAM,KAAA,EAAM,gDAAA,EAAiD,qBAAA,EAAoB,OAAA,EAAQ,gBAAA,EAAgB,MAAA,CAAO,SAAA,CAAU,SAAA,IAAa,KAAK,GAAG,YAAA,EAAY,SAAA,CAAU,KAAA,IAAS,MAAA,EAAW,KAAA,EAAO,SAAA,CAAU,IAAA,GAAO,CAAA,4BAAA,EAA+B,UAAU,IAAI,CAAA,EAAA,CAAA,GAAO,MAAA,EAClR,QAAA,kBAAA,GAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,8BAAA,EAAgC,QAAA,EAAA,SAAA,CAAU,SAAQ,CAAA,EAC/D;AAAA,GAAA,EACF,CAAA;AACF","file":"workbench.js","sourcesContent":["import type { SafeHtml } from 'kerfjs';\n\n/** A collapsible Workbench panel — a side rail or the bottom drawer. */\nexport interface WorkbenchPanel {\n content: SafeHtml;\n /** Whether the panel is currently collapsed (the app owns this). */\n collapsed?: boolean;\n /** Rail width, or drawer height, in px. Overrides the CSS default. */\n size?: number;\n /** Accessible name for the panel region. */\n label?: string;\n}\n\nexport interface WorkbenchProps {\n id: string;\n label: string;\n /** The central work area. */\n main: SafeHtml;\n leftRail?: WorkbenchPanel;\n rightRail?: WorkbenchPanel;\n bottomDrawer?: WorkbenchPanel;\n className?: string;\n}\n\n/**\n * The Xcode-like multi-panel workspace: a collapsible left rail, right rail, and\n * bottom drawer around a central work area (any absent). Collapsing snaps the\n * panel's track to zero in one reflow while its fixed-size content slides out via\n * a composited transform — the instant-width / sliding-content technique, so the\n * work area relayouts once, not per frame. The app owns each `collapsed` flag;\n * the collapse is pure CSS (no wire). See `docs/23-app-layouts.md` §3.3.\n */\nexport function Workbench({ id, label, main, leftRail, rightRail, bottomDrawer, className = '' }: WorkbenchProps) {\n return <section class={`kui-workbench ${className}`.trim()} id={id} data-component=\"workbench\" aria-label={label}>\n {leftRail && <aside class=\"kui-workbench__rail kui-workbench__rail--left\" data-workbench-rail=\"left\" data-collapsed={String(leftRail.collapsed ?? false)} aria-label={leftRail.label || undefined} style={leftRail.size ? `--kui-workbench-rail-width: ${leftRail.size}px` : undefined}>\n <div class=\"kui-workbench__panel-content\">{leftRail.content}</div>\n </aside>}\n <div class=\"kui-workbench__center\">\n <div class=\"kui-workbench__main\" data-workbench-main>{main}</div>\n {bottomDrawer && <section class=\"kui-workbench__drawer\" data-workbench-drawer data-collapsed={String(bottomDrawer.collapsed ?? false)} aria-label={bottomDrawer.label || undefined} style={bottomDrawer.size ? `--kui-workbench-drawer-height: ${bottomDrawer.size}px` : undefined}>\n <div class=\"kui-workbench__panel-content\">{bottomDrawer.content}</div>\n </section>}\n </div>\n {rightRail && <aside class=\"kui-workbench__rail kui-workbench__rail--right\" data-workbench-rail=\"right\" data-collapsed={String(rightRail.collapsed ?? false)} aria-label={rightRail.label || undefined} style={rightRail.size ? `--kui-workbench-rail-width: ${rightRail.size}px` : undefined}>\n <div class=\"kui-workbench__panel-content\">{rightRail.content}</div>\n </aside>}\n </section>;\n}\n"]}
@@ -6,11 +6,28 @@ These are normative contracts for package components and consuming applications.
6
6
 
7
7
  - All controls have accessible names and visible `:focus-visible` treatment.
8
8
  - Decorative `LucideIcon` output is `aria-hidden`; pass `label` only when the SVG itself conveys meaning.
9
- - `MenuItem` uses native button Enter/Space behavior. `selected` maps to `aria-current="page"`; `pressed` maps to `aria-pressed`. It deliberately does not emit `role="menuitem"` in isolation—a true ARIA menu must own arrow, Home/End, Escape, and focus behavior as one widget.
9
+ - `ListItem` uses native button Enter/Space behavior. `selected` maps to `aria-current="page"`; `pressed` maps to `aria-pressed`. Its `trailing` content is dormant and must not contain controls. Its `rootAttributes` slot accepts only application `data-*` metadata and deliberately cannot emit `role="menuitem"` in isolation—a true ARIA menu must own arrow, Home/End, Escape, and focus behavior as one widget.
10
+ - `ListHeader` owns action naming, disabled state, and disclosure `aria-expanded`. Toggle mode supplies one decorative 18px `DisclosureArrow` when `actionIcon` is omitted; it mirrors `expanded` while the native button's accessible name stays stable. The application must update that controlled state and reveal or hide real content. A valid `count` is visually rendered in an `aria-hidden` neutral pill while the required localized `countLabel` becomes part of the owning heading or disclosure button's accessible name; zero remains a real count. Its narrow `triggerAttributes` slot may describe a native popover or controlled-content relationship with `popoverTarget`, `popoverTargetAction`, `aria-controls`, and `aria-haspopup`; the application owns the target surface and its focus/dismissal behavior.
11
+ - In any `.kui-pane`, menu rows, `ListHeader` actions, and toolbar groups keep a minimum 44px target in both dimensions. A `ListHeader` fills the available inline width and keeps that action at the logical end, including in RTL. Its action visual defaults to 18px through `--kui-list-header-action-icon-size`; do not reduce the target to the visible icon.
10
12
  - `LoadingSpinner` is either labeled (`role="img"`) or hidden. Its rotation stops for reduced motion.
11
13
  - `StateBanner` defaults to polite `role="status"`; use `urgency="alert"` only for an attention-requiring failure.
12
14
  - `EmptyState` reports busy state through `aria-busy` and never relies on an illustration as its label.
13
- - `Select` follows the Web Awesome host's standard `input`/`change` events. Application tests verify the live `value`, focus, and events—not attributes alone.
15
+ - `Select` follows the Web Awesome host's standard `input`/`change` events. Application tests verify the live `value`, focus, and events—not attributes alone. Its decorative option icons and value-dependent selected content remain present after controlled rerenders, so the visible choice does not silently lose its non-text cue.
16
+ - `DisclosureArrow` is an 18px root-scaled decorative visual by default, not an interaction target. Its owning native control supplies a stable accessible name, pointer and keyboard interaction, and `aria-expanded`; multiple arrows keep independently controlled state. Consumers may override `--kui-disclosure-arrow-size` without changing that ownership. Configured directions animate over the shortest path; a 180-degree closed-to-open tie uses counterclockwise rotation.
17
+
18
+ ## ListActionRow
19
+
20
+ `ListActionRow` renders a noninteractive visual root containing primary and
21
+ trailing native buttons as siblings. The primary comes first in DOM and Tab
22
+ order and alone receives `aria-current` or `aria-pressed`; the trailing button
23
+ has its own required accessible name and disabled state. Both controls carry
24
+ the row's item id for application delegation, while the root deliberately has
25
+ no action or role. `label`, `icon`, and `trailingActionIcon` are dormant visual
26
+ content and must not contain buttons, links, interactive roles, or other
27
+ controls. A trailing click, double click, context menu, Enter, or Space
28
+ must never activate the primary control. The application owns controlled
29
+ selection and the lifecycle, focus, and dismissal policy of any related
30
+ popover or context menu.
14
31
 
15
32
  ## ResizableRegion
16
33
 
@@ -22,12 +39,33 @@ The handle exposes separator role, orientation, name, minimum, maximum, and curr
22
39
  - primary-pointer drag with clamping;
23
40
  - preview callbacks while dragging and one commit callback on release.
24
41
 
25
- The application owns persistence and collapsed/expanded policy. Keep the last expanded size outside the component and restore it when reopening.
42
+ The application owns persistence and collapsed/expanded policy. Keep the last expanded size outside the component and restore it when reopening. An optional `handleIcon` replaces only decorative dormant content; it must not contain controls or interactive roles because the separator remains the sole focus and interaction owner.
43
+
44
+ ## PanelHeader
45
+
46
+ `PanelHeader` is a plain `Toolbar` heading. Its leading zone holds an optional
47
+ icon (a normal bordered `ToolbarControlGroup`) and the title as extra-large
48
+ `ToolbarText`, and the app's trailing controls go straight into the trailing
49
+ zone. By default the title carries no native heading role, so for a **dialog or
50
+ panel** the application connects `titleId` and an optional `summaryId` to the
51
+ owning host through `aria-labelledby` and `aria-describedby`. For a **page or
52
+ view** title, pass `headingLevel` (usually `1`): the title then exposes
53
+ `role="heading"` with a matching `aria-level`, giving the view a heading landmark
54
+ so screen-reader heading navigation and "main heading" semantics work — the same
55
+ role/level pair `ToolbarText` exposes when it is given `headingLevel` directly.
56
+ Keep the levels meaningful and non-skipping within a view. Pass the trailing
57
+ controls as a labeled `ToolbarControlGroup` when that group needs an accessible
58
+ name.
26
59
 
27
60
  ## Tabs
28
61
 
29
62
  `AppTab` renders one controlled tab. `TabBar` supplies the containing tab list,
30
63
  fixed leading/trailing regions, and a horizontally scrollable strip.
64
+ `AppTab.rootAttributes` accepts application `data-*` metadata only; runtime
65
+ filtering rejects roles plus case variants of component- or wiring-owned
66
+ action, identity, selection, drag, drop, and component attributes. An optional
67
+ `closeIcon` is decorative dormant content inside the already named close button
68
+ and must not contain interactive descendants.
31
69
  `wireTabBars()` adds Left/Right wrapping, Home/End, Delete/Backspace close
32
70
  activation, same-bar pointer reordering, `Alt+Shift+ArrowLeft/ArrowRight`
33
71
  reordering, focus restoration, scroll-into-view, and pointer-proximity
@@ -35,6 +73,15 @@ autoscroll at either horizontal edge. Edge autoscroll is direct manipulation,
35
73
  stops on drop/drag end/disposal, and does not change keyboard behavior. It
36
74
  returns a disposer.
37
75
 
76
+ Arrow / Home / End follow the ARIA Tabs **automatic-activation** pattern by
77
+ default: they move roving focus and select the focused tab. Pass
78
+ `activation: 'manual'` (or set `data-tab-activation="manual"` on a strip via the
79
+ `TabBar` `activation` prop, which overrides the option per bar) for
80
+ **manual activation**: arrow keys move roving focus only and the user selects
81
+ with Enter / Space (native on the tab button) or click. Use manual activation
82
+ when selecting a tab is a heavy or side-effecting action — e.g. a tab that loads
83
+ a project — so arrowing through the strip does not trigger it on every tab.
84
+
38
85
  The application owns the ordered tab array, selection, panels, close policy,
39
86
  routing, and persistence. On `onReorder`, synchronously render the reported
40
87
  order so the helper can restore focus to the moved tab. Pair tabs with
@@ -51,6 +98,57 @@ re-renders. Keep labels unique and meaningful even when `content` shows only an
51
98
  icon. Use tabs—not a segmented control—when choices switch page regions that
52
99
  need `tab`/`tabpanel` semantics.
53
100
 
101
+ ## TokenSearchField
102
+
103
+ `TokenSearchField` exposes the editable surface as a named `searchbox`. Each
104
+ chip is atomic (`contenteditable="false"`) and contains separately named edit
105
+ and remove buttons; the clear action is also named. Disabled fields publish
106
+ `aria-disabled` and stop editing without hiding the current expression. The
107
+ application owns query parsing and must announce result-count or loading
108
+ changes separately when that feedback is useful. Use `readTokenSearchField()`
109
+ to ignore the chip buttons' visible text when reading browser-edited content,
110
+ and `placeTokenSearchCaret()` to restore a text caret without landing inside a
111
+ chip. Call `wireTokenSearchFields()` once at a stable root so Enter submits
112
+ without inserting a contenteditable line break and keyboard chip deletion
113
+ restores focus plus the text-relative caret after controlled rendering replaces
114
+ the editor; the editor still wraps text visually at its inline edge. Editable text is DOM-owned between token changes; a clear handler empties
115
+ the editor's `textContent` before updating application state. Leading and
116
+ trailing controls share the first text line's fixed vertical center and remain
117
+ there as the editor wraps. In `collapsible` mode, the closed state is one named
118
+ iconic search button and the open state is the same named searchbox, whether
119
+ the field stands alone or is composed inside `ToolbarControlGroup`.
120
+ Applications set `expanded` while the field is focused, move focus from the
121
+ trigger into the revealed editor, and clear `expanded` only after focus leaves
122
+ the complete component. Text or tokens keep the field expanded even when that
123
+ transient state is false. Width animation is disabled under reduced motion.
124
+
125
+ When the field is composed with a caller-owned surface rendered outside it — a
126
+ suggestions listbox, date picker, or help popover — mark that surface (or wrap
127
+ it) with `data-token-search-keep-open`, or pass `collapsible.keepOpenOn(target)`,
128
+ so focus moving into it does not collapse an empty field. `wireTokenSearchFields`
129
+ can also, opt-in via `keyboard`, own atomic-chip editing keys: from a collapsed
130
+ caret with no selection, Backspace removes the token before the caret and Delete
131
+ the token after it (reported through `onRemoveToken` for the app to apply to its
132
+ controlled state), and ArrowRight moves the caret past a trailing chip so typed
133
+ text lands after it. An optional `onEdit({ id, editor })` fires on every editor
134
+ `input`, letting a caller drop its own `input` listener; the application still
135
+ owns query parsing and result-count/loading announcements.
136
+
137
+ ## CollapsiblePanel / sidebar
138
+
139
+ `CollapsiblePanel` is a labeled `aside` region that is `aria-hidden` while
140
+ collapsed; its content slides via transform (disabled under reduced motion).
141
+ `CollapsiblePanelToggle` is a named button carrying `aria-expanded` and the
142
+ standard per-side collapse/expand glyph (`PanelLeft*` / `PanelRight*` /
143
+ `PanelBottom*`); place a collapse toggle inside the panel and an expand toggle in
144
+ an always-visible location so it is reachable while collapsed. `wireSidebar()`
145
+ moves focus into the panel when it opens and restores it to the trigger when it
146
+ closes. When a `deviceClass()` reports `compact`, an open panel becomes an overlay
147
+ with a dismissable backdrop, Escape and backdrop-click collapse it, and Tab is
148
+ trapped within it (the ARIA dialog pattern). The application owns each `collapsed`
149
+ signal, the panels, their sizes, and content; the wire may persist the collapsed
150
+ state per panel.
151
+
54
152
  ## Verification matrix
55
153
 
56
154
  For each changed component, inspect default, hover, focus, disabled, selected/pressed, busy/error, long-content, wide, narrow, light, dark, increased-contrast, reduced-motion, keyboard-only, and 200%-zoom states where applicable. DOM order must match reading and focus order, with no clipping or unreachable action.
@@ -0,0 +1,61 @@
1
+ # Choosing an app layout
2
+
3
+ `@kerfjs/ui` ships four opt-in, tree-shakeable whole-screen layouts plus the
4
+ [`device-class`](device-class.md) signal that drives their responsive behavior.
5
+ This guide maps a **data + interaction + device** situation to the layout to
6
+ reach for, and states the device-class threshold at which the presentation
7
+ changes. The layouts:
8
+
9
+ - [`NavStack`](nav-stack.md) — push/pop navigation (a single pane is a one-entry stack).
10
+ - [`SplitView`](split-view.md) — list-detail (two panes, collapsing to a stack).
11
+ - [`Workbench`](workbench.md) — the Xcode-like collapsible rails + drawer.
12
+ - [`TabScaffold`](tab-scaffold.md) — the iOS bottom tab bar (each tab a stack).
13
+ - [`CollapsiblePanel` + `wireSidebar`](collapsible-panel.md) — a standalone
14
+ collapsible rail or bottom drawer (with `CollapsiblePanelToggle`), outside a full
15
+ shell: the standard collapse animation, icon convention, and `wireSidebar`
16
+ semantics (focus, compact overlay, persistence).
17
+
18
+ Derive responsiveness from `deviceClass()`: `compact` (a handset or portrait
19
+ tablet) means "one pane at a time"; `atLeast('tablet')` / `atLeast('desktop')`
20
+ gate the roomier presentations.
21
+
22
+ ## Decision matrix
23
+
24
+ | Situation | Layout | Device threshold |
25
+ | --- | --- | --- |
26
+ | Simple app, a few flat sections | `NavStack` with one entry (single pane); add `TabScaffold` for 2–5 co-equal sections on handset | `TabScaffold` on `compact`; promote its tabs to a `Workbench` rail / sidebar `atLeast('desktop')` |
27
+ | Drill-down browsing (list → item → sub-item) | `NavStack`; upgrade to `SplitView` once list + detail fit together | `SplitView` two-pane `atLeast('tablet')` landscape / non-`compact`; `NavStack` form on `compact` |
28
+ | Two related panes, selecting on the left updates the right | `SplitView` | two panes when not `compact`; collapses to `NavStack` (list → detail) on `compact` |
29
+ | Complex tool / editor with peripheral panels (navigator, inspector, console) | `Workbench` | full three-panel `atLeast('desktop')`; on smaller classes present the rails via `NavStack` / overlay drawers, not a shrunken shell |
30
+ | Mobile app with 2–5 top-level destinations, each its own drill-down | `TabScaffold`, each tab a `NavStack` | bottom bar on `compact`; promote to a rail / sidebar `atLeast('desktop')` |
31
+
32
+ ### Worked examples
33
+
34
+ - **Settings screen (simple):** one `NavStack` entry per screen; push a subpage
35
+ on tap. No `SplitView`/`Workbench` — it is a single flow.
36
+ - **Mail (drill-down + two-pane):** `SplitView` with `list={<ThreadList/>}` and
37
+ `detail={<Message/>}`, `compact={device.value.compact}`,
38
+ `detailActive={selected != null}`. On desktop both panes show with a resizable
39
+ separator; on a phone it is a `NavStack` (threads → message, back clears the
40
+ selection).
41
+ - **IDE (complex tool):** `Workbench` with a left navigator rail, a right
42
+ inspector rail, and a bottom console drawer, each `collapsed` bound to a
43
+ signal. Only offer this `atLeast('desktop')`.
44
+ - **Social app (tabbed):** `TabScaffold` with Home / Search / Profile tabs, each
45
+ `content` a `NavStack`. On a tablet/desktop, render the same sections as a
46
+ `Workbench` left rail instead of a bottom bar.
47
+
48
+ ## Dialogs
49
+
50
+ Pick the dialog's inner layout by the same complexity axis, then apply the device
51
+ class to how it is presented (compose with [`overlay`](../../docs/19-native-overlay-backing.md)):
52
+
53
+ - **desktop:** an inline dialog — a `SplitView` two-pane body, or a `NavStack`
54
+ for a wizard.
55
+ - **portrait tablet / handset:** present a `SplitView`/complex dialog as a
56
+ full-screen modal (its `compact` `NavStack` form).
57
+ - **landscape tablet:** a large partial-cover modal (does not need to go full
58
+ screen).
59
+
60
+ A `NavStack` works as a dialog body at every size — a wizard pushes and pops its
61
+ steps with cross-faded chrome.
@@ -0,0 +1,155 @@
1
+ # Catalog — a reusable component-gallery shell
2
+
3
+ `@kerfjs/ui/catalog` is an opt-in, whole-screen shell for building a **component
4
+ catalog** — the collapsible category sidebar + titled preview stage + resources
5
+ footer that the kerf UI catalog itself uses. Point it at your own components and
6
+ you get the same shell without rebuilding it. Like the app layouts, it is a
7
+ subpath-only, tree-shakeable module that adds nothing to the main barrel.
8
+
9
+ ```bash
10
+ npm install @kerfjs/ui # kerfjs is a peer; @kerfjs/ui/select/register is needed only if entries use `related`
11
+ ```
12
+
13
+ - `Catalog(props)` returns the shell as `SafeHtml` (a `<main class="kui-catalog">`).
14
+ It is **controlled and stateless**: your app owns the `active`, `collapsed`, and
15
+ `theme` signals and computes the preview `content` from `active` in its own
16
+ `mount()` render.
17
+ - `wireCatalog(root, options)` wires the interactions (sidebar selection, the
18
+ related-entry popup menu, and the collapse/theme toggles) with one delegated
19
+ listener set and returns a disposer; it can also mirror the active id into the
20
+ URL.
21
+
22
+ ## What you supply
23
+
24
+ - **`sections`** — category-grouped entries: `{ category, entries: [{ id, name,
25
+ description?, resources?, related? }] }`. Each entry becomes a sidebar `ListItem`
26
+ under a `ListHeader` for its category.
27
+ - **`content`** — the rendered preview for the active entry. Keep a map of `id →
28
+ () => SafeHtml` in your app and call `renderers[active]()` in your render.
29
+ - **`brand`** — `{ title, subtitle?, logoUrl? }` for the sidebar header.
30
+ - **`secondarySections`** — an optional secondary "ecosystem" group shown below the
31
+ primary sections with a quieter treatment: `{ label, sections, collapsible?,
32
+ expanded? }`. When `collapsible`, the label is a disclosure toggle controlling
33
+ `expanded` (the app owns it; wire it with `wireCatalog`'s `onToggleSecondary`).
34
+ - Optional slots: `headerActions` (extra header controls), `sidebarFooter` (extra
35
+ sidebar content), and `status` (a footer status line).
36
+
37
+ Per-entry `resources` render as "open in new tab" links in the footer, and
38
+ `related` renders a "Related entries" popup menu — a `single` `ToolbarControlGroup`
39
+ around a `wa-dropdown` (grouped by each entry's `group`), so register its elements
40
+ with `@kerfjs/ui/select/register` when you use it.
41
+
42
+ ## Preview examples
43
+
44
+ Compose each entry's `content` from `CatalogExample` (and `CatalogExampleStack`)
45
+ instead of hand-rolled example markup, so labels, notes, and left-edge alignment
46
+ stay consistent:
47
+
48
+ ```tsx
49
+ import { CatalogExample, CatalogExampleStack } from '@kerfjs/ui/catalog';
50
+
51
+ const buttonPreview = (
52
+ <CatalogExampleStack label="Button variants">
53
+ <CatalogExample label="Icon" note="A bare glyph." align="glyph">
54
+ <LucideIcon icon={Plus} name="plus" />
55
+ </CatalogExample>
56
+ <CatalogExample label="Control" align="inline-control">
57
+ <SegmentedControl id="view" label="View" value="list" choices={choices} />
58
+ </CatalogExample>
59
+ <CatalogExample label="In composition">
60
+ <ValueTable label="Metadata">{rows}</ValueTable>
61
+ </CatalogExample>
62
+ </CatalogExampleStack>
63
+ );
64
+ ```
65
+
66
+ `label` is optional — omit it for a bare specimen with no `ListHeader`. `align`
67
+ lines a specimen's visible left edge up with its label text: `'glyph'`
68
+ (16px) for a bare glyph/text specimen, `'inline-control'` (8px) for a control that
69
+ already carries ~8px of its own inline padding, and `'none'` (the default) for a
70
+ content-item/composition that already owns its geometry. The inset is published as
71
+ the `--kui-catalog-example-align` custom property so a debug overlay can exclude it
72
+ from a specimen's measured margin.
73
+
74
+ ## Complete example
75
+
76
+ ```tsx
77
+ import { mount, signal } from 'kerfjs';
78
+ import { Catalog, type CatalogSection } from '@kerfjs/ui/catalog';
79
+ import { wireCatalog } from '@kerfjs/ui/wire-catalog';
80
+ import '@kerfjs/ui/styles.css'; // or import each primitive's CSS + @kerfjs/ui/catalog.css
81
+
82
+ // 1. Describe your components once.
83
+ const sections: CatalogSection[] = [
84
+ {
85
+ category: 'Controls',
86
+ entries: [
87
+ { id: 'button', name: 'Button', description: 'A pressable control.',
88
+ resources: [{ label: 'Source', href: '/src/button.tsx', detail: 'src/button.tsx' }] },
89
+ { id: 'field', name: 'Field', description: 'A labeled input.',
90
+ related: [{ id: 'button', name: 'Button', group: 'Used with' }] },
91
+ ],
92
+ },
93
+ { category: 'Feedback', entries: [{ id: 'toast', name: 'Toast', description: 'A transient message.' }] },
94
+ ];
95
+
96
+ // 2. One preview render per entry id.
97
+ const renderers: Record<string, () => ReturnType<typeof Button>> = {
98
+ button: () => <Button label="Save" />,
99
+ field: () => <Field label="Name" />,
100
+ toast: () => <Toast>Saved</Toast>,
101
+ };
102
+
103
+ // 3. App-owned state (domain: which entry; transient: collapsed; global: theme).
104
+ const initial = new URLSearchParams(location.search).get('c') ?? sections[0].entries[0].id;
105
+ const active = signal(initial);
106
+ const collapsed = signal(false);
107
+ const theme = signal<'light' | 'dark'>('light');
108
+
109
+ const app = document.getElementById('app')!;
110
+ mount(app, () => (
111
+ <Catalog
112
+ brand={{ title: 'Acme UI', subtitle: 'Design system' }}
113
+ sections={sections}
114
+ active={active.value}
115
+ content={renderers[active.value]?.() ?? <></>}
116
+ collapsed={collapsed.value}
117
+ theme={theme.value}
118
+ />
119
+ ));
120
+
121
+ wireCatalog(app, {
122
+ onSelect: (id) => { active.value = id; },
123
+ onToggleSidebar: () => { collapsed.value = !collapsed.value; },
124
+ onToggleTheme: () => {
125
+ theme.value = theme.value === 'dark' ? 'light' : 'dark';
126
+ document.documentElement.dataset.theme = theme.value; // apply your theme however you like
127
+ },
128
+ urlParam: 'c', // mirror the active id into ?c=<id>
129
+ });
130
+ ```
131
+
132
+ ## Ownership boundary
133
+
134
+ `Catalog` renders the shell; the app owns everything stateful:
135
+
136
+ - **`active`** is domain state (which entry is shown) — the app's signal, updated in
137
+ `onSelect`, read to compute `content`.
138
+ - **`collapsed`** is transient UI — the app's signal, flipped in `onToggleSidebar`.
139
+ - **`theme`** is a global preference — the app's signal; `wireCatalog` only reports
140
+ the toggle, the app applies the theme (the shell reads `theme` to show the toggle's
141
+ opposite-state label). Omit `theme` to hide the toggle entirely.
142
+
143
+ ## Custom action names
144
+
145
+ The shell emits `data-action="catalog-select"` (sidebar items),
146
+ `catalog-toggle-sidebar`, and `catalog-toggle-theme`. Override them with
147
+ `selectAction` / `toggleSidebarAction` / `toggleThemeAction` on `Catalog` (and the
148
+ matching options on `wireCatalog`) if they collide with your own action table.
149
+
150
+ ## CSS
151
+
152
+ `Catalog` composes public primitives (`Toolbar`, `ListHeader`, `ListItem`, `Select`,
153
+ …). Import `@kerfjs/ui/styles.css` for the whole layer, or `@kerfjs/ui/catalog.css`
154
+ plus each composed primitive's CSS. The shell is theme-aware and responsive: it
155
+ stacks the sidebar above the detail below ~832px and hides it when collapsed.
@@ -0,0 +1,102 @@
1
+ # CollapsiblePanel + wireSidebar — reusable sidebar / drawer support
2
+
3
+ `@kerfjs/ui/collapsible-panel` and `@kerfjs/ui/wire-sidebar` are the standalone,
4
+ reusable pieces for an app's own **side rails** and **bottom drawers** — the same
5
+ collapse semantics the `Workbench` shell and the catalog sidebar use, but outside
6
+ a full shell so you can dock one panel wherever your layout needs it. They are
7
+ subpath-only, tree-shakeable modules that add nothing to the main barrel.
8
+
9
+ ```bash
10
+ npm install @kerfjs/ui # kerfjs is a peer
11
+ ```
12
+
13
+ Import the panel CSS (`@kerfjs/ui/collapsible-panel.css`) alongside `foundation.css`.
14
+
15
+ ## The pieces
16
+
17
+ - **`CollapsiblePanel({ id, side, collapsed?, size?, label?, children })`** — the
18
+ docked panel: a `'left'` / `'right'` rail or a `'bottom'` drawer. It owns only
19
+ presentation. Collapsing snaps the panel's own size to zero in one reflow while
20
+ the fixed-size content slides out via `transform` (composited, clipped) — never
21
+ a per-frame width/height animation. The app owns the `collapsed` signal; `size`
22
+ overrides the CSS default width/height.
23
+ - **`CollapsiblePanelToggle({ side, collapsed, action, panelId?, label? })`** and
24
+ **`collapsiblePanelToggleIcon(side, collapsed)`** — the standard toggle
25
+ affordance and its icon convention, so every sidebar reads the same: `PanelLeft*`
26
+ for a left rail, `PanelRight*` for a right rail, `PanelBottom*` for a bottom
27
+ drawer — the `Close` glyph while open, the `Open` glyph while collapsed. Placement
28
+ is the app's: put a collapse toggle in the panel's own header and an expand toggle
29
+ somewhere always-visible (a toolbar) so it is reachable while collapsed.
30
+ - **`wireSidebar(root, { panels, deviceClass?, storage? })`** — the interaction
31
+ semantics. Each `panels` entry is `{ id, collapsed, toggleAction, storageKey? }`.
32
+ It:
33
+ - **toggles** the panel's `collapsed` signal when any `[data-action=toggleAction]`
34
+ button is clicked, and remembers the trigger;
35
+ - **manages focus** — moves focus into the panel when it opens, and restores it
36
+ to the trigger when it closes;
37
+ - **presents a compact overlay** when `deviceClass.compact` is true (pass a
38
+ `deviceClass()` signal): the open panel floats over the content with a
39
+ dismissable backdrop, Escape and backdrop-click collapse it, and Tab is trapped
40
+ within the panel (the ARIA dialog pattern);
41
+ - **persists** the collapsed state to `storage` (default `localStorage`) under
42
+ `storageKey`, seeding the signal on wire-up.
43
+
44
+ Returns a disposer. Retain it and call it on teardown.
45
+
46
+ ## Example
47
+
48
+ ```tsx
49
+ import { signal, mount } from 'kerfjs';
50
+ import { deviceClass } from '@kerfjs/ui/device-class';
51
+ import { CollapsiblePanel, CollapsiblePanelToggle } from '@kerfjs/ui/collapsible-panel';
52
+ import { wireSidebar } from '@kerfjs/ui/wire-sidebar';
53
+ import '@kerfjs/ui/collapsible-panel.css';
54
+
55
+ const navCollapsed = signal(false);
56
+ const device = deviceClass();
57
+
58
+ const app = document.querySelector('#app')!;
59
+ mount(app, () => (
60
+ <div class="layout">
61
+ <CollapsiblePanel id="nav" side="left" collapsed={navCollapsed.value} label="Navigator">
62
+ <header>
63
+ <CollapsiblePanelToggle side="left" collapsed={navCollapsed.value} action="toggle-nav" panelId="nav" />
64
+ </header>
65
+ {/* nav items */}
66
+ </CollapsiblePanel>
67
+ <main>
68
+ {navCollapsed.value && <CollapsiblePanelToggle side="left" collapsed action="toggle-nav" label="Show navigator" />}
69
+ {/* content */}
70
+ </main>
71
+ </div>
72
+ ));
73
+
74
+ const stop = wireSidebar(app, {
75
+ panels: [{ id: 'nav', collapsed: navCollapsed, toggleAction: 'toggle-nav', storageKey: 'app.nav-collapsed' }],
76
+ deviceClass: device,
77
+ });
78
+ ```
79
+
80
+ ## When to use which
81
+
82
+ - One or two independent rails / a drawer you place yourself → **`CollapsiblePanel` +
83
+ `wireSidebar`**.
84
+ - A whole Xcode-like workspace (left rail + right rail + bottom drawer + work area
85
+ in one shell) → **[`Workbench`](workbench.md)**, which owns the layout and the same
86
+ collapse animation.
87
+ - Drag-to-resize a panel → compose **[`ResizableRegion`](../src/resizable-region.tsx)**
88
+ / `wireResizableRegions`; the app owns the size signal.
89
+
90
+ The app still owns everything domain-specific — which panels exist, their order,
91
+ sizes, content, and any per-project persistence — exactly as with the other layouts.
92
+
93
+ ## Recipe and coverage
94
+
95
+ The catalog ships a runnable **Collapsible sidebar** recipe — a left rail and a
96
+ bottom drawer with the standard toggles, the compact overlay, and per-panel
97
+ persistence: [open it](../ux-demo/?component=recipe-collapsible-sidebar) or read
98
+ [`recipes.md`](recipes.md#collapsible-sidebar) · [TSX source](../ux-demo/recipes/collapsible-sidebar.tsx).
99
+ It is covered end-to-end across Chromium, Firefox, and WebKit by
100
+ `tests/browser/collapsible-sidebar-recipe.spec.ts` (collapse/expand, focus
101
+ move/restore, the compact overlay + Escape/backdrop dismiss, and the Tab trap),
102
+ alongside the component/wire unit tests.