@smwb/ui-react 2.8.0 → 2.10.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.
- package/AI_LESS_OVERRIDES.md +84 -21
- package/AI_THEMING.md +72 -28
- package/README.md +654 -0
- package/dist/assets/styles/less/components/appBar/appBar.entry.css +1 -1
- package/dist/assets/styles/less/components/badge/badge.entry.css +1 -1
- package/dist/assets/styles/less/components/button/button.entry.css +1 -1
- package/dist/assets/styles/less/components/checkbox/checkbox.entry.css +1 -1
- package/dist/assets/styles/less/components/chip/chip.entry.css +1 -1
- package/dist/assets/styles/less/components/commandPalette/commandPalette.entry.css +1 -0
- package/dist/assets/styles/less/components/composer/composer.entry.css +1 -0
- package/dist/assets/styles/less/components/dagView/dagView.entry.css +1 -0
- package/dist/assets/styles/less/components/diffStat/diffStat.entry.css +1 -0
- package/dist/assets/styles/less/components/kbd/kbd.entry.css +1 -0
- package/dist/assets/styles/less/components/list/list.entry.css +1 -1
- package/dist/assets/styles/less/components/qrCode/qrCode.entry.css +1 -0
- package/dist/assets/styles/less/components/radioButton/radioButton.entry.css +1 -1
- package/dist/assets/styles/less/components/selectField/selectField.entry.css +1 -1
- package/dist/assets/styles/less/components/sidebar/sidebar.entry.css +1 -1
- package/dist/assets/styles/less/components/statusDot/statusDot.entry.css +1 -0
- package/dist/assets/styles/less/components/swimlanes/swimlanes.entry.css +1 -0
- package/dist/assets/styles/less/components/tabGroups/tabGroups.entry.css +1 -0
- package/dist/assets/styles/less/components/tabs/tab/tab.entry.css +1 -1
- package/dist/assets/styles/less/components/tabs/tabPanel/tabPanel.entry.css +1 -0
- package/dist/assets/styles/less/components/tabs/tabs.entry.css +1 -1
- package/dist/assets/styles/less/components/textField/textField.entry.css +1 -1
- package/dist/assets/styles/less/components/toggle/toggle.entry.css +1 -1
- package/dist/assets/styles/less/components/treeView/treeView.entry.css +1 -1
- package/dist/assets/styles/less/tokens.entry.css +1 -1
- package/dist/components/dataDisplay/badge/badge.d.ts +2 -1
- package/dist/components/dataDisplay/badge/badge.js.map +1 -1
- package/dist/components/dataDisplay/chat/chat.d.ts +43 -1
- package/dist/components/dataDisplay/chat/chat.js +179 -150
- package/dist/components/dataDisplay/chat/chat.js.map +1 -1
- package/dist/components/dataDisplay/chat/chatComposer.d.ts +17 -0
- package/dist/components/dataDisplay/chat/chatComposer.js +31 -29
- package/dist/components/dataDisplay/chat/chatComposer.js.map +1 -1
- package/dist/components/dataDisplay/chat/index.d.ts +1 -1
- package/dist/components/dataDisplay/chat/utils.d.ts +14 -0
- package/dist/components/dataDisplay/chat/utils.js.map +1 -1
- package/dist/components/dataDisplay/dagView/dagLayout.d.ts +74 -0
- package/dist/components/dataDisplay/dagView/dagLayout.js +289 -0
- package/dist/components/dataDisplay/dagView/dagLayout.js.map +1 -0
- package/dist/components/dataDisplay/dagView/dagView.d.ts +157 -0
- package/dist/components/dataDisplay/dagView/dagView.js +543 -0
- package/dist/components/dataDisplay/dagView/dagView.js.map +1 -0
- package/dist/components/dataDisplay/dagView/index.d.ts +4 -0
- package/dist/components/dataDisplay/dagView/panZoom.d.ts +75 -0
- package/dist/components/dataDisplay/dagView/panZoom.js +171 -0
- package/dist/components/dataDisplay/dagView/panZoom.js.map +1 -0
- package/dist/components/dataDisplay/diffStat/diffStat.d.ts +54 -0
- package/dist/components/dataDisplay/diffStat/diffStat.js +63 -0
- package/dist/components/dataDisplay/diffStat/diffStat.js.map +1 -0
- package/dist/components/dataDisplay/diffStat/index.d.ts +2 -0
- package/dist/components/dataDisplay/kbd/index.d.ts +4 -0
- package/dist/components/dataDisplay/kbd/kbd.d.ts +40 -0
- package/dist/components/dataDisplay/kbd/kbd.js +27 -0
- package/dist/components/dataDisplay/kbd/kbd.js.map +1 -0
- package/dist/components/dataDisplay/kbd/kbdKeys.d.ts +18 -0
- package/dist/components/dataDisplay/kbd/kbdKeys.js +72 -0
- package/dist/components/dataDisplay/kbd/kbdKeys.js.map +1 -0
- package/dist/components/dataDisplay/list/listItem.d.ts +34 -0
- package/dist/components/dataDisplay/list/listItem.js +47 -24
- package/dist/components/dataDisplay/list/listItem.js.map +1 -1
- package/dist/components/dataDisplay/qrCode/index.d.ts +2 -0
- package/dist/components/dataDisplay/qrCode/qrCode.d.ts +28 -0
- package/dist/components/dataDisplay/qrCode/qrCode.js +78 -0
- package/dist/components/dataDisplay/qrCode/qrCode.js.map +1 -0
- package/dist/components/dataDisplay/qrCode/qrCodePath.d.ts +7 -0
- package/dist/components/dataDisplay/qrCode/qrCodePath.js +20 -0
- package/dist/components/dataDisplay/qrCode/qrCodePath.js.map +1 -0
- package/dist/components/dataDisplay/statusDot/index.d.ts +2 -0
- package/dist/components/dataDisplay/statusDot/statusDot.d.ts +35 -0
- package/dist/components/dataDisplay/statusDot/statusDot.js +30 -0
- package/dist/components/dataDisplay/statusDot/statusDot.js.map +1 -0
- package/dist/components/dataDisplay/swimlanes/index.d.ts +2 -0
- package/dist/components/dataDisplay/swimlanes/swimlanes.d.ts +53 -0
- package/dist/components/dataDisplay/swimlanes/swimlanes.js +357 -0
- package/dist/components/dataDisplay/swimlanes/swimlanes.js.map +1 -0
- package/dist/components/dataDisplay/treeView/treeView.d.ts +17 -1
- package/dist/components/dataDisplay/treeView/treeView.js +113 -92
- package/dist/components/dataDisplay/treeView/treeView.js.map +1 -1
- package/dist/components/feedBack/modal/modal.js +84 -63
- package/dist/components/feedBack/modal/modal.js.map +1 -1
- package/dist/components/inputs/button/button.d.ts +13 -1
- package/dist/components/inputs/button/button.js +57 -51
- package/dist/components/inputs/button/button.js.map +1 -1
- package/dist/components/inputs/composer/composer.d.ts +149 -0
- package/dist/components/inputs/composer/composer.js +219 -0
- package/dist/components/inputs/composer/composer.js.map +1 -0
- package/dist/components/inputs/composer/composerFiles.d.ts +26 -0
- package/dist/components/inputs/composer/composerFiles.js +19 -0
- package/dist/components/inputs/composer/composerFiles.js.map +1 -0
- package/dist/components/inputs/composer/composerKeys.d.ts +29 -0
- package/dist/components/inputs/composer/composerKeys.js +7 -0
- package/dist/components/inputs/composer/composerKeys.js.map +1 -0
- package/dist/components/inputs/composer/index.d.ts +3 -0
- package/dist/components/layout/sheet/sheet.d.ts +12 -0
- package/dist/components/layout/sheet/sheet.js +152 -137
- package/dist/components/layout/sheet/sheet.js.map +1 -1
- package/dist/components/layout/tabGroups/index.d.ts +4 -0
- package/dist/components/layout/tabGroups/tabGroups.d.ts +94 -0
- package/dist/components/layout/tabGroups/tabGroups.js +783 -0
- package/dist/components/layout/tabGroups/tabGroups.js.map +1 -0
- package/dist/components/layout/tabGroups/tabGroupsFocus.d.ts +55 -0
- package/dist/components/layout/tabGroups/tabGroupsFocus.js +199 -0
- package/dist/components/layout/tabGroups/tabGroupsFocus.js.map +1 -0
- package/dist/components/layout/tabGroups/tabGroupsLayout.d.ts +250 -0
- package/dist/components/layout/tabGroups/tabGroupsLayout.js +497 -0
- package/dist/components/layout/tabGroups/tabGroupsLayout.js.map +1 -0
- package/dist/components/navigation/appBar/appBar.d.ts +22 -0
- package/dist/components/navigation/appBar/appBar.js +31 -26
- package/dist/components/navigation/appBar/appBar.js.map +1 -1
- package/dist/components/navigation/commandPalette/commandPalette.d.ts +111 -0
- package/dist/components/navigation/commandPalette/commandPalette.js +337 -0
- package/dist/components/navigation/commandPalette/commandPalette.js.map +1 -0
- package/dist/components/navigation/commandPalette/commandPaletteFilter.d.ts +11 -0
- package/dist/components/navigation/commandPalette/commandPaletteFilter.js +16 -0
- package/dist/components/navigation/commandPalette/commandPaletteFilter.js.map +1 -0
- package/dist/components/navigation/commandPalette/commandPaletteNavigation.d.ts +13 -0
- package/dist/components/navigation/commandPalette/commandPaletteNavigation.js +38 -0
- package/dist/components/navigation/commandPalette/commandPaletteNavigation.js.map +1 -0
- package/dist/components/navigation/commandPalette/commandPaletteTypes.d.ts +39 -0
- package/dist/components/navigation/commandPalette/index.d.ts +4 -0
- package/dist/components/navigation/sidebar/sidebar.d.ts +10 -0
- package/dist/components/navigation/sidebar/sidebar.js +61 -59
- package/dist/components/navigation/sidebar/sidebar.js.map +1 -1
- package/dist/components/navigation/tabs/index.d.ts +3 -1
- package/dist/components/navigation/tabs/tab/tab.d.ts +88 -1
- package/dist/components/navigation/tabs/tab/tab.js +121 -42
- package/dist/components/navigation/tabs/tab/tab.js.map +1 -1
- package/dist/components/navigation/tabs/tabPanel/tabPanel.d.ts +19 -0
- package/dist/components/navigation/tabs/tabPanel/tabPanel.js +27 -0
- package/dist/components/navigation/tabs/tabPanel/tabPanel.js.map +1 -0
- package/dist/components/navigation/tabs/tabs.d.ts +79 -6
- package/dist/components/navigation/tabs/tabs.js +369 -145
- package/dist/components/navigation/tabs/tabs.js.map +1 -1
- package/dist/components/navigation/tabs/tabs.utils.d.ts +20 -0
- package/dist/components/navigation/tabs/tabs.utils.js +11 -0
- package/dist/components/navigation/tabs/tabs.utils.js.map +1 -0
- package/dist/components/navigation/tabs/tabsContext.d.ts +11 -0
- package/dist/components/navigation/tabs/tabsContext.js +6 -0
- package/dist/components/navigation/tabs/tabsContext.js.map +1 -0
- package/dist/headless/components/dataDisplay/badge/badge.js.map +1 -1
- package/dist/headless/components/dataDisplay/chat/chat.js +179 -150
- package/dist/headless/components/dataDisplay/chat/chat.js.map +1 -1
- package/dist/headless/components/dataDisplay/chat/chatComposer.js +31 -29
- package/dist/headless/components/dataDisplay/chat/chatComposer.js.map +1 -1
- package/dist/headless/components/dataDisplay/chat/utils.js.map +1 -1
- package/dist/headless/components/dataDisplay/dagView/dagLayout.js +289 -0
- package/dist/headless/components/dataDisplay/dagView/dagLayout.js.map +1 -0
- package/dist/headless/components/dataDisplay/dagView/dagView.js +541 -0
- package/dist/headless/components/dataDisplay/dagView/dagView.js.map +1 -0
- package/dist/headless/components/dataDisplay/dagView/panZoom.js +171 -0
- package/dist/headless/components/dataDisplay/dagView/panZoom.js.map +1 -0
- package/dist/headless/components/dataDisplay/diffStat/diffStat.js +61 -0
- package/dist/headless/components/dataDisplay/diffStat/diffStat.js.map +1 -0
- package/dist/headless/components/dataDisplay/kbd/kbd.js +25 -0
- package/dist/headless/components/dataDisplay/kbd/kbd.js.map +1 -0
- package/dist/headless/components/dataDisplay/kbd/kbdKeys.js +72 -0
- package/dist/headless/components/dataDisplay/kbd/kbdKeys.js.map +1 -0
- package/dist/headless/components/dataDisplay/list/listItem.js +47 -24
- package/dist/headless/components/dataDisplay/list/listItem.js.map +1 -1
- package/dist/headless/components/dataDisplay/qrCode/qrCode.js +76 -0
- package/dist/headless/components/dataDisplay/qrCode/qrCode.js.map +1 -0
- package/dist/headless/components/dataDisplay/qrCode/qrCodePath.js +20 -0
- package/dist/headless/components/dataDisplay/qrCode/qrCodePath.js.map +1 -0
- package/dist/headless/components/dataDisplay/statusDot/statusDot.js +28 -0
- package/dist/headless/components/dataDisplay/statusDot/statusDot.js.map +1 -0
- package/dist/headless/components/dataDisplay/swimlanes/swimlanes.js +355 -0
- package/dist/headless/components/dataDisplay/swimlanes/swimlanes.js.map +1 -0
- package/dist/headless/components/dataDisplay/treeView/treeView.js +113 -92
- package/dist/headless/components/dataDisplay/treeView/treeView.js.map +1 -1
- package/dist/headless/components/feedBack/modal/modal.js +85 -64
- package/dist/headless/components/feedBack/modal/modal.js.map +1 -1
- package/dist/headless/components/inputs/button/button.js +56 -50
- package/dist/headless/components/inputs/button/button.js.map +1 -1
- package/dist/headless/components/inputs/composer/composer.js +217 -0
- package/dist/headless/components/inputs/composer/composer.js.map +1 -0
- package/dist/headless/components/inputs/composer/composerFiles.js +19 -0
- package/dist/headless/components/inputs/composer/composerFiles.js.map +1 -0
- package/dist/headless/components/inputs/composer/composerKeys.js +7 -0
- package/dist/headless/components/inputs/composer/composerKeys.js.map +1 -0
- package/dist/headless/components/layout/sheet/sheet.js +160 -145
- package/dist/headless/components/layout/sheet/sheet.js.map +1 -1
- package/dist/headless/components/layout/tabGroups/tabGroups.js +781 -0
- package/dist/headless/components/layout/tabGroups/tabGroups.js.map +1 -0
- package/dist/headless/components/layout/tabGroups/tabGroupsFocus.js +199 -0
- package/dist/headless/components/layout/tabGroups/tabGroupsFocus.js.map +1 -0
- package/dist/headless/components/layout/tabGroups/tabGroupsLayout.js +497 -0
- package/dist/headless/components/layout/tabGroups/tabGroupsLayout.js.map +1 -0
- package/dist/headless/components/navigation/appBar/appBar.js +35 -30
- package/dist/headless/components/navigation/appBar/appBar.js.map +1 -1
- package/dist/headless/components/navigation/commandPalette/commandPalette.js +335 -0
- package/dist/headless/components/navigation/commandPalette/commandPalette.js.map +1 -0
- package/dist/headless/components/navigation/commandPalette/commandPaletteFilter.js +16 -0
- package/dist/headless/components/navigation/commandPalette/commandPaletteFilter.js.map +1 -0
- package/dist/headless/components/navigation/commandPalette/commandPaletteNavigation.js +38 -0
- package/dist/headless/components/navigation/commandPalette/commandPaletteNavigation.js.map +1 -0
- package/dist/headless/components/navigation/sidebar/sidebar.js +56 -54
- package/dist/headless/components/navigation/sidebar/sidebar.js.map +1 -1
- package/dist/headless/components/navigation/tabs/tab/tab.js +121 -42
- package/dist/headless/components/navigation/tabs/tab/tab.js.map +1 -1
- package/dist/headless/components/navigation/tabs/tabPanel/tabPanel.js +25 -0
- package/dist/headless/components/navigation/tabs/tabPanel/tabPanel.js.map +1 -0
- package/dist/headless/components/navigation/tabs/tabs.js +369 -145
- package/dist/headless/components/navigation/tabs/tabs.js.map +1 -1
- package/dist/headless/components/navigation/tabs/tabs.utils.js +11 -0
- package/dist/headless/components/navigation/tabs/tabs.utils.js.map +1 -0
- package/dist/headless/components/navigation/tabs/tabsContext.js +6 -0
- package/dist/headless/components/navigation/tabs/tabsContext.js.map +1 -0
- package/dist/headless/hooks/useFocusTrap.js +24 -21
- package/dist/headless/hooks/useFocusTrap.js.map +1 -1
- package/dist/headless/hooks/useKeyboardShortcut.js +19 -0
- package/dist/headless/hooks/useKeyboardShortcut.js.map +1 -0
- package/dist/headless/index.js +360 -316
- package/dist/headless/index.js.map +1 -1
- package/dist/headless/utils/keyboardShortcut.js +49 -0
- package/dist/headless/utils/keyboardShortcut.js.map +1 -0
- package/dist/headless/utils/linkAttrs.js +13 -0
- package/dist/headless/utils/linkAttrs.js.map +1 -0
- package/dist/headless/utils/restoreFocus.js +10 -0
- package/dist/headless/utils/restoreFocus.js.map +1 -0
- package/dist/hooks/useFocusTrap.d.ts +6 -1
- package/dist/hooks/useFocusTrap.js +24 -21
- package/dist/hooks/useFocusTrap.js.map +1 -1
- package/dist/hooks/useKeyboardShortcut.d.ts +30 -0
- package/dist/hooks/useKeyboardShortcut.js +19 -0
- package/dist/hooks/useKeyboardShortcut.js.map +1 -0
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +24 -3
- package/dist/index.js +360 -316
- package/dist/index.js.map +1 -1
- package/dist/utils/keyboardShortcut.d.ts +12 -0
- package/dist/utils/keyboardShortcut.js +49 -0
- package/dist/utils/keyboardShortcut.js.map +1 -0
- package/dist/utils/linkAttrs.d.ts +13 -0
- package/dist/utils/linkAttrs.js +13 -0
- package/dist/utils/linkAttrs.js.map +1 -0
- package/dist/utils/restoreFocus.d.ts +8 -0
- package/dist/utils/restoreFocus.js +10 -0
- package/dist/utils/restoreFocus.js.map +1 -0
- 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:
|