@smwb/ui-react 2.8.0 → 2.9.0

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 (239) hide show
  1. package/AI_LESS_OVERRIDES.md +84 -21
  2. package/AI_THEMING.md +72 -28
  3. package/README.md +654 -0
  4. package/dist/assets/styles/less/components/appBar/appBar.entry.css +1 -1
  5. package/dist/assets/styles/less/components/button/button.entry.css +1 -1
  6. package/dist/assets/styles/less/components/checkbox/checkbox.entry.css +1 -1
  7. package/dist/assets/styles/less/components/chip/chip.entry.css +1 -1
  8. package/dist/assets/styles/less/components/commandPalette/commandPalette.entry.css +1 -0
  9. package/dist/assets/styles/less/components/composer/composer.entry.css +1 -0
  10. package/dist/assets/styles/less/components/dagView/dagView.entry.css +1 -0
  11. package/dist/assets/styles/less/components/diffStat/diffStat.entry.css +1 -0
  12. package/dist/assets/styles/less/components/kbd/kbd.entry.css +1 -0
  13. package/dist/assets/styles/less/components/list/list.entry.css +1 -1
  14. package/dist/assets/styles/less/components/qrCode/qrCode.entry.css +1 -0
  15. package/dist/assets/styles/less/components/radioButton/radioButton.entry.css +1 -1
  16. package/dist/assets/styles/less/components/selectField/selectField.entry.css +1 -1
  17. package/dist/assets/styles/less/components/sidebar/sidebar.entry.css +1 -1
  18. package/dist/assets/styles/less/components/statusDot/statusDot.entry.css +1 -0
  19. package/dist/assets/styles/less/components/swimlanes/swimlanes.entry.css +1 -0
  20. package/dist/assets/styles/less/components/tabGroups/tabGroups.entry.css +1 -0
  21. package/dist/assets/styles/less/components/tabs/tab/tab.entry.css +1 -1
  22. package/dist/assets/styles/less/components/tabs/tabPanel/tabPanel.entry.css +1 -0
  23. package/dist/assets/styles/less/components/tabs/tabs.entry.css +1 -1
  24. package/dist/assets/styles/less/components/textField/textField.entry.css +1 -1
  25. package/dist/assets/styles/less/components/toggle/toggle.entry.css +1 -1
  26. package/dist/assets/styles/less/components/treeView/treeView.entry.css +1 -1
  27. package/dist/assets/styles/less/tokens.entry.css +1 -1
  28. package/dist/components/dataDisplay/chat/chat.d.ts +43 -1
  29. package/dist/components/dataDisplay/chat/chat.js +179 -150
  30. package/dist/components/dataDisplay/chat/chat.js.map +1 -1
  31. package/dist/components/dataDisplay/chat/chatComposer.d.ts +17 -0
  32. package/dist/components/dataDisplay/chat/chatComposer.js +31 -29
  33. package/dist/components/dataDisplay/chat/chatComposer.js.map +1 -1
  34. package/dist/components/dataDisplay/chat/index.d.ts +1 -1
  35. package/dist/components/dataDisplay/chat/utils.d.ts +14 -0
  36. package/dist/components/dataDisplay/chat/utils.js.map +1 -1
  37. package/dist/components/dataDisplay/dagView/dagLayout.d.ts +74 -0
  38. package/dist/components/dataDisplay/dagView/dagLayout.js +289 -0
  39. package/dist/components/dataDisplay/dagView/dagLayout.js.map +1 -0
  40. package/dist/components/dataDisplay/dagView/dagView.d.ts +157 -0
  41. package/dist/components/dataDisplay/dagView/dagView.js +543 -0
  42. package/dist/components/dataDisplay/dagView/dagView.js.map +1 -0
  43. package/dist/components/dataDisplay/dagView/index.d.ts +4 -0
  44. package/dist/components/dataDisplay/dagView/panZoom.d.ts +75 -0
  45. package/dist/components/dataDisplay/dagView/panZoom.js +171 -0
  46. package/dist/components/dataDisplay/dagView/panZoom.js.map +1 -0
  47. package/dist/components/dataDisplay/diffStat/diffStat.d.ts +54 -0
  48. package/dist/components/dataDisplay/diffStat/diffStat.js +63 -0
  49. package/dist/components/dataDisplay/diffStat/diffStat.js.map +1 -0
  50. package/dist/components/dataDisplay/diffStat/index.d.ts +2 -0
  51. package/dist/components/dataDisplay/kbd/index.d.ts +4 -0
  52. package/dist/components/dataDisplay/kbd/kbd.d.ts +40 -0
  53. package/dist/components/dataDisplay/kbd/kbd.js +27 -0
  54. package/dist/components/dataDisplay/kbd/kbd.js.map +1 -0
  55. package/dist/components/dataDisplay/kbd/kbdKeys.d.ts +18 -0
  56. package/dist/components/dataDisplay/kbd/kbdKeys.js +72 -0
  57. package/dist/components/dataDisplay/kbd/kbdKeys.js.map +1 -0
  58. package/dist/components/dataDisplay/list/listItem.d.ts +34 -0
  59. package/dist/components/dataDisplay/list/listItem.js +47 -24
  60. package/dist/components/dataDisplay/list/listItem.js.map +1 -1
  61. package/dist/components/dataDisplay/qrCode/index.d.ts +2 -0
  62. package/dist/components/dataDisplay/qrCode/qrCode.d.ts +28 -0
  63. package/dist/components/dataDisplay/qrCode/qrCode.js +78 -0
  64. package/dist/components/dataDisplay/qrCode/qrCode.js.map +1 -0
  65. package/dist/components/dataDisplay/qrCode/qrCodePath.d.ts +7 -0
  66. package/dist/components/dataDisplay/qrCode/qrCodePath.js +20 -0
  67. package/dist/components/dataDisplay/qrCode/qrCodePath.js.map +1 -0
  68. package/dist/components/dataDisplay/statusDot/index.d.ts +2 -0
  69. package/dist/components/dataDisplay/statusDot/statusDot.d.ts +35 -0
  70. package/dist/components/dataDisplay/statusDot/statusDot.js +30 -0
  71. package/dist/components/dataDisplay/statusDot/statusDot.js.map +1 -0
  72. package/dist/components/dataDisplay/swimlanes/index.d.ts +2 -0
  73. package/dist/components/dataDisplay/swimlanes/swimlanes.d.ts +53 -0
  74. package/dist/components/dataDisplay/swimlanes/swimlanes.js +357 -0
  75. package/dist/components/dataDisplay/swimlanes/swimlanes.js.map +1 -0
  76. package/dist/components/dataDisplay/treeView/treeView.d.ts +17 -1
  77. package/dist/components/dataDisplay/treeView/treeView.js +113 -92
  78. package/dist/components/dataDisplay/treeView/treeView.js.map +1 -1
  79. package/dist/components/feedBack/modal/modal.js +84 -63
  80. package/dist/components/feedBack/modal/modal.js.map +1 -1
  81. package/dist/components/inputs/button/button.d.ts +13 -1
  82. package/dist/components/inputs/button/button.js +57 -51
  83. package/dist/components/inputs/button/button.js.map +1 -1
  84. package/dist/components/inputs/composer/composer.d.ts +149 -0
  85. package/dist/components/inputs/composer/composer.js +219 -0
  86. package/dist/components/inputs/composer/composer.js.map +1 -0
  87. package/dist/components/inputs/composer/composerFiles.d.ts +26 -0
  88. package/dist/components/inputs/composer/composerFiles.js +19 -0
  89. package/dist/components/inputs/composer/composerFiles.js.map +1 -0
  90. package/dist/components/inputs/composer/composerKeys.d.ts +29 -0
  91. package/dist/components/inputs/composer/composerKeys.js +7 -0
  92. package/dist/components/inputs/composer/composerKeys.js.map +1 -0
  93. package/dist/components/inputs/composer/index.d.ts +3 -0
  94. package/dist/components/layout/sheet/sheet.d.ts +12 -0
  95. package/dist/components/layout/sheet/sheet.js +152 -137
  96. package/dist/components/layout/sheet/sheet.js.map +1 -1
  97. package/dist/components/layout/tabGroups/index.d.ts +4 -0
  98. package/dist/components/layout/tabGroups/tabGroups.d.ts +94 -0
  99. package/dist/components/layout/tabGroups/tabGroups.js +783 -0
  100. package/dist/components/layout/tabGroups/tabGroups.js.map +1 -0
  101. package/dist/components/layout/tabGroups/tabGroupsFocus.d.ts +55 -0
  102. package/dist/components/layout/tabGroups/tabGroupsFocus.js +199 -0
  103. package/dist/components/layout/tabGroups/tabGroupsFocus.js.map +1 -0
  104. package/dist/components/layout/tabGroups/tabGroupsLayout.d.ts +250 -0
  105. package/dist/components/layout/tabGroups/tabGroupsLayout.js +497 -0
  106. package/dist/components/layout/tabGroups/tabGroupsLayout.js.map +1 -0
  107. package/dist/components/navigation/appBar/appBar.d.ts +22 -0
  108. package/dist/components/navigation/appBar/appBar.js +31 -26
  109. package/dist/components/navigation/appBar/appBar.js.map +1 -1
  110. package/dist/components/navigation/commandPalette/commandPalette.d.ts +111 -0
  111. package/dist/components/navigation/commandPalette/commandPalette.js +337 -0
  112. package/dist/components/navigation/commandPalette/commandPalette.js.map +1 -0
  113. package/dist/components/navigation/commandPalette/commandPaletteFilter.d.ts +11 -0
  114. package/dist/components/navigation/commandPalette/commandPaletteFilter.js +16 -0
  115. package/dist/components/navigation/commandPalette/commandPaletteFilter.js.map +1 -0
  116. package/dist/components/navigation/commandPalette/commandPaletteNavigation.d.ts +13 -0
  117. package/dist/components/navigation/commandPalette/commandPaletteNavigation.js +38 -0
  118. package/dist/components/navigation/commandPalette/commandPaletteNavigation.js.map +1 -0
  119. package/dist/components/navigation/commandPalette/commandPaletteTypes.d.ts +39 -0
  120. package/dist/components/navigation/commandPalette/index.d.ts +4 -0
  121. package/dist/components/navigation/sidebar/sidebar.d.ts +10 -0
  122. package/dist/components/navigation/sidebar/sidebar.js +61 -59
  123. package/dist/components/navigation/sidebar/sidebar.js.map +1 -1
  124. package/dist/components/navigation/tabs/index.d.ts +3 -1
  125. package/dist/components/navigation/tabs/tab/tab.d.ts +88 -1
  126. package/dist/components/navigation/tabs/tab/tab.js +121 -42
  127. package/dist/components/navigation/tabs/tab/tab.js.map +1 -1
  128. package/dist/components/navigation/tabs/tabPanel/tabPanel.d.ts +19 -0
  129. package/dist/components/navigation/tabs/tabPanel/tabPanel.js +27 -0
  130. package/dist/components/navigation/tabs/tabPanel/tabPanel.js.map +1 -0
  131. package/dist/components/navigation/tabs/tabs.d.ts +75 -5
  132. package/dist/components/navigation/tabs/tabs.js +369 -145
  133. package/dist/components/navigation/tabs/tabs.js.map +1 -1
  134. package/dist/components/navigation/tabs/tabs.utils.d.ts +20 -0
  135. package/dist/components/navigation/tabs/tabs.utils.js +11 -0
  136. package/dist/components/navigation/tabs/tabs.utils.js.map +1 -0
  137. package/dist/components/navigation/tabs/tabsContext.d.ts +11 -0
  138. package/dist/components/navigation/tabs/tabsContext.js +6 -0
  139. package/dist/components/navigation/tabs/tabsContext.js.map +1 -0
  140. package/dist/headless/components/dataDisplay/chat/chat.js +179 -150
  141. package/dist/headless/components/dataDisplay/chat/chat.js.map +1 -1
  142. package/dist/headless/components/dataDisplay/chat/chatComposer.js +31 -29
  143. package/dist/headless/components/dataDisplay/chat/chatComposer.js.map +1 -1
  144. package/dist/headless/components/dataDisplay/chat/utils.js.map +1 -1
  145. package/dist/headless/components/dataDisplay/dagView/dagLayout.js +289 -0
  146. package/dist/headless/components/dataDisplay/dagView/dagLayout.js.map +1 -0
  147. package/dist/headless/components/dataDisplay/dagView/dagView.js +541 -0
  148. package/dist/headless/components/dataDisplay/dagView/dagView.js.map +1 -0
  149. package/dist/headless/components/dataDisplay/dagView/panZoom.js +171 -0
  150. package/dist/headless/components/dataDisplay/dagView/panZoom.js.map +1 -0
  151. package/dist/headless/components/dataDisplay/diffStat/diffStat.js +61 -0
  152. package/dist/headless/components/dataDisplay/diffStat/diffStat.js.map +1 -0
  153. package/dist/headless/components/dataDisplay/kbd/kbd.js +25 -0
  154. package/dist/headless/components/dataDisplay/kbd/kbd.js.map +1 -0
  155. package/dist/headless/components/dataDisplay/kbd/kbdKeys.js +72 -0
  156. package/dist/headless/components/dataDisplay/kbd/kbdKeys.js.map +1 -0
  157. package/dist/headless/components/dataDisplay/list/listItem.js +47 -24
  158. package/dist/headless/components/dataDisplay/list/listItem.js.map +1 -1
  159. package/dist/headless/components/dataDisplay/qrCode/qrCode.js +76 -0
  160. package/dist/headless/components/dataDisplay/qrCode/qrCode.js.map +1 -0
  161. package/dist/headless/components/dataDisplay/qrCode/qrCodePath.js +20 -0
  162. package/dist/headless/components/dataDisplay/qrCode/qrCodePath.js.map +1 -0
  163. package/dist/headless/components/dataDisplay/statusDot/statusDot.js +28 -0
  164. package/dist/headless/components/dataDisplay/statusDot/statusDot.js.map +1 -0
  165. package/dist/headless/components/dataDisplay/swimlanes/swimlanes.js +355 -0
  166. package/dist/headless/components/dataDisplay/swimlanes/swimlanes.js.map +1 -0
  167. package/dist/headless/components/dataDisplay/treeView/treeView.js +113 -92
  168. package/dist/headless/components/dataDisplay/treeView/treeView.js.map +1 -1
  169. package/dist/headless/components/feedBack/modal/modal.js +85 -64
  170. package/dist/headless/components/feedBack/modal/modal.js.map +1 -1
  171. package/dist/headless/components/inputs/button/button.js +56 -50
  172. package/dist/headless/components/inputs/button/button.js.map +1 -1
  173. package/dist/headless/components/inputs/composer/composer.js +217 -0
  174. package/dist/headless/components/inputs/composer/composer.js.map +1 -0
  175. package/dist/headless/components/inputs/composer/composerFiles.js +19 -0
  176. package/dist/headless/components/inputs/composer/composerFiles.js.map +1 -0
  177. package/dist/headless/components/inputs/composer/composerKeys.js +7 -0
  178. package/dist/headless/components/inputs/composer/composerKeys.js.map +1 -0
  179. package/dist/headless/components/layout/sheet/sheet.js +160 -145
  180. package/dist/headless/components/layout/sheet/sheet.js.map +1 -1
  181. package/dist/headless/components/layout/tabGroups/tabGroups.js +781 -0
  182. package/dist/headless/components/layout/tabGroups/tabGroups.js.map +1 -0
  183. package/dist/headless/components/layout/tabGroups/tabGroupsFocus.js +199 -0
  184. package/dist/headless/components/layout/tabGroups/tabGroupsFocus.js.map +1 -0
  185. package/dist/headless/components/layout/tabGroups/tabGroupsLayout.js +497 -0
  186. package/dist/headless/components/layout/tabGroups/tabGroupsLayout.js.map +1 -0
  187. package/dist/headless/components/navigation/appBar/appBar.js +35 -30
  188. package/dist/headless/components/navigation/appBar/appBar.js.map +1 -1
  189. package/dist/headless/components/navigation/commandPalette/commandPalette.js +335 -0
  190. package/dist/headless/components/navigation/commandPalette/commandPalette.js.map +1 -0
  191. package/dist/headless/components/navigation/commandPalette/commandPaletteFilter.js +16 -0
  192. package/dist/headless/components/navigation/commandPalette/commandPaletteFilter.js.map +1 -0
  193. package/dist/headless/components/navigation/commandPalette/commandPaletteNavigation.js +38 -0
  194. package/dist/headless/components/navigation/commandPalette/commandPaletteNavigation.js.map +1 -0
  195. package/dist/headless/components/navigation/sidebar/sidebar.js +56 -54
  196. package/dist/headless/components/navigation/sidebar/sidebar.js.map +1 -1
  197. package/dist/headless/components/navigation/tabs/tab/tab.js +121 -42
  198. package/dist/headless/components/navigation/tabs/tab/tab.js.map +1 -1
  199. package/dist/headless/components/navigation/tabs/tabPanel/tabPanel.js +25 -0
  200. package/dist/headless/components/navigation/tabs/tabPanel/tabPanel.js.map +1 -0
  201. package/dist/headless/components/navigation/tabs/tabs.js +369 -145
  202. package/dist/headless/components/navigation/tabs/tabs.js.map +1 -1
  203. package/dist/headless/components/navigation/tabs/tabs.utils.js +11 -0
  204. package/dist/headless/components/navigation/tabs/tabs.utils.js.map +1 -0
  205. package/dist/headless/components/navigation/tabs/tabsContext.js +6 -0
  206. package/dist/headless/components/navigation/tabs/tabsContext.js.map +1 -0
  207. package/dist/headless/hooks/useFocusTrap.js +24 -21
  208. package/dist/headless/hooks/useFocusTrap.js.map +1 -1
  209. package/dist/headless/hooks/useKeyboardShortcut.js +19 -0
  210. package/dist/headless/hooks/useKeyboardShortcut.js.map +1 -0
  211. package/dist/headless/index.js +360 -316
  212. package/dist/headless/index.js.map +1 -1
  213. package/dist/headless/utils/keyboardShortcut.js +49 -0
  214. package/dist/headless/utils/keyboardShortcut.js.map +1 -0
  215. package/dist/headless/utils/linkAttrs.js +13 -0
  216. package/dist/headless/utils/linkAttrs.js.map +1 -0
  217. package/dist/headless/utils/restoreFocus.js +10 -0
  218. package/dist/headless/utils/restoreFocus.js.map +1 -0
  219. package/dist/hooks/useFocusTrap.d.ts +6 -1
  220. package/dist/hooks/useFocusTrap.js +24 -21
  221. package/dist/hooks/useFocusTrap.js.map +1 -1
  222. package/dist/hooks/useKeyboardShortcut.d.ts +30 -0
  223. package/dist/hooks/useKeyboardShortcut.js +19 -0
  224. package/dist/hooks/useKeyboardShortcut.js.map +1 -0
  225. package/dist/index.cjs +3 -2
  226. package/dist/index.cjs.map +1 -1
  227. package/dist/index.d.ts +24 -3
  228. package/dist/index.js +360 -316
  229. package/dist/index.js.map +1 -1
  230. package/dist/utils/keyboardShortcut.d.ts +12 -0
  231. package/dist/utils/keyboardShortcut.js +49 -0
  232. package/dist/utils/keyboardShortcut.js.map +1 -0
  233. package/dist/utils/linkAttrs.d.ts +13 -0
  234. package/dist/utils/linkAttrs.js +13 -0
  235. package/dist/utils/linkAttrs.js.map +1 -0
  236. package/dist/utils/restoreFocus.d.ts +8 -0
  237. package/dist/utils/restoreFocus.js +10 -0
  238. package/dist/utils/restoreFocus.js.map +1 -0
  239. package/package.json +5 -3
package/README.md CHANGED
@@ -66,6 +66,482 @@ component sets the same prop there itself, the two meet by these rules, the same
66
66
  `Chip`, `Card` or `MenuItem` (for roving focus, say) replaces the component's default. Passing `undefined` keeps
67
67
  the default.
68
68
 
69
+ ### Composer
70
+
71
+ `Composer` is a task input for chat-style screens: a multi-line field that grows from `minRows` to `maxRows`, a
72
+ `toolbar` slot for chip selectors, a send button that turns into Stop while `busy`, and optional attachments and
73
+ character counter. Ctrl/⌘+Enter sends and Enter adds a line; `sendKey="enter"` swaps that for a reply line.
74
+
75
+ ```tsx
76
+ import { Composer } from "@smwb/ui-react";
77
+
78
+ function TaskInput({ run, cancel, running }: { run: (task: string) => void; cancel: () => void; running: boolean }) {
79
+ return (
80
+ <Composer
81
+ aria-label="Task for the agent"
82
+ placeholder="Describe the task"
83
+ maxLength={2000}
84
+ busy={running}
85
+ onSend={run}
86
+ onStop={cancel}
87
+ toolbar={<span>Agent · Model</span>}
88
+ />
89
+ );
90
+ }
91
+ ```
92
+
93
+ Name the field with `aria-label` or `aria-labelledby`: a placeholder is not a name. `onAttach` turns on the attach
94
+ button and makes the whole composer a drop target for files (dragged text and links still go into the field);
95
+ `accept`, `multiple` and `maxFiles` apply to a pick and to a drop alike, and `onAttach` is never called with an empty
96
+ list. The composer keeps no file state, so show the picked files in the `attachments` slot yourself. The second click
97
+ of a double-click on the action button is ignored, so sending does not stop the run it has just started.
98
+
99
+ `Chat` draws a `Composer` as its footer now, so a chat gets the growing field, the Stop button and the file drop
100
+ target without a separate component. See "Migrating from `ChatComposer`" below for what changed in `Chat`.
101
+
102
+ ### Migrating from `ChatComposer`
103
+
104
+ `Chat` builds its footer out of `Composer` now. `ChatComposer` still works and `Chat` still draws it when you ask for
105
+ it, but both are deprecated and go in the next major.
106
+
107
+ **Stop and the agent's reply** are new props on `Chat`, the same pair `Composer` has: while `busy` the send button
108
+ becomes Stop and calls `onStop`, the keys do not send and the text stays editable. Everything else the composer needs
109
+ goes through one `composerProps` object.
110
+
111
+ ```tsx
112
+ import { Chat } from "@smwb/ui-react";
113
+
114
+ <Chat
115
+ messages={messages}
116
+ onSendMessage={send}
117
+ busy={replying}
118
+ onStop={cancel}
119
+ composerProps={{ maxLength: 2000, counter: true, toolbar: <span>Agent · Model</span> }}
120
+ />;
121
+ ```
122
+
123
+ **Keys.** Enter still sends, but the modifiers swap sides — Ctrl/⌘+Enter now sends instead of adding a line, and
124
+ Alt+Enter no longer sends: it is left to the browser, which adds a line on macOS (`Option+Return`) and does nothing in
125
+ Chromium on Windows and Linux.
126
+
127
+ | Keys in the chat input | `ChatComposer` (before) | `Chat` default `Composer` (after) |
128
+ | --------------------------- | ---------------------------- | --------------------------------- |
129
+ | Enter | send | send |
130
+ | Shift+Enter | newline (`false` → send) | newline, always |
131
+ | Ctrl/⌘+Enter | **newline** (`false` → send) | **send** |
132
+ | Alt+Enter | send | left to the browser |
133
+ | Enter during an IME compose | ignored | ignored |
134
+
135
+ `composerProps={{ sendKey: "modEnter" }}` gives the `Composer` default instead: Enter adds a line, Ctrl/⌘+Enter sends.
136
+ `composerShiftEnterNewline` and `composerCtrlEnterNewline` are deprecated and do nothing unless `legacyComposer` is on.
137
+
138
+ **DOM, classes and test ids.**
139
+
140
+ | Before (`ChatComposer`) | After (`Composer`) |
141
+ | ------------------------------------------------------- | ----------------------------------------------------------------- |
142
+ | `.smwb-chat-composer` | `.smwb-composer` (also `.smwb-chat__composer` on a Chat's footer) |
143
+ | `.smwb-chat-composer__input`, `.smwb-text-field__input` | `.smwb-composer__input` |
144
+ | `chat-composer` | `composer` |
145
+ | `chat-composer-send` | `composer-action` (`data-action="send"` / `"stop"`) |
146
+ | `chat-composer-attach` (a `label`) | `composer-attach` (a focusable `button`) |
147
+ | `chat-file-input` | `composer-file-input` |
148
+
149
+ The textarea is now named "Message" (it had only a placeholder before); override it with
150
+ `composerProps={{ "aria-label": … }}` or `composerProps={{ "aria-labelledby": … }}`. The send button keeps the name
151
+ "Send message".
152
+
153
+ **Keeping the old composer while you migrate:** pass `legacyComposer` and `Chat` draws `ChatComposer` exactly as
154
+ before, with the old DOM, test ids and Enter/Ctrl+Enter semantics. `busy`, `onStop` and `composerProps` are ignored in
155
+ that mode. `composerShiftEnterNewline` and `composerCtrlEnterNewline` work only there.
156
+
157
+ **Porting a standalone `ChatComposer`:**
158
+
159
+ | `ChatComposer` | `Composer` |
160
+ | ------------------------------------------------ | ----------------------------------------- |
161
+ | `onSendMessage` | `onSend` |
162
+ | Enter sends | `sendKey="enter"` |
163
+ | `sendAriaLabel` | `sendLabel` |
164
+ | `attachAriaLabel` | `attachLabel` |
165
+ | `fileUpload` + `attachInputId` / `onAttachClick` | `onAttach` (button, picker and drop zone) |
166
+ | `shiftEnterNewline` | `sendKey="enter"` |
167
+ | `ctrlEnterNewline` | none: Ctrl/⌘+Enter always sends |
168
+ | `className` | `className` |
169
+
170
+ Inside a `Chat` the composer is not a drop target (`dropTarget={false}`): the whole chat shell already is, so a drop
171
+ anywhere — including on the field — uploads once.
172
+
173
+ `ChatComposer`, `shouldComposerNewlineOnEnter`, `ChatComposerNewlineOptions`, `Chat`'s `legacyComposer`,
174
+ `composerShiftEnterNewline` and `composerCtrlEnterNewline` are removed in the next major.
175
+
176
+ ### Tabs, panels and link tabs
177
+
178
+ `Tabs` follows the WAI-ARIA tabs pattern with **manual activation**: the arrow keys along the row's `orientation` (Left
179
+ and Right, Up and Down for `"vertical"`), Home and End move focus between the tabs and skip disabled ones, and Enter or
180
+ Space selects the focused tab (`onChange`, as a click does). Moving focus selects nothing, so a row of links does not
181
+ navigate as the arrows pass it. Only one tab, the focused or else the selected one, is in the page's Tab order; Tab
182
+ leaves the row for the selected panel.
183
+
184
+ The root of `Tabs` has no role. Give the content to `panels` as `TabPanel`s (`index` is the tab's index, counted as for
185
+ `value`): each is `role="tabpanel"`, hidden unless its tab is selected, and linked to its tab by `aria-labelledby`
186
+ and `aria-controls`. Ids derive from `Tabs`' `id` (`{id}-tab-0`, `{id}-panel-0`, ...; generated by default). An `id`
187
+ of your own on a `Tab` wins over the derived one, and its panel is labelled by it; an `id` of your own on a `TabPanel`
188
+ wins too, and then so must your own `aria-controls` on its `Tab`. Give a panel to every tab. Without `panels` the tabs
189
+ link to nothing, as before. A `TabPanel` outside `panels` is not linked to any tabs and is always shown. With
190
+ `scrollButtons` the scroll buttons are for the pointer and stay out of the Tab order: the arrow keys reach every tab and
191
+ scroll it into view, and Tab goes on from the row to the panel.
192
+
193
+ A `Tab` with an `href` is a link tab for route navigation: a real `<a href role="tab">` that never has a `target`.
194
+ The router takes the plain click in the tab's `onClick` with `event.preventDefault()`; `Tabs` still calls `onChange`
195
+ for it. A modified or non-primary click (Ctrl, Meta, Shift or Alt held, or a button other than the main one) is the
196
+ browser's, as "open in a new tab": the router leaves it alone, as a router's `Link` does, and `Tabs` does not call
197
+ `onChange` for it. A disabled link tab has no `href`.
198
+
199
+ A `Tab` can be closable and carry actions, for workspace tabs such as terminal sessions. `closable` draws a close button
200
+ after the tab, and `onClose` is called for it, with the click, and for Delete or Backspace on the focused tab (Backspace
201
+ because a Mac has no Delete key), with the key; the tab does not remove itself, your `onClose` removes it. `onClose`
202
+ also gets what asked for the close, `"button"`, `"key"` or `"actions"`. The button is labelled "Close tab", `closeLabel`
203
+ localises it or names the tab. `actions` is content after the tab and before the close button: a trigger with a `Menu`
204
+ ("Rename", "Terminate"), or any control; given as a function it receives `{ close }`.
205
+
206
+ The close button and the actions are the tab's siblings, not inside its `role="tab"` element: a control inside a tab
207
+ would join the tab's accessible name, so a screen reader reads the tab as its label alone. They are not hidden from
208
+ assistive technology, though. The close button keeps its localisable label because a screen reader user who cannot press
209
+ Delete, as on a touch screen, closes the tab with it, and `actions` is yours. The cost: they sit in the tab list beside
210
+ their tab, and ARIA 1.2 lets a `tablist` own only `tab`s, so a linter that applies that strictly (axe's
211
+ `aria-required-children`) can flag them. The tab says which keys it answers to in `aria-keyshortcuts`: "Delete Backspace"
212
+ when closable, "Shift+F10 ContextMenu" with `actions`.
213
+
214
+ A tab given `closable` or `actions` (even `closable={false}`) is wrapped with them in a `.smwb-tab-item`; one given neither
215
+ is the bare element, as before. `ref`, `className` and the handlers stay on the tab. The row keeps one tab stop: the close
216
+ button has `tabIndex` -1, and so should the trigger in `actions`.
217
+
218
+ The keyboard reaches the actions with Shift+F10 or the Menu key on the focused tab: it focuses the first control in
219
+ `actions` and clicks it (a control that says `aria-expanded="true"` is already open and is left so). If that control
220
+ names its popup in `aria-controls`, focus then moves to the first enabled `menuitem` in it, as the WAI-ARIA menu button
221
+ pattern asks: ArrowDown moves on, Enter chooses, and Escape hands focus back to the trigger, which still counts as focus
222
+ on its tab. Without `aria-controls` focus stays on the trigger. The tab row clips what overflows it, so draw the menu in a
223
+ portal (`createPortal`), anchored to the trigger and with the `id` the trigger names.
224
+
225
+ When the tab that has focus closes, focus moves to the tab that takes its place, the previous one if it was last,
226
+ skipping disabled tabs; a close from a pointer with focus elsewhere takes none. A choice in the menu that removes the tab
227
+ ("Terminate") should call the `close` that `actions` receives, with its event: that runs `onClose` with the reason
228
+ `"actions"` and tells the row that focus, which is in the menu outside it, is to move on. A tab removed some other way
229
+ leaves focus on the page. A link tab can be closable too: the anchor is the tab and the button its sibling.
230
+
231
+ ### Tab groups
232
+
233
+ `TabGroups` is a split area of tab groups: one CSS grid holding any number of tab rows, each with its panels, and a
234
+ handle between neighbours that resizes them. It is the workspace screen — an agent beside two terminals, a log under a
235
+ preview — and the whole arrangement is one JSON value you can store and hand back.
236
+
237
+ The layout holds **ids and sizes only**: no labels, no content, no state. It stays small, and it survives a rename of
238
+ what a tab is called.
239
+
240
+ ```json
241
+ {
242
+ "version": 1,
243
+ "activeGroup": "g1",
244
+ "root": {
245
+ "type": "split",
246
+ "id": "s1",
247
+ "direction": "row",
248
+ "sizes": [50, 50],
249
+ "children": [
250
+ { "type": "group", "id": "g1", "tabs": ["agent"], "active": "agent" },
251
+ { "type": "group", "id": "g2", "tabs": ["term-1", "term-2"], "active": "term-1" }
252
+ ]
253
+ }
254
+ }
255
+ ```
256
+
257
+ A group is a row of `tabs` with the `active` one selected; a split has a `direction` (`"row"` puts its children side by
258
+ side, `"column"` stacks them), `sizes` in percent and `children`. `version` is 1, and it is what
259
+ `tabGroupsLayout.parseTabGroupsLayout` checks before it trusts anything else: that call drops tab ids that are no longer
260
+ open, repairs sizes and node ids, and returns `null` for JSON that is not a layout, which is all the validation a stored
261
+ or shared layout needs. Reading order is depth-first: it is the Tab order and the order a screen reader reads the area
262
+ in, though the panels keep the DOM order they first appeared in. Generated group and split ids are the next free number
263
+ (`g4`, `s3`), so an id can come back after its group closed: key what the app keeps per pane by tab id, not by group id.
264
+
265
+ Give the component a `layout` and it is controlled: it draws what you hand it and reports through
266
+ `onLayoutChange(layout, change)`, where `change` says which of `select`, `activate`, `move`, `split`, `close` and
267
+ `resize` happened (an `activate` is a focus move into another group, not an edit worth storing). Give it a
268
+ `defaultLayout` instead and it keeps the layout itself. `onLayoutChange` is called in both modes, and a controlled owner
269
+ that ignores a change simply sees nothing move. A change that takes the focus with it — a chord, a menu item, a drop
270
+ of the focused tab, the close of a group's last tab — focuses that tab on the first layout the owner draws after it,
271
+ also when the owner applies `onLayoutChange` later (in a transition or a timeout); a layout drawn without the change
272
+ drops it.
273
+
274
+ `getTab(tabId)` says what a tab shows — `label`, `title` for the announcements, `icon`, `closable`, `closeLabel`,
275
+ `actions`, `disabled` — and an id it returns nothing for is drawn by the id itself. `renderPanel(tabId)` says what the
276
+ panel holds. A panel is rendered the first time its tab is selected and stays mounted from then on: hidden while
277
+ another tab of the group is shown, and kept when the tab moves to another group, so a form keeps what was typed and a
278
+ terminal its scrollback. A move changes only where the panel is drawn — its node stays where it is in the DOM — so a
279
+ scrolled panel keeps its scroll, an iframe in it does not reload, and the focus inside it stays, in the moved tab's
280
+ panel as in every other one.
281
+
282
+ The model behind the component is exported as a namespace, so none of its names collide with yours:
283
+
284
+ ```tsx
285
+ import React from "react";
286
+ import { TabGroups, tabGroupsLayout, type TabGroupsLayout, type TabGroupsTab } from "@smwb/ui-react";
287
+
288
+ const TABS: Record<string, TabGroupsTab> = {
289
+ agent: { label: "Agent" },
290
+ "term-1": { label: "Terminal 1" },
291
+ "term-2": { label: "Terminal 2" },
292
+ };
293
+ const KEY = "workspace.layout";
294
+
295
+ /** What storage holds, or a fresh layout: `parseTabGroupsLayout` repairs it or refuses it. */
296
+ function readLayout(tabs: string[]): TabGroupsLayout {
297
+ try {
298
+ const raw = window.localStorage.getItem(KEY);
299
+ const parsed = raw === null ? null : tabGroupsLayout.parseTabGroupsLayout(JSON.parse(raw), { tabs });
300
+ return parsed ?? tabGroupsLayout.createTabGroupsLayout(tabs);
301
+ } catch {
302
+ return tabGroupsLayout.createTabGroupsLayout(tabs);
303
+ }
304
+ }
305
+
306
+ export function Workspace() {
307
+ const [open, setOpen] = React.useState(() => Object.keys(TABS));
308
+ const [layout, setLayout] = React.useState(() => readLayout(open));
309
+ const nextTerminal = React.useRef(3);
310
+ return (
311
+ <div style={{ height: "70vh" }}>
312
+ <button
313
+ type="button"
314
+ onClick={() => {
315
+ const tabId = `term-${nextTerminal.current}`;
316
+ nextTerminal.current += 1;
317
+ setOpen((list) => [...list, tabId]);
318
+ setLayout((current) => tabGroupsLayout.addTab(current, tabId));
319
+ }}
320
+ >
321
+ New terminal
322
+ </button>
323
+ <TabGroups
324
+ layout={layout}
325
+ getTab={(tabId) => TABS[tabId]}
326
+ renderPanel={(tabId) => <pre>{`$ ${tabId}`}</pre>}
327
+ empty={<p>Nothing open.</p>}
328
+ onTabClose={(tabId) => {
329
+ setOpen((list) => list.filter((id) => id !== tabId));
330
+ }}
331
+ onLayoutChange={(next) => {
332
+ setLayout(next);
333
+ try {
334
+ window.localStorage.setItem(KEY, JSON.stringify(next));
335
+ } catch {
336
+ /* storage off: the layout is simply not remembered */
337
+ }
338
+ }}
339
+ />
340
+ </div>
341
+ );
342
+ }
343
+ ```
344
+
345
+ `addTab` is the "New terminal" case: the tab goes into the group the user last worked in, selected. When your own code
346
+ changes which tabs are open — a session loaded, a command closed three of them — `tabGroupsLayout.syncTabs(layout, tabs)`
347
+ makes the layout hold exactly that list: ids that are gone close, and new ones are appended to that same group.
348
+ `removeTab`, `selectTab`, `moveTab`, `resizeSplit` and `setActiveGroup` are the rest of it, and every one returns the
349
+ layout it was given when there is nothing to do.
350
+
351
+ **Keyboard.** Everything is reachable without a mouse, and every move is announced in a `role="status"` region:
352
+
353
+ | Where | Keys | What happens |
354
+ | ------------------------ | --------------------------- | ----------------------------------------------------------------------------------- |
355
+ | A tab row | ← → Home End | move focus between the row's tabs, as `Tabs` does |
356
+ | A tab row | Enter, Space | select the focused tab |
357
+ | The focused tab | Alt+Shift+Arrow | move it into the group that way, or split a new group there when the area ends |
358
+ | The focused tab | Ctrl/⌘+Shift+← → | move it one place along its row, without wrapping |
359
+ | The focused tab | Delete, Backspace, Ctrl/⌘+W | close it, `onTabClose` first |
360
+ | The focused tab | Shift+F10, Menu | open the tab's own `actions` menu |
361
+ | A group's actions button | Enter, Space, ↓ | open the group menu on its first enabled item |
362
+ | The group menu | ↑ ↓ Enter Esc | split right or down, move to the previous or next group, move the tab left or right |
363
+ | A separator | ← → or ↑ ↓ | resize by `keyboardStep` percent of the split (5 by default) |
364
+ | A separator | Home, End | as small as `minGroupSize` (120 px) allows, then as large |
365
+ | Anywhere | Tab | a row, its actions button, its selected panel, the separator after it, the next row |
366
+
367
+ Tab and Shift+Tab follow the reading order from group to group, while inside a row, a panel or a separator the
368
+ browser keeps its own rules: a radio group is one stop (its checked radio), a frame is entered at its first or its last
369
+ stop, and a `TabGroups` nested in a panel is part of that panel, gone through in its own reading order.
370
+
371
+ The group menu acts on that group's selected tab, and an item is disabled when it cannot act: a split needs two tabs to
372
+ leave one behind, and "previous" is grey on the first group in reading order. `groupActions={false}` takes the buttons
373
+ away and leaves the chords and the pointer.
374
+
375
+ **Drag and drop.** Dragging a tab draws a zone over every group's body. The centre of a body drops the tab into that
376
+ group; within a quarter of the body's edge it splits a new group there, and the zone is drawn as that half. The group
377
+ the drag started in refuses its own centre, and its own edge splits only with two tabs or more, so the cursor says "not
378
+ allowed" instead of the drop doing nothing. Dropping on another group's tab row inserts the tab before the one under the
379
+ pointer, or at the end over the row's empty part; a drop on its own row reorders, as `Tabs` does. After a drop the moved
380
+ tab is focused at its new place when it had focus before the drag.
381
+
382
+ **Accessibility.** The rows are `Tabs`, and its caveat applies: a closable tab, or one with `actions`, keeps those
383
+ controls as siblings inside the `tablist`, which a strict `aria-required-children` linter can flag (see above). Each row
384
+ is named "Group 1 of 4" — "Tabs" when there is only one group, so a single group reads as a plain tab row — and the
385
+ group the user is in carries a primary line over its header. Each separator is a `role="separator"` with an
386
+ `aria-orientation` and an `aria-valuenow` of 0–100 (the first of its two neighbours' share of the pair), and each panel
387
+ is a tab stop of its own, so Tab leaves a row for the content it shows. Closing the last tab of a group moves focus to
388
+ the selected tab of the group that takes the space, never to the page. The groups, rows and selected panels render on
389
+ the server; dragging and resizing run in the browser.
390
+
391
+ ### Links in `Button` and `ListItem`
392
+
393
+ A `Button` or a `ListItem` with an `href` is a real link (`<a href>`). By default it opens in a new tab, as `Button`
394
+ always did: `target="_blank"` and `rel="noreferrer"`. For a link inside your app, in a footer list of pages say:
395
+
396
+ - `target={null}` leaves the `target` attribute out, so the link opens in place and has no `rel` of its own. An empty
397
+ string does the same. Any other string is used as is (`"_self"`, or a window name, which gets `rel="noreferrer"`
398
+ like `"_blank"`); `undefined` keeps `"_blank"`.
399
+ - `rel` replaces the default `rel` (`rel=""` drops it), so add `noreferrer` yourself if you still want it.
400
+ - A router takes the plain click in `onClick` with `event.preventDefault()`, and leaves a modified or non-primary click
401
+ (Ctrl, Meta, Shift or Alt held, or a button other than the main one) to the browser, as a router's `Link` does; test
402
+ it before you call the router. Without `preventDefault` the browser follows the `href`.
403
+
404
+ `ListItem` with an `href` puts its `startAdornment` and content in an anchor that fills the row, so the link is the
405
+ row's Tab stop, `Enter` follows it, and the row is not focusable on its own. A disabled link row has no `href`.
406
+
407
+ - The `endAdornment` sits beside the anchor, not in it: a control there (a pin button, a toggle) is not nested in the
408
+ link, and a click on it is not a click of the row, so `onClick` and its `preventDefault` do not see it. Keep the
409
+ `startAdornment` non-interactive.
410
+ - `selected` paints the row and marks the link `aria-current="page"` (a list item cannot be `aria-selected`), which is
411
+ how a list of pages tells assistive technology where the user is.
412
+
413
+ See the `InternalLinks` story of `List`, which stands in for the router with a few lines of state, and `InternalLink`
414
+ of `Button`.
415
+
416
+ ### Sheet: header labels and focus return
417
+
418
+ The icon-only header buttons of `Sheet` are named: `closeAriaLabel` (default `"Close sheet"`) for the close button and,
419
+ for the size toggle, `expandAriaLabel` (`"Expand sheet"`), `collapseAriaLabel` (`"Collapse sheet"`) and
420
+ `restoreAriaLabel` (`"Restore sheet size"`). The toggle cycles normal, expanded, collapsed (header only) and back, and
421
+ its name always says what the next press does, so the name changing is how it exposes its state (it is not an
422
+ `aria-pressed` button). It shows the `open_in_full` icon, and `close_fullscreen` while expanded; both are in the smwb
423
+ icon set (Storybook, **UI/Base/Icon → All Icons**). Pass translated labels for a localized UI.
424
+
425
+ When a `Sheet` opens it remembers the element that had focus (usually the button that opened it) and moves focus into
426
+ the sheet. When it closes (Escape, the close button, the outside click of a modal sheet, `isOpen` turning false, or the
427
+ sheet unmounting while open), focus goes back to that element. Focus the app moved on purpose is left alone: the
428
+ element is focused only while focus is still inside the sheet or has fallen back to the page body, and an element that
429
+ has left the document is skipped.
430
+
431
+ ### Keyboard shortcuts
432
+
433
+ `Kbd` draws a shortcut as key caps, for a `Tooltip` message, beside a `MenuItemText` in a `MenuItem`, or in a command
434
+ palette: `<Kbd keys={["Mod", "K"]} />`. The root and every cap are `<kbd>` elements. `Mod` is the Command symbol on
435
+ macOS and `Ctrl` elsewhere. On macOS `Shift`, `Alt`/`Option`, `Ctrl`/`Control`, `Meta`/`Cmd`/`Command`, `Enter`, `Escape`,
436
+ `Tab`, `Backspace` and `Delete` are drawn as the symbols of the Mac keyboard; elsewhere the same names, in any case, are drawn as
437
+ `Ctrl`, `Alt`, `Shift`, `Meta`, `Enter`, `Esc`, `Tab`, `Backspace` and `Del`. `" "` and `Space` are the Space key, `+` and
438
+ `Plus` the plus key. Arrow names (`ArrowUp` or `Up`, ...) are arrows everywhere, a single letter is
439
+ upper-cased, and any other key is drawn as given. A `+` stands between the caps elsewhere (`separator` changes it), and
440
+ none on macOS.
441
+
442
+ Assistive technology skips the caps and reads one text: "Ctrl+K", or "Command K" on macOS (`label` replaces it). The
443
+ platform is the browser's own, found after the page has mounted: the server, and the first client render, draw the
444
+ non-Mac keys so that hydration matches. Pass `platform="mac"` or `platform="other"` for a fixed look, in stories and tests.
445
+ `detectKbdPlatform()` and `kbdKeyLabel(key, platform)` are exported for your own use.
446
+
447
+ `useKeyboardShortcut` binds one while the component that calls it is mounted, and
448
+ `matchesKeyboardShortcut(event, keys)` answers the same question for your own listener:
449
+
450
+ ```tsx
451
+ import { Kbd, useKeyboardShortcut } from "@smwb/ui-react";
452
+
453
+ const [open, setOpen] = useState(false);
454
+ useKeyboardShortcut({ keys: ["Mod", "K"], onTrigger: () => setOpen((was) => !was) });
455
+
456
+ <Button label="Search" onClick={() => setOpen(true)} endIcon={<Kbd keys={["Mod", "K"]} />} />;
457
+ ```
458
+
459
+ The palette does **not** bind its own key: one binding per app, so two palettes never fight over it
460
+ and any opener works. The listener ignores events another handler already used (`defaultPrevented`),
461
+ key repeats and IME compositions, and prevents the default action on a match — Chrome and Firefox
462
+ otherwise take Ctrl+K for their own search. Modifiers must match exactly (Mod+K does not fire on
463
+ Mod+Shift+K), and a single letter or digit also matches by `event.code`, so `["Mod", "K"]` keeps
464
+ working on a keyboard layout that types other letters.
465
+
466
+ ### Command palette (⌘K)
467
+
468
+ `CommandPalette` is the dialog that key opens: one search field and grouped results, used with the
469
+ keyboard alone. It is built on `Modal` — portaled into `<body>`, `role="dialog"`, closed by Escape
470
+ when it is the top overlay, focus trapped in the field and handed back to the element that opened it.
471
+
472
+ ```tsx
473
+ import { CommandPalette, Kbd, useKeyboardShortcut } from "@smwb/ui-react";
474
+ import type { CommandPaletteGroup, CommandPaletteItem } from "@smwb/ui-react";
475
+
476
+ const groups: CommandPaletteGroup[] = [
477
+ {
478
+ label: "Actions",
479
+ items: [
480
+ {
481
+ id: "new-worktree",
482
+ label: "New worktree",
483
+ icon: "add",
484
+ shortcut: ["Mod", "Shift", "N"],
485
+ onSelect: newWorktree,
486
+ },
487
+ { id: "theme", label: "Toggle dark theme", keywords: ["theme", "night"], onSelect: toggleTheme },
488
+ ],
489
+ },
490
+ { label: "Projects", items: projects.map((p) => ({ id: p.slug, label: p.slug, subtitle: p.owner })) },
491
+ ];
492
+
493
+ <CommandPalette
494
+ open={open}
495
+ onOpenChange={setOpen}
496
+ groups={groups}
497
+ recent={recent}
498
+ footer={
499
+ <>
500
+ <Kbd keys={["Up"]} /> <Kbd keys={["Down"]} /> to move, <Kbd keys={["Enter"]} /> to run, <Kbd keys={["Esc"]} /> to
501
+ close
502
+ </>
503
+ }
504
+ onSelect={(item, group) => pushRecent(item, group)}
505
+ />;
506
+ ```
507
+
508
+ ArrowUp/ArrowDown (and PageUp/PageDown) move the active option, Enter runs it, Escape closes. The
509
+ active option is the first one right away, so Enter runs the top hit; the field keeps the focus and
510
+ points at it with `aria-activedescendant`. An option is named by its label, while its `subtitle` and
511
+ its `Kbd` shortcut are a description, so a screen reader reads "New worktree" and then "acme/web,
512
+ Ctrl+Shift+N". `disabled` items are drawn dimmed and skipped by the arrows.
513
+
514
+ **Recent items are yours.** The palette draws `recent` as its first group while the query is empty
515
+ and never filters it, but it does not store anything: keep the list where your users keep their
516
+ preferences (local storage, a server, per user) and update it from `onSelect`, which reports the item
517
+ and the group it was chosen from. The recent group arrives there as `{ id: "recent", label: recentLabel, items: recent }`.
518
+
519
+ **Async search.** `source(query, { signal })` is called after `debounce` (200 ms) of quiet typing and
520
+ only for a query of at least `minQueryLength` (1) characters. Its groups are appended after the
521
+ static ones. Honour `signal` — it is aborted when a newer query, a close or an unmount makes the
522
+ answer useless — and an answer that arrives late is dropped even if you ignored the signal:
523
+
524
+ ```tsx
525
+ const source = async (query: string, { signal }: { signal: AbortSignal }) => {
526
+ const response = await fetch(`/api/tasks?q=${encodeURIComponent(query)}`, { signal });
527
+ if (!response.ok) throw new Error(`Search failed: ${response.status}`);
528
+ return [{ label: "Tasks", items: (await response.json()).map(toItem) }];
529
+ };
530
+ ```
531
+
532
+ While a request is pending the palette draws skeleton rows and marks the listbox `aria-busy`; a
533
+ rejection that is not an abort shows `errorText` and leaves the static matches usable. Results are
534
+ announced politely once the search settles ("3 results", `noResultsText` for none).
535
+
536
+ **Filtering.** The default `commandPaletteFilter` matches every word of the query against the label,
537
+ the subtitle and `keywords`, ignoring case and accents ("cafe" finds "Café menu"). It never reorders.
538
+ If you rank yourself or search on a server, pass `filter={false}` and send the groups in your order.
539
+ Both `commandPaletteFilter` and `normalizeSearchText` are exported.
540
+
541
+ Filtering and rendering are linear, so cap what you pass — 50 items per group is a good ceiling — or
542
+ rank on the server. Every text is a prop (`placeholder`, `searchLabel`, `noResultsText`, `errorText`,
543
+ `recentLabel`, `resultsAnnouncement`), so a localized UI needs no wrapper.
544
+
69
545
  ## Icons
70
546
 
71
547
  `<Icon>` renders one of two icon sets under the **same name**, so switching sets never touches
@@ -241,6 +717,184 @@ needed. Fill them in order: slots 8–12 are unset by default and emit nothing,
241
717
  colours as are set. An unset slot is skipped, not kept as a gap: if you set slot 9 but not 8, slot 9's
242
718
  colour is drawn for the 8th series. A colour you give in the chart's own `option.color` wins over the theme's.
243
719
 
720
+ ### Change size (DiffStat)
721
+
722
+ ```tsx
723
+ import { DiffStat } from "@smwb/ui-react";
724
+
725
+ <DiffStat added={128} removed={37} />
726
+ <DiffStat added={128} removed={37} compact />
727
+ ```
728
+
729
+ How big a change is: `+N −M` (the minus is U+2212, not a hyphen) in the theme's success and error colours, with a
730
+ bar of five cells beside it whose shares match the two figures; the cells a side leaves over are neutral, so `0` and
731
+ `0` leave every cell neutral. Each side takes `floor(5 × its share)` cells, so a side under a fifth of the change reaches
732
+ no cell and shows in its figure only. `compact` is the tab variant: smaller figures and no bar (`bar` turns the bar on
733
+ or off either way). Counts are written with `group`, three digits per group split by U+202F NARROW NO-BREAK SPACE
734
+ (`12 345`) by default; the separator is fixed rather than taken from a locale because the server and the browser
735
+ would group the same figure differently and break hydration, so an app that knows its reader's locale passes
736
+ `group={(n) => n.toLocaleString(locale)}`. Fractions are dropped, a count below zero or one that is not a number (`NaN`, `undefined`, `Infinity`) counts as `0`
737
+ in the figures, the name and the bar alike, and the count is otherwise shown as given, without abbreviating (`1.2k` is
738
+ left to the caller's `group`).
739
+
740
+ The whole is one `role="img"` with one accessible name, `added 12, removed 3`, and the digits and the cells are
741
+ hidden from assistive technology so nothing is read twice. Translate the name with `format={(added, removed) => ...}`
742
+ (it gets the two counts as numbers, so the default name reads plain digits, `added 12345, removed 3`, and a localiser
743
+ can write them as it likes, e.g. `добавлено 12, удалено 3`), or replace it with `aria-label`. The figures and the
744
+ cells use the theme's success and error colours mixed toward the surface ink (`@diffStatColorMix` in your
745
+ `theme.config`, 55% by default), which keeps text contrast at 5.4:1 or better on the page and the surface in both the
746
+ light and the dark theme; a cell nobody owns is an outline of the surface ink (`@diffStatNeutralMix`, 60%), 3.69:1 or
747
+ better against the page in every bundled theme, so it reads as empty next to a filled cell. See Storybook → **Data Display / DiffStat**.
748
+
749
+ ### Status marker (StatusDot)
750
+
751
+ ```tsx
752
+ import { StatusDot } from "@smwb/ui-react";
753
+
754
+ <StatusDot status="active" label="Working" />;
755
+ ```
756
+
757
+ `status` is `active` (info, pulses), `attention` (warning), `idle` (neutral), `done` (success), `error` or `unknown`
758
+ (a ring in the outline colour; also the default). `color` takes the theme colour names `Chip` takes and, with no
759
+ `status`, makes a plain disc. `size` is `md` (12px) or `sm` (8px); `pulse` turns the ring on or off (on for `active`
760
+ only, and never drawn under `prefers-reduced-motion: reduce`). `label` is required: the dot is a `role="img"` named
761
+ by it, so screen readers hear the status. For sighted users the filled statuses differ by colour only (`idle` is
762
+ solid and `unknown` a ring), so show the status in text or a tooltip where it matters. Each status colour reaches 3:1 against the surface in the light and dark
763
+ themes (see `@smwb/ui-styles`).
764
+
765
+ ### Participant lanes (Swimlanes)
766
+
767
+ ```tsx
768
+ import { Swimlanes } from "@smwb/ui-react";
769
+
770
+ <Swimlanes
771
+ label="Agent team"
772
+ lanes={[
773
+ {
774
+ id: "anna",
775
+ header: <Who name="Anna" role="Team lead" />,
776
+ content: <Now item="Release notes" done={7} of={12} />,
777
+ },
778
+ { id: "ilya", header: <Who name="Ilya" role="Frontend" />, content: <Now item="Sticky column" done={3} of={9} /> },
779
+ { id: "vera", header: <Who name="Vera" role="Design" />, content: <Now item="Migration guide" done={0} of={6} /> },
780
+ ]}
781
+ onSelectedChange={(id) => openDetails(id)}
782
+ />;
783
+ ```
784
+
785
+ One row per participant: a header on the left (an avatar, a name, a role) and the content on the right (the current
786
+ item, progress, the last signal, the budget). A lane is `{ id, header, content }`, where `id` is stable and unique —
787
+ it keys the row, the selection and the Tab stop — and `header` and `content` are any nodes. A `disabled` lane stays
788
+ readable and focusable but cannot be selected. Rows are keyed by `id`, so a `lanes` array rebuilt on every refresh
789
+ (`participants.map(toLane)`) keeps its rows, their cells and the focus, and the new content is drawn in place.
790
+
791
+ The header column stays put while the content scrolls sideways under it. `headerWidth` is its width (240px by
792
+ default; a number of pixels or any CSS length) and `contentMinWidth` the narrowest the content column can be, so a
793
+ grid too narrow for the two together scrolls sideways. Cells clip, so content that needs more room needs a wider
794
+ `contentMinWidth`; an ellipsis inside it is yours.
795
+
796
+ Every lane is `rowHeight` tall (64px by default) — the rows are virtualised, which needs a fixed height — and the
797
+ grid is `height` tall (480px from the stylesheet, so a height in `style` works too). Only the lanes in view and
798
+ `overscan` more above and below them (4 by default) are in the DOM, so 200 lanes cost about 16 rows, while
799
+ `aria-rowcount` still reports all of them and each row carries its own `aria-rowindex`. On the server, and at the
800
+ first paint before the grid can measure itself, the window is the one `height` implies, so the browser hydrates the
801
+ markup the server sent.
802
+
803
+ A click in a lane, or Enter or Space on its focused cell, selects it: `selectedId` / `defaultSelectedId` /
804
+ `onSelectedChange(id | null)`, controlled while `selectedId` is not `undefined`, where `null` means "controlled,
805
+ nothing selected". Escape clears the selection, and only when there is one, so an enclosing dialog still gets the
806
+ key. A click on a control _inside_ a lane selects nothing — that click belongs to the control. `selectable={false}`
807
+ turns selection off altogether: nothing is painted or exposed, a `selectedId` given anyway is ignored and Escape is
808
+ left alone. A selected lane that leaves `lanes` clears the selection through `onSelectedChange(null)`.
809
+
810
+ For assistive technology the grid is a `role="grid"` named by `label` (your `aria-labelledby` wins), two columns
811
+ wide, each lane's header a `rowheader`. Focus is on cells, and the grid is **one Tab stop**: the cell focused last,
812
+ else the selected lane's header, else the first lane's header. Links and buttons inside a lane stay reachable — Tab
813
+ from the active cell walks that lane's own controls and then leaves the grid — so there is no cell edit mode to
814
+ switch into and no F2 to press. Focus on a control inside a lane (a click on its button) makes that lane the Tab stop,
815
+ and the lane holding focus stays in the DOM when it scrolls out of the window and keeps focus when it scrolls back.
816
+
817
+ | Key | Moves to |
818
+ | ---------------------- | -------------------------------------------------------------------------------- |
819
+ | ArrowDown / ArrowUp | the next or the previous lane, same column; no wrap at the ends |
820
+ | ArrowRight / ArrowLeft | the content cell / the header cell of the same lane |
821
+ | Home / End | the first / the last cell of the row |
822
+ | Ctrl+Home / Ctrl+End | the first / the last lane, same column |
823
+ | PageDown / PageUp | a page of lanes: the rows that fit, less one so a row of context stays on screen |
824
+ | Enter / Space | selects the lane |
825
+ | Escape | clears the selection |
826
+
827
+ A disabled lane takes focus like any other and is not skipped. Keys pressed inside a lane's own control belong to
828
+ that control, and combinations with Ctrl, Cmd or Alt are the browser's, apart from Ctrl+Home and Ctrl+End. With
829
+ `lanes={[]}` the root carries no grid role at all — an empty grid has no rows — and shows `emptyLabel` instead.
830
+ Colours come from the theme's runtime tokens, so the dark theme follows with no extra rules. See Storybook → **Data
831
+ Display / Swimlanes**.
832
+
833
+ ### QR code (QrCode)
834
+
835
+ ```tsx
836
+ import { QrCode } from "@smwb/ui-react";
837
+
838
+ <QrCode value="https://mateme.io/pair" caption="Scan with the phone app" />;
839
+ ```
840
+
841
+ `value` (a URL, a pairing token) is encoded as UTF-8 bytes and drawn as an SVG image, `role="img"` named
842
+ "QR code" (rename with `label`). The modules are dark on a white square — the quiet zone is part of the
843
+ white — in every theme, so a phone camera reads the code in dark mode too; the colours are fixed and never
844
+ follow the theme. `size` is the side in pixels, quiet zone included (192 by default); keep at least ~4px per
845
+ module, so a long payload needs a bigger `size` (or a shorter value). `errorCorrection` is `L`/`M` (default)/
846
+ `Q`/`H` — the share of the code that may be damaged or covered; `quietZone` is the light margin in modules
847
+ (4, the spec minimum). `caption` renders a `<figcaption>` under the code, and the figure is named by it.
848
+ A `value` past the QR capacity does not throw: the code renders an empty white square with
849
+ `data-error="too-long"`, named "QR code: value too long". Any other encode failure (a lone surrogate fails in
850
+ `encodeURI`) renders the same empty square with `data-error="encode-failed"`, named "QR code: value could not be
851
+ encoded". Neither throws, and each cause is reported once — a code bound to a growing text field does not add a
852
+ console line per keystroke. The value itself is not exposed to assistive technology (tokens may be secret), so show
853
+ a copyable link or code next to the QR for users who cannot scan.
854
+ Encoding runs on `uqr`, a new runtime dependency of `@smwb/ui-react` (external in the bundle, like `clsx`).
855
+ See Storybook → **Data Display / QR Code**.
856
+
857
+ ### Window title bar (`AppBar variant="window"`)
858
+
859
+ ```tsx
860
+ import { AppBar } from "@smwb/ui-react";
861
+
862
+ <AppBar
863
+ variant="window"
864
+ windowControls="left"
865
+ title="Untitled document"
866
+ leftContent={<Button icon={<MenuIcon />} variant="text" aria-label="Main menu" onClick={openMenu} />}
867
+ rightContent={<Button label="Login" variant="contained" size="small" />}
868
+ />;
869
+ ```
870
+
871
+ `variant="window"` makes the bar the title bar of a desktop app window (Electron, Tauri, an installed PWA with
872
+ Window Controls Overlay): the bar becomes a drag region (`-webkit-app-region: drag`), so dragging it moves the
873
+ window. Interactive content stays clickable, which matters because Electron delivers no mouse events at all
874
+ inside a drag region. The styles already take `no-drag` on buttons, links, fields, anything focusable or
875
+ role-tagged, and on the library's tooltip wrapper; a custom control that is neither focusable nor role-tagged (a
876
+ plain `div` with an `onClick`) opts out with the `smwb-appbar__no-drag` class or the `data-no-drag` attribute.
877
+ Overlays are excluded from the drag region too — they portal to `<body>`, out of the bar's subtree, while Electron
878
+ hit-tests drag regions by geometry, not by the DOM tree. That covers the layers carrying `data-smwb-overlay-layer`,
879
+ the `Menu`, `Tooltip` and Snackbar popups, and the library's own full-screen portals (the Chat attachment lightbox,
880
+ the ImagesList lightbox, the ThumbCard preview and the ChatShell panel). Roles that keep `tabindex="-1"` while their
881
+ group holds the tab stop (`menuitemcheckbox`, `menuitemradio`, `treeitem`, `spinbutton`, `gridcell`, `listbox`,
882
+ `scrollbar`) are in the list as well, because the focusable rule alone cannot see them. Nothing is inherited by
883
+ `children`, so a bar of your own markup drags the same way.
884
+
885
+ `windowControls` keeps the content clear of the window buttons the OS draws: `left` for the macOS traffic lights,
886
+ `right` for the Windows/Linux caption buttons, `none` to reserve nothing, and `auto` (the default) for the area the
887
+ shell reports through `env(titlebar-area-*)` — under Window Controls Overlay and Electron's `titleBarOverlay` that
888
+ is the exact rectangle, and in a plain browser tab nothing is reserved. The platform is never sniffed: the app
889
+ knows its own platform, so the server and the browser render the same classes and nothing shifts after hydration.
890
+ In Electron, `titleBarStyle: "hiddenInset"` goes with `windowControls="left"` and `titleBarOverlay` with `"auto"`;
891
+ set the overlay's height to the bar's (65px, or 48px with `dense`).
892
+
893
+ The reserved width is `@appBarWindowControlsLeft` (64px) and `@appBarWindowControlsRight` (138px), overridable at
894
+ runtime for both sides with `--smui-appBarWindowControlsWidth` (see `@smwb/ui-styles`). A bar without `variant`
895
+ renders exactly as before. See Storybook → **Navigation / AppBar**: `WindowTitleBar`, `WindowTitleBarWindows`,
896
+ `WindowDense`.
897
+
244
898
  ## Customization and theming
245
899
 
246
900
  Pick the shallowest layer that solves the task: