@sckyzo/cosmos-react 0.4.0 → 0.4.1
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/CHANGELOG.md +23 -0
- package/README.md +19 -1691
- package/dist/index.cjs +117 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +117 -49
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,38 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@sckyzo/cosmos-react)
|
|
4
4
|
|
|
5
|
-
React 19 component library for the Cosmos design system
|
|
6
|
-
|
|
5
|
+
React 19 component library for the Cosmos design system: themeable, accessible components,
|
|
6
|
+
built with Tailwind 4 and shipped as ESM, CJS and types.
|
|
7
7
|
|
|
8
8
|
Browse the full catalog in the [live Storybook](https://sckyzo.github.io/cosmos/).
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
npm install @sckyzo/cosmos-react @sckyzo/cosmos-theme
|
|
14
|
+
npm install react react-dom # peers, react 19+
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
`@sckyzo/cosmos-theme` carries the CSS (tokens, fonts, theme and accent vars). The
|
|
18
|
-
components render unstyled without it.
|
|
18
|
+
components render unstyled without it. The three Cosmos packages share one version number:
|
|
19
|
+
install the same version of each.
|
|
19
20
|
|
|
20
21
|
## Set up the styles
|
|
21
22
|
|
|
22
|
-
Components are styled with Tailwind 4 utility classes, so your app's Tailwind
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
Cosmos utility classes are generated and every component renders unstyled.
|
|
23
|
+
Components are styled with Tailwind 4 utility classes, so your app's Tailwind build has to
|
|
24
|
+
generate them. Tailwind does not scan `node_modules`, so you **must** point `@source` at the
|
|
25
|
+
installed package: without it no Cosmos class is generated and every component renders unstyled.
|
|
26
26
|
|
|
27
27
|
In your main CSS file:
|
|
28
28
|
|
|
29
29
|
```css
|
|
30
30
|
@import 'tailwindcss';
|
|
31
31
|
|
|
32
|
-
/* Required
|
|
33
|
-
The path is relative to this CSS file; adjust the depth to your layout. */
|
|
32
|
+
/* Required. The path is relative to this CSS file; adjust the depth to your layout. */
|
|
34
33
|
@source '../node_modules/@sckyzo/cosmos-react/dist/**/*.{js,cjs}';
|
|
35
34
|
|
|
36
|
-
/* Cosmos tokens (the @theme block) then the theme and accent CSS vars. */
|
|
37
35
|
@import '@sckyzo/cosmos-theme/tailwind';
|
|
38
36
|
@import '@sckyzo/cosmos-theme/vars';
|
|
39
37
|
|
|
@@ -44,14 +42,6 @@ In your main CSS file:
|
|
|
44
42
|
@import '@sckyzo/cosmos-react/calendar.css';
|
|
45
43
|
```
|
|
46
44
|
|
|
47
|
-
`Calendar` and `DatePicker` wrap [react-day-picker](https://daypicker.dev), whose
|
|
48
|
-
structural styles are not bundled in the JS, so the package stays loadable with
|
|
49
|
-
`require` in Node and Jest. `calendar.css` is the explicit export that loads them;
|
|
50
|
-
without it the calendar grid renders unstyled. Import it once, after Tailwind.
|
|
51
|
-
|
|
52
|
-
If you also use `@sckyzo/cosmos-charts`, add a second `@source` line for its
|
|
53
|
-
`dist` the same way.
|
|
54
|
-
|
|
55
45
|
## Use
|
|
56
46
|
|
|
57
47
|
```tsx
|
|
@@ -72,1678 +62,16 @@ function Example() {
|
|
|
72
62
|
}
|
|
73
63
|
```
|
|
74
64
|
|
|
75
|
-
##
|
|
76
|
-
|
|
77
|
-
`ref` is a regular prop (React 19), so no component needs `forwardRef`. Fields,
|
|
78
|
-
buttons and custom controls point it at the element you would focus, for
|
|
79
|
-
instance to put the focus on the first invalid field:
|
|
80
|
-
|
|
81
|
-
```tsx
|
|
82
|
-
import { useRef } from 'react';
|
|
83
|
-
import { Input } from '@sckyzo/cosmos-react';
|
|
84
|
-
|
|
85
|
-
const emailRef = useRef<HTMLInputElement>(null);
|
|
86
|
-
|
|
87
|
-
<Input ref={emailRef} label="Email" />;
|
|
88
|
-
// on a validation error: emailRef.current?.focus()
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
The `ref` prop of each component says what it points to: the native `<input>`
|
|
92
|
-
for text, number and search fields, the switch `<button>` for `Toggle`, the first
|
|
93
|
-
cell for `OtpInput`, the trigger for `DatePicker`, and the segment you would Tab
|
|
94
|
-
to for `SegmentedControl`, in either appearance (the selected one, else the first).
|
|
95
|
-
|
|
96
|
-
## Links and your router
|
|
97
|
-
|
|
98
|
-
A component with an `href` (`Sidebar.Item`, `Breadcrumb`, `Menu.Item`, `Link`, `Tag`, a `Row`, a
|
|
99
|
-
`DataTable` row…) draws a plain `<a>`, so a click loads the page again. Mount `LinkProvider` once, at
|
|
100
|
-
the root of the app, to send those links through your router instead: it renders the component you
|
|
101
|
-
give in place of the `<a>`, so a click navigates in place, and middle-click and "Open in a new tab"
|
|
102
|
-
keep working, the element still being a real link. `cosmos-react` imports no router; the adapter is
|
|
103
|
-
yours.
|
|
104
|
-
|
|
105
|
-
```tsx
|
|
106
|
-
import { Link } from 'react-router';
|
|
107
|
-
import { LinkProvider, type LinkComponentProps } from '@sckyzo/cosmos-react';
|
|
108
|
-
|
|
109
|
-
// Define it once, at module level, and hand on every prop (`ref`, `className`, `onClick`, `aria-*`).
|
|
110
|
-
const AppLink = ({ href, ...props }: LinkComponentProps) => <Link to={href} {...props} />;
|
|
111
|
-
|
|
112
|
-
<LinkProvider component={AppLink}>{app}</LinkProvider>;
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Next.js's `Link` takes `href` as it is: `<LinkProvider component={NextLink}>`. Without a provider
|
|
116
|
-
nothing changes. A link with `target="_blank"`, a link with no `href` or a bare `#`, and the links in
|
|
117
|
-
chat messages stay a plain `<a>`, and a provider inside another one replaces it for its subtree.
|
|
118
|
-
|
|
119
|
-
## Variants worth knowing
|
|
120
|
-
|
|
121
|
-
- `Tabs variant="pills"`: rounded triggers on a tinted track, the active one filled with the accent, horizontal or vertical.
|
|
122
|
-
- `Tabs variant="raised"`: the same neutral gray track, but the active tab is raised on it in white (a lighter gray in the dark themes) with no accent, horizontal or vertical.
|
|
123
|
-
- `Toggle size="sm"`: a 36×20 track; the label stays in the same `<label>`.
|
|
124
|
-
- `Textarea autoResize maxRows={6}`: `rows` is the minimum, the field grows with its content up to `maxRows`, then scrolls.
|
|
125
|
-
- `Input`, `Select` and `Textarea` `success="Username is available."`: the message under a field whose value is valid, with a green border (and a check icon on `Input` and `Select`). It is linked as the description and announced politely; `error` wins when both are set. `OtpInput success`: a green border on every cell, the message is yours.
|
|
126
|
-
- `SegmentedControl` options take a `badge` (a count in a small pill, part of the radio's accessible name).
|
|
127
|
-
- `PageHeader icon={Plug} iconIntent="brand"`: a decorative icon tile left of the title.
|
|
128
|
-
- `PageHeader breadcrumb={…} description="…"`: the breadcrumb sits above the title and the description under it; both can be passed together. Below `sm` the `actions` wrap under the meta, at the start of the line, and the icon tile stays beside the text column.
|
|
129
|
-
- `ErrorState onRetry={reload} retryLabel="Reload" action={<Button>Back to tickets</Button>}`: `retryLabel` renames the retry button (default "Try again"); `action` is a way out that is not a retry, next to it (the retry first), or alone without `onRetry`.
|
|
130
|
-
- `EmptyState` `children`: rendered under the description and before the `action`, start-aligned and up to 28rem wide: a short numbered list of setup steps, for instance.
|
|
131
|
-
- `FormRow`: label and description at the start, the control at the end. Below `sm` they stack, the label and its description above the control, which takes the row's width (a `Select` with `fullWidth={false}` stays at its own width, at the start).
|
|
132
|
-
- `StickyScrollNav sections={[{ id, label }]}` (a long page with anchors, such as a settings page): below `sm` the row of buttons folds into a native select, named "Jump to section" (`jumpLabel`), that shows the section being read and scrolls to the one picked, below the nav like the buttons do. From `sm` up nothing changes. `VerticalNavigation` is a list of links to pages, not anchors, and keeps its look.
|
|
133
|
-
- `KpiCard value="€12.4M" exactValue="€12,418,906.40"`: the exact figure behind an abbreviated value. A tooltip shows it on hover and focus, and it is read as part of the card's name ("…€12.4M, exact value €12,418,906.40"). On a plain card the value becomes a tab stop to reach the tooltip; a link or button card shows it when the card takes keyboard focus, and stays one tab stop.
|
|
134
|
-
- `Rating value={4.5} allowHalf readOnly showValue count="(128 reviews)"`: stars on the warning hue with an outlined empty star, announced as `role="img"` ("Rated 4.5 out of 5"). Without `readOnly` it is a `radiogroup` (one tab stop, arrows, Home/End, hover preview) with `defaultValue`/`value` and `onChange(value)`; `0` is "no rating".
|
|
135
|
-
- `Carousel.Thumbnails`: a scrolling row of buttons under the slides, one child per slide, named "Go to slide N" with `aria-current` on the current one and scrolled into view as it changes.
|
|
136
|
-
- `ProfileHeader`: the top of a profile page. A `cover` (a gradient of the accent's tints by default), an `avatar` overlapping its bottom edge, the `name` with `badges`, a `headline` and `meta`, `actions`, `stats` read as a `<dl>`, and a `tabs` slot for a `Tabs.List` under a border. Below `sm` it stacks and the actions share the width.
|
|
137
|
-
- `ImageGrid layout="masonry"`: CSS columns, each picture keeping its aspect with its caption under it. Items flow down the first column, then the next; give the images `width` and `height` so the columns don't rebalance as they load.
|
|
138
|
-
- `ConfirmationModal intent="danger" requireText="my-project"`: a red tile and a solid action, disabled until the exact (case-sensitive) text is typed; `requireTextLabel` replaces the label.
|
|
139
|
-
- `SecretValue label="API key" value={key} visibleSuffix={4}`: a stored secret, masked until revealed. While masked the real value is not in the DOM (20 bullets plus the suffix, at most half of the value); the Copy button copies the real value without revealing it. `autoHideAfter` (ms) masks it again.
|
|
140
|
-
- `Link intent="danger"`: a `colored` link in `success`, `warning` or `danger` in place of the accent; the other variants have no color of their own and ignore it.
|
|
141
|
-
- `ProgressBar intent="neutral"`: a gray fill for a gauge that means nothing (space used, a quota). Segments and `ProgressRing` take it too.
|
|
142
|
-
- `Badge size="lg"` and `shape="rounded"`: a larger badge, and `rounded-md` corners in place of the pill.
|
|
143
|
-
- `Carousel.Indicators position="inside"`: the dots at the bottom center of the slide, 12px from the edge, on a dark pill. Keep the default when `Carousel.Controls` is `outside` too: its row sits under the slide.
|
|
144
|
-
- `Ribbon shape="pill"`: a fully rounded label set inside the card, 12px from the corner of its `position`.
|
|
145
|
-
|
|
146
|
-
## Tables
|
|
147
|
-
|
|
148
|
-
`DataTable` sorts, filters, searches and paginates its rows. Give it a height
|
|
149
|
-
(`className="max-h-96"`) and the rows scroll under the header, in an area the
|
|
150
|
-
keyboard can focus and scroll while it overflows; `aria-label` or
|
|
151
|
-
`aria-labelledby` names the table, and that area as a region. For a short
|
|
152
|
-
read-only table, compose the table primitives instead: `Table`, `TableCaption`,
|
|
153
|
-
`TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` and
|
|
154
|
-
`TableCell` render the native elements with the Cosmos styling and no logic.
|
|
155
|
-
|
|
156
|
-
```tsx
|
|
157
|
-
import {
|
|
158
|
-
Table,
|
|
159
|
-
TableBody,
|
|
160
|
-
TableCaption,
|
|
161
|
-
TableCell,
|
|
162
|
-
TableHead,
|
|
163
|
-
TableHeaderCell,
|
|
164
|
-
TableRow,
|
|
165
|
-
} from '@sckyzo/cosmos-react';
|
|
166
|
-
|
|
167
|
-
<Table striped>
|
|
168
|
-
<TableCaption>Nodes</TableCaption>
|
|
169
|
-
<TableHead>
|
|
170
|
-
<TableRow>
|
|
171
|
-
<TableHeaderCell>Name</TableHeaderCell>
|
|
172
|
-
<TableHeaderCell numeric>CPU</TableHeaderCell>
|
|
173
|
-
</TableRow>
|
|
174
|
-
</TableHead>
|
|
175
|
-
<TableBody>
|
|
176
|
-
<TableRow>
|
|
177
|
-
<TableCell>node-101</TableCell>
|
|
178
|
-
<TableCell numeric>42%</TableCell>
|
|
179
|
-
</TableRow>
|
|
180
|
-
</TableBody>
|
|
181
|
-
</Table>;
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
`DataTable` shows a muted two-way icon on every sortable column, keeps its
|
|
185
|
-
header (and the width of its selection and action columns) while it loads, is
|
|
186
|
-
empty or fails, and takes `loadingRows` for the number of placeholder rows. Its
|
|
187
|
-
rows can act: `onRowClick(row)` makes them focusable (Enter and Space) and
|
|
188
|
-
clickable, `rowHref(row)` makes each one a link (a real `<a>` in the first
|
|
189
|
-
column, drawn over the row, so middle click and "Open in a new tab" work; keep
|
|
190
|
-
buttons and links out of that column). A click on a control inside the row (the
|
|
191
|
-
checkbox, the action column, a link or button in another cell) never counts as a
|
|
192
|
-
row click. `RowActionsMenu` is the ⋮ button and menu of a row, for
|
|
193
|
-
`renderRowAction`: give it `Menu.Item`s, a destructive one with
|
|
194
|
-
`intent="danger"`. On a narrow screen the edge of a table that scrolls sideways
|
|
195
|
-
fades, and `Table`'s `stickyFirstColumn` pins its first column (`DataTable` pins
|
|
196
|
-
columns with `pin`, below). `TableIdentityCell`, `TableIdCell`, `TableDescriptionCell` and
|
|
197
|
-
`TableAmountCell` are the cells tables keep rebuilding.
|
|
198
|
-
|
|
199
|
-
`DataTable`'s column filters can be controlled: `filters`, `defaultFilters` and
|
|
200
|
-
`onFiltersChange(filters)` hold a record of column `key` to the set of selected
|
|
201
|
-
values. `filterTabs` (`{ columnKey, options: [{ value, label, count }], allLabel,
|
|
202
|
-
allCount }`) puts underlined status tabs at the top of the card, each setting
|
|
203
|
-
that column's filter ("All" clears it) and staying in sync with the active
|
|
204
|
-
filters bar; the counts come from you, the table never computes them.
|
|
205
|
-
|
|
206
|
-
`DataTable` takes `mobileLayout="cards"` to show each row as a block below `sm`
|
|
207
|
-
instead of scrolling: the checkbox, the column marked `mobile: 'title'` and the
|
|
208
|
-
row action on the first line, then a label/value pair per other column
|
|
209
|
-
(`mobileLabel` renames a label, `mobile: 'hidden'` drops a column). The toolbar
|
|
210
|
-
gets a "Sort by" select for the sortable columns and, when a column is
|
|
211
|
-
filterable, a "Filters" button with the number of values filtered: it opens a
|
|
212
|
-
bottom sheet with the filter of each filterable column, "Clear all" and "Show n
|
|
213
|
-
rows", and sets the same `filters` as the column headers, controlled or not. The
|
|
214
|
-
default, `scroll`, keeps the table.
|
|
215
|
-
|
|
216
|
-
With `selectable`, a selection puts a tinted bar on one row: the count, "Clear"
|
|
217
|
-
and the bulk actions on the start side, the search and the toolbar on the end
|
|
218
|
-
side. `bulkActionItems` takes the actions as data (`{ key, label, icon,
|
|
219
|
-
intent, disabled, onClick(selectedRows) }`) so the bar can adapt: the first is a
|
|
220
|
-
button and, under 1152px of bar, the others fold into an "Actions" menu; the
|
|
221
|
-
toolbar buttons go icon-only and the search narrows under 1360px; under 640px
|
|
222
|
-
the bar wraps to two lines; with `mobileLayout="cards"` on a phone each action is
|
|
223
|
-
an icon button named by its `label`. `bulkActions(selectedRows)` still renders
|
|
224
|
-
any node after them, as given at every width. While rows are selected the
|
|
225
|
-
toolbar's Export follows them ("Export 3" downloads those rows, in the table's
|
|
226
|
-
sort order, and `onExport` gets their keys as `selection`); with none selected it
|
|
227
|
-
exports the filtered rows.
|
|
228
|
-
|
|
229
|
-
`emptyAction` puts a call to action under the empty state ("Create the first
|
|
230
|
-
order"). When a search or filters hide every row it gives way to a "Clear
|
|
231
|
-
filters" button, which clears both.
|
|
232
|
-
|
|
233
|
-
`DataTable` rows can expand: `renderExpandedRow(row)` adds a first column of
|
|
234
|
-
chevron buttons (`aria-expanded`, `aria-controls` the detail row while it is
|
|
235
|
-
open, named "Show details for <row key>") and draws what it returns in a row
|
|
236
|
-
below, spanning every column. The open rows are a set of row keys, like the
|
|
237
|
-
selection: `expandedRows`, `defaultExpandedRows` and
|
|
238
|
-
`onExpandedRowsChange(expanded)`. A toggle never selects a row nor counts as a
|
|
239
|
-
click on it. Limit: with `virtualize` nothing expands (its rows have one fixed
|
|
240
|
-
height), and the prop is ignored. With `mobileLayout="cards"` the chevron sits on
|
|
241
|
-
the card's title line and the content opens inside the card, under its fields.
|
|
242
|
-
|
|
243
|
-
`pin: 'start' | 'end'` on a column keeps it in place while the table scrolls
|
|
244
|
-
sideways, several per side: list the `start` ones first and the `end` ones last
|
|
245
|
-
in `columns`. A pinned cell sticks after the widths of the pinned columns before
|
|
246
|
-
it, measured, so percentage widths, a resize and the text size keep it right.
|
|
247
|
-
The selection and toggle columns are pinned with the start columns, the action
|
|
248
|
-
column with the end ones. A shadow marks an edge only while content scrolls
|
|
249
|
-
under it, and the scroll fade leaves a pinned side alone. `pin` replaces
|
|
250
|
-
`DataTable`'s `stickyFirstColumn` (removed): put `pin: 'start'` on the first
|
|
251
|
-
column. With `rowHref`, the link covers its own column when that is pinned and
|
|
252
|
-
a click on the rest of the row follows it from a handler. The cards of
|
|
253
|
-
`mobileLayout="cards"` ignore `pin`.
|
|
254
|
-
|
|
255
|
-
```tsx
|
|
256
|
-
<DataTable
|
|
257
|
-
rows={hosts}
|
|
258
|
-
rowKey={(r) => r.host}
|
|
259
|
-
selectable
|
|
260
|
-
renderExpandedRow={(r) => <HostDetail host={r} />}
|
|
261
|
-
columns={[
|
|
262
|
-
{ key: 'host', label: 'Host', pin: 'start', render: (r) => r.host },
|
|
263
|
-
{ key: 'rack', label: 'Rack', render: (r) => r.rack },
|
|
264
|
-
// …
|
|
265
|
-
{
|
|
266
|
-
key: 'status',
|
|
267
|
-
label: 'Status',
|
|
268
|
-
pin: 'end',
|
|
269
|
-
render: (r) => <StatusBadge status={r.status} />,
|
|
270
|
-
},
|
|
271
|
-
]}
|
|
272
|
-
/>
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Inline editing: `edit` on a column is `{ initialValue(row), render(editor) }`,
|
|
276
|
-
and `render` draws an existing form field from the editor: `editor.value` (the
|
|
277
|
-
draft, a string: parse it yourself), `setValue`, `commit`, `cancel`, `saving`
|
|
278
|
-
and `fieldProps` (the field's name, "Rack for node-a02", its error tie, `disabled`
|
|
279
|
-
while saving and a red border on `aria-invalid`), which an `Input` or a `Select`
|
|
280
|
-
takes as it is. `editTrigger` picks how an editable cell opens, one per table:
|
|
281
|
-
|
|
282
|
-
- `'cell'` (default): the cell's content is a button that fills it; a click,
|
|
283
|
-
Enter or F2 opens the editor in place, focused. Keep that content to text and
|
|
284
|
-
badges, with no link or button in it.
|
|
285
|
-
- `'button'`: a pencil button in each editable cell ("Edit Rack for node-a02"),
|
|
286
|
-
shown on the hovered or focused row and always on a screen with no hover; the
|
|
287
|
-
editor has Save and Cancel buttons beside the field.
|
|
288
|
-
- `'row'`: an Edit button in the row's action column turns every editable cell
|
|
289
|
-
of the row into its field, with Cancel and Save in place of the button.
|
|
290
|
-
|
|
291
|
-
In `'cell'` and `'button'`, Enter or blur saves and Escape cancels, putting the
|
|
292
|
-
focus back on the cell's trigger. `onCellEdit(row, columnKey, value)` is called
|
|
293
|
-
only when the draft changed, and may return a promise: the cell is `aria-busy`
|
|
294
|
-
with a spinner until it settles. If it rejects (or throws), the editor opens
|
|
295
|
-
again with what was typed, and the error's message shows under the cell, tied to
|
|
296
|
-
the field by `aria-describedby`. In `'row'`, Save (or Enter in a field) calls
|
|
297
|
-
`onRowEdit(row, changes)` once, `changes` being column `key` to draft for the
|
|
298
|
-
cells that changed only; a promise makes the row `aria-busy` and the fields
|
|
299
|
-
disabled, and a rejection keeps the fields open with the error in a row under
|
|
300
|
-
the row. Escape or Cancel drops the changes. Only one cell or row edits at a
|
|
301
|
-
time, and nothing opens while a save is pending. The table never changes
|
|
302
|
-
`rows`: update them from the handler. Limits: editing is off with `virtualize`
|
|
303
|
-
(a fixed-height cell would clip an editor and its error) and in the cards of
|
|
304
|
-
`mobileLayout="cards"`.
|
|
305
|
-
|
|
306
|
-
```tsx
|
|
307
|
-
<DataTable
|
|
308
|
-
rows={hosts}
|
|
309
|
-
rowKey={(r) => r.host}
|
|
310
|
-
editTrigger="cell"
|
|
311
|
-
onCellEdit={async (row, key, value) => {
|
|
312
|
-
await api.patchHost(row.host, { [key]: value }); // a rejection shows under the cell
|
|
313
|
-
setHosts((all) => all.map((h) => (h.host === row.host ? { ...h, [key]: value } : h)));
|
|
314
|
-
}}
|
|
315
|
-
columns={[
|
|
316
|
-
{ key: 'host', label: 'Host', render: (r) => r.host },
|
|
317
|
-
{
|
|
318
|
-
key: 'rack',
|
|
319
|
-
label: 'Rack',
|
|
320
|
-
render: (r) => r.rack,
|
|
321
|
-
edit: {
|
|
322
|
-
initialValue: (r) => r.rack,
|
|
323
|
-
render: (e) => (
|
|
324
|
-
<Input
|
|
325
|
-
{...e.fieldProps}
|
|
326
|
-
size="sm"
|
|
327
|
-
value={e.value}
|
|
328
|
-
onChange={(ev) => e.setValue(ev.target.value)}
|
|
329
|
-
/>
|
|
330
|
-
),
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
]}
|
|
334
|
-
/>
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
`DataTable`'s `density` is controllable like the rest: `density`, `defaultDensity`
|
|
338
|
-
and `onDensityChange(density)`: `compact`, `default` or `comfortable`. Without
|
|
339
|
-
`density` and `defaultDensity` the table follows the page's `data-density`; set,
|
|
340
|
-
it is a local override of the page. As with `pageSize`, a `density` passed without
|
|
341
|
-
`onDensityChange` is a controlled value that never moves, so a saved view
|
|
342
|
-
reports its density and the table keeps the one you pass.
|
|
343
|
-
|
|
344
|
-
`exportable` adds an Export button before Columns (the toolbar shows even
|
|
345
|
-
without a search field). It downloads a CSV of every page of the rows that the
|
|
346
|
-
search, filters and sort keep, in the visible columns and their order; the
|
|
347
|
-
selection and action columns stay out. A header is the column's `label` when it
|
|
348
|
-
is a string or a number, else `mobileLabel` when that is a string, else the
|
|
349
|
-
`key`. A cell takes `exportValue(row)`, else `sortValue`, `searchValue` or
|
|
350
|
-
`filterValue` (the first the column has); `null` is an empty cell. The file is
|
|
351
|
-
named by `exportFileName` (default `export.csv`) and is UTF-8 with a byte order
|
|
352
|
-
mark (U+FEFF), so that spreadsheets read accents. Fields follow RFC 4180
|
|
353
|
-
(quoted when they hold `"`, `,` or a line break, records end with CRLF), and a
|
|
354
|
-
header or string value that starts with `=`, `+`, `-`, `@`, a tab or a carriage
|
|
355
|
-
return gets a `'` prefix so a spreadsheet never runs it as a formula; a
|
|
356
|
-
`number` is left alone. To export on the server, `onExport(query)` receives `{ search, filters,
|
|
357
|
-
sort, columns }` (the visible column keys, in order, plus `selection`, the
|
|
358
|
-
selected keys, while rows are selected) and builds no file.
|
|
359
|
-
`toCsv(rows, columns)` gives the same text, without the byte order mark, for an
|
|
360
|
-
export outside a table (prepend U+FEFF for a file meant for spreadsheets).
|
|
361
|
-
|
|
362
|
-
```tsx
|
|
363
|
-
<DataTable
|
|
364
|
-
rows={rows}
|
|
365
|
-
columns={[
|
|
366
|
-
{ key: 'name', label: 'Name', render: (r) => r.name, sortValue: (r) => r.name },
|
|
367
|
-
{
|
|
368
|
-
key: 'cpu',
|
|
369
|
-
label: 'CPU %',
|
|
370
|
-
render: (r) => <Gauge value={r.cpu} />,
|
|
371
|
-
exportValue: (r) => r.cpu,
|
|
372
|
-
},
|
|
373
|
-
]}
|
|
374
|
-
rowKey={(r) => r.id}
|
|
375
|
-
exportable
|
|
376
|
-
exportFileName="nodes.csv"
|
|
377
|
-
/>
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
On paper a `DataTable` drops its toolbar, bulk-action bar, filter tabs, pager
|
|
381
|
-
and the selection and action columns, prints every row of the page instead of
|
|
382
|
-
scrolling, repeats the header on each page and keeps a row on one page. There is
|
|
383
|
-
no Print button: use the browser's. The theme is not switched, so a dark theme
|
|
384
|
-
prints its own colours: pick a light one for print (or `@media print` in your CSS).
|
|
385
|
-
|
|
386
|
-
Saved views: `views` (or `defaultViews`) shows a Views button in the toolbar.
|
|
387
|
-
A view is plain JSON, `{ id, name, search, filters, sort, columnVisibility,
|
|
388
|
-
pageSize, density }` (`filters` maps a column key to an array of values, `sort`
|
|
389
|
-
is `{ key, direction }` or `null`, `pageSize` is a number or `'all'` for "Show
|
|
390
|
-
all"), so you can keep the list anywhere: the table never stores it. Pass
|
|
391
|
-
`views` and `onViewsChange(views)`, or `defaultViews` and listen to
|
|
392
|
-
`onViewsChange`; `activeView` / `defaultActiveView` / `onActiveViewChange(id)`
|
|
393
|
-
hold the `id` of the applied view, or `null`. The menu applies a view, saves the
|
|
394
|
-
current state as a new one ("Save as…"), renames the active one or deletes it
|
|
395
|
-
(after a confirmation); a name must be filled and not used by another view.
|
|
396
|
-
Applying a view sets each part through the table's own state, so a part you
|
|
397
|
-
control reports its own `on*Change`, and the table goes back to the first page.
|
|
398
|
-
The button shows the active view's name, with "(modified)" once the table no
|
|
399
|
-
longer matches it. The `DataTableView`, `DataTableSort` and `DataTableExportQuery`
|
|
400
|
-
types are exported.
|
|
401
|
-
|
|
402
|
-
```tsx
|
|
403
|
-
const [views, setViews] = useState<DataTableView[]>(() => load());
|
|
404
|
-
<DataTable
|
|
405
|
-
rows={rows}
|
|
406
|
-
columns={columns}
|
|
407
|
-
rowKey={(r) => r.id}
|
|
408
|
-
views={views}
|
|
409
|
-
onViewsChange={(next) => {
|
|
410
|
-
setViews(next);
|
|
411
|
-
save(next);
|
|
412
|
-
}}
|
|
413
|
-
/>;
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
Server mode: `manual` turns the in-memory search, filters, sort and pagination
|
|
417
|
-
off. `rows` is the page you fetched, drawn in its order, and the controls only
|
|
418
|
-
report: `onSearchChange`, `onFiltersChange`, `onSortChange`, `onPageChange` and
|
|
419
|
-
`onPageSizeChange` (a change of search, filters, sort or page size goes back to
|
|
420
|
-
page 1 first). `rowCount` is the total across every page, for the pager and its
|
|
421
|
-
"Showing X to Y of Z"; without it the pager counts `rows.length`. A filter
|
|
422
|
-
dropdown lists the values found in `rows`, which in `manual` are one page, so a
|
|
423
|
-
filterable column takes `filterOptions` (`['Paid', 'Pending']`, in the order to
|
|
424
|
-
show) instead; it works without `manual` too. "Show all" is not offered, and
|
|
425
|
-
the built-in CSV export only holds the loaded rows: pass `onExport` to export on
|
|
426
|
-
the server.
|
|
427
|
-
|
|
428
|
-
The sort and the page are controlled with or without `manual`: `sort`,
|
|
429
|
-
`defaultSort` and `onSortChange(sort)` hold a `DataTableSort` (`{ key,
|
|
430
|
-
direction }` or `null`), and `page`, `defaultPage` and `onPageChange(page)` are
|
|
431
|
-
1-indexed like `Pagination`. A controlled table shows a new value once you pass
|
|
432
|
-
it back, and a change that resets the page calls `onPageChange(1)` (not when it
|
|
433
|
-
is on page 1 already).
|
|
434
|
-
|
|
435
|
-
`loading` with rows on screen keeps them, dimmed and inert (nothing in them can
|
|
436
|
-
be clicked meanwhile), under a centred spinner, with the pager still there and
|
|
437
|
-
`aria-busy` on the table; with no rows yet it draws the `loadingRows`
|
|
438
|
-
placeholders as before. Use it for every request, the first or the next page.
|
|
439
|
-
|
|
440
|
-
```tsx
|
|
441
|
-
const [sort, setSort] = useState<DataTableSort>(null);
|
|
442
|
-
const [page, setPage] = useState(1);
|
|
443
|
-
const { data, isFetching } = useOrders({ sort, page });
|
|
444
|
-
<DataTable
|
|
445
|
-
manual
|
|
446
|
-
rows={data?.rows ?? []}
|
|
447
|
-
rowCount={data?.total}
|
|
448
|
-
loading={isFetching}
|
|
449
|
-
columns={columns}
|
|
450
|
-
rowKey={(o) => o.id}
|
|
451
|
-
sort={sort}
|
|
452
|
-
onSortChange={setSort}
|
|
453
|
-
page={page}
|
|
454
|
-
onPageChange={setPage}
|
|
455
|
-
/>;
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
`virtualize` draws only the rows in view and a few around them, for thousands of
|
|
459
|
-
rows on one page (a large `pageSize`, or "Show all"). Two `aria-hidden` spacer
|
|
460
|
-
rows stand for the rest, so the scroll bar measures the whole list, and the
|
|
461
|
-
header stays sticky. The table says where the window is (`aria-rowcount`, and
|
|
462
|
-
`aria-rowindex` on each drawn row), and the row that holds the keyboard focus
|
|
463
|
-
stays drawn while it is scrolled out of view, so focus and selection survive
|
|
464
|
-
scrolling. Its limits:
|
|
465
|
-
|
|
466
|
-
- The table needs a height (`className="max-h-96"`): its own area is what
|
|
467
|
-
scrolls, and without a height every row is in view.
|
|
468
|
-
- Every row has one fixed height, from the density: 41px when `compact`, 49px,
|
|
469
|
-
or 61px when `comfortable`, at the default text size, and growing with the text size
|
|
470
|
-
setting. Content taller than that is clipped, so keep a cell to a line of text
|
|
471
|
-
or a control, as in a standard row.
|
|
472
|
-
- It applies to the table layout only: with `mobileLayout="cards"` below `sm`
|
|
473
|
-
the cards keep the pagination.
|
|
474
|
-
- It is not for expandable rows or inline editing: both are off with it.
|
|
475
|
-
- On paper only the drawn rows print.
|
|
476
|
-
|
|
477
|
-
```tsx
|
|
478
|
-
<DataTable
|
|
479
|
-
rows={tenThousandRows}
|
|
480
|
-
columns={columns}
|
|
481
|
-
rowKey={(r) => r.id}
|
|
482
|
-
pageSize={10_000}
|
|
483
|
-
virtualize
|
|
484
|
-
className="max-h-96"
|
|
485
|
-
/>
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
`Table` takes `density` (`compact`, `default` or `comfortable`, as on `DataTable`; the
|
|
489
|
-
page's by default),
|
|
490
|
-
`striped`, `stickyHeader` (give the wrapper a height with
|
|
491
|
-
`containerClassName`, e.g. `max-h-80`) and `stickyFirstColumn`. While data
|
|
492
|
-
loads, `TableSkeletonRows` fills the `TableBody` under the header row. Cells take `align` and `numeric`
|
|
493
|
-
(`start`, `center` or `end`; `numeric` aligns to the end, with tabular figures). `TableHeaderCell` sets `scope` to `col` in the
|
|
494
|
-
head and `row` elsewhere. The wrapper scrolls horizontally, so a wide table
|
|
495
|
-
stays inside the page on small screens.
|
|
496
|
-
|
|
497
|
-
## Audit diff
|
|
498
|
-
|
|
499
|
-
`AuditDiff` shows what an audit log entry changed: `changes` is a list of
|
|
500
|
-
`{ field, label?, before?, after? }`. The default `table` variant is a real
|
|
501
|
-
table (Field, Before, After) that stacks each change as a block below `sm`; the
|
|
502
|
-
old value is struck through on a red tint and the new one sits on a green tint.
|
|
503
|
-
`variant="inline"` is one line per field (`Role: Viewer → Admin`), for an entry
|
|
504
|
-
of an `ActivityFeed` or a `Timeline`.
|
|
505
|
-
|
|
506
|
-
```tsx
|
|
507
|
-
import { AuditDiff } from '@sckyzo/cosmos-react';
|
|
508
|
-
|
|
509
|
-
<AuditDiff
|
|
510
|
-
variant="inline"
|
|
511
|
-
changes={[
|
|
512
|
-
{ field: 'role', label: 'Role', before: 'Viewer', after: 'Admin' },
|
|
513
|
-
{ field: 'phone', label: 'Phone', after: '+33 1 23 45 67 89' },
|
|
514
|
-
]}
|
|
515
|
-
/>;
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
Values are rendered as text (markup in one shows as written): strings as they
|
|
519
|
-
are, numbers and booleans with `String`, objects and arrays as compact JSON
|
|
520
|
-
(there is no nested diff). `null`, `undefined` and `''` mean the field was
|
|
521
|
-
empty on that side and show "—", which a screen reader reads as "empty".
|
|
522
|
-
`formatValue(value, change, side)` draws a value yourself. When both sides are
|
|
523
|
-
strings of a few words or more, the cells mark the words that moved with `<del>`
|
|
524
|
-
and `<ins>`, each with a visually hidden "removed:" / "added:"; past about 500
|
|
525
|
-
tokens on each side the whole values are shown instead. `labels` rewords every
|
|
526
|
-
text the component writes (`field`, `before`, `after`, `empty`, `removed`,
|
|
527
|
-
`added`), for another language.
|
|
528
|
-
|
|
529
|
-
## Progress
|
|
530
|
-
|
|
531
|
-
`ProgressBar` shows one value (`value`, `max`, `intent`) or a list of parts
|
|
532
|
-
(`segments`), never both. Each segment has a `value`, a `label` and an `intent`
|
|
533
|
-
you pick yourself; the parts sit 2px apart, and their sum is held to `max`, so
|
|
534
|
-
show the overflow yourself ("112%"). `legend` adds a row of swatch, label and value
|
|
535
|
-
under the bar, with what is left on the track. `aria-valuetext` lists the parts
|
|
536
|
-
("Documents 40, Images 25, Free 35"); `valueLabel` puts a unit on a part.
|
|
537
|
-
|
|
538
|
-
```tsx
|
|
539
|
-
<ProgressBar
|
|
540
|
-
aria-label="Storage"
|
|
541
|
-
legend
|
|
542
|
-
segments={[
|
|
543
|
-
{ label: 'Documents', value: 40, valueLabel: '40 GB' },
|
|
544
|
-
{ label: 'Images', value: 25, intent: 'success', valueLabel: '25 GB' },
|
|
545
|
-
]}
|
|
546
|
-
free={{ valueLabel: '35 GB' }}
|
|
547
|
-
/>
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
`ProgressRing` is the same meter as an SVG ring (no dependency): `value`, `max`,
|
|
551
|
-
`intent`, `size` (`sm` 32px, `md` 56px, `lg` 96px, or a number of pixels), `thickness`,
|
|
552
|
-
and `children` for the centre (a value, an icon). It is a `progressbar` too, so name it
|
|
553
|
-
with `aria-label` or `aria-labelledby`; the centre is not read a second time. `indeterminate`
|
|
554
|
-
spins a quarter arc, except under reduced motion.
|
|
555
|
-
|
|
556
|
-
```tsx
|
|
557
|
-
<ProgressRing aria-label="Course progress" value={68} intent="success">
|
|
558
|
-
68%
|
|
559
|
-
</ProgressRing>
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
## Kanban board
|
|
563
|
-
|
|
564
|
-
`KanbanBoard` shows columns of cards that move between columns: a sprint board, a pipeline, tasks by
|
|
565
|
-
status. Cards are plain data (`id`, `title`, an optional `description`, and `badges` and `footer`
|
|
566
|
-
slots for `Tag`s and an `AvatarGroup`), columns are `{ id, title, cards }`. It follows the usual
|
|
567
|
-
controlled/uncontrolled pair (`value`, `defaultValue`, `onChange(columns)`), and owns no data: the
|
|
568
|
-
host saves the move. `onCardMove` carries just the move, after `onChange`, which is what a server
|
|
569
|
-
needs.
|
|
570
|
-
|
|
571
|
-
```tsx
|
|
572
|
-
<KanbanBoard
|
|
573
|
-
ariaLabel="Sprint board"
|
|
574
|
-
defaultValue={columns}
|
|
575
|
-
onChange={setColumns}
|
|
576
|
-
onCardMove={({ cardId, fromColumnId, toColumnId, index }) => save(cardId, toColumnId, index)}
|
|
577
|
-
onCardOpen={(card) => setOpen(card)}
|
|
578
|
-
onAddCard={(columnId) => openForm(columnId)}
|
|
579
|
-
/>
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
A card is dragged by its grip, with a pointer or a finger. From the keyboard, focus the grip, press
|
|
583
|
-
Space or Enter to pick the card up, the arrow keys to move it between positions and columns, Space
|
|
584
|
-
or Enter to drop it, Escape to put it back; a live region announces each step. Every card also has
|
|
585
|
-
an actions menu ("Move to" the other columns, "Move up", "Move down"), so the board works without
|
|
586
|
-
dragging. `onCardOpen` makes the whole card a button, `onAddCard` adds an "Add card" button to each
|
|
587
|
-
column, `renderCard` replaces what is inside a card (the grip and the actions menu stay), and
|
|
588
|
-
`loading` shows skeleton columns. A column takes an `intent` for the dot before its title and an
|
|
589
|
-
`empty` node for when it has no cards. Below `sm` the columns scroll sideways, one column wide.
|
|
590
|
-
|
|
591
|
-
Drag and drop is built on [`@dnd-kit/react`](https://dndkit.com), pinned to an exact version since it
|
|
592
|
-
is pre-1.0. It is a regular dependency of the package, not a peer.
|
|
593
|
-
|
|
594
|
-
## Dates, clocks and countdowns
|
|
595
|
-
|
|
596
|
-
`DateTime`, `RelativeTime`, `Clock` and `Countdown` write a moment the same way everywhere, with
|
|
597
|
-
`Intl` and in the user's language. They render in the browser only, since the server knows neither
|
|
598
|
-
the locale nor the zone, and keep the room of their text until then.
|
|
599
|
-
|
|
600
|
-
```tsx
|
|
601
|
-
<DateTime value={order.createdAt} format="datetime" timeZone="Europe/Paris" />
|
|
602
|
-
<RelativeTime value={event.at} tooltip />
|
|
603
|
-
<Clock variant="card" timeZone="Asia/Tokyo" label="Tokyo" showDate />
|
|
604
|
-
<Countdown target="2026-03-01T09:00:00Z" onComplete={() => reload()} />
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
- `DateTime` takes a `format` (`date`, `time`, `datetime`, `long`, `full`, or `Intl.DateTimeFormat`
|
|
608
|
-
options), a `locale` and a `timeZone`, and draws a `<time dateTime>`. `tooltip` adds the full date
|
|
609
|
-
and zone, and makes the `<time>` a tab stop so a keyboard reaches it.
|
|
610
|
-
- `RelativeTime` reads "5 minutes ago", "yesterday", "in 2 days". Every instance shares one timer and
|
|
611
|
-
wakes only when its text changes: each second in the first minute, each minute in the first hour,
|
|
612
|
-
and rarely after, so a long list costs nothing.
|
|
613
|
-
- `Clock` has the variants `inline`, `card` and `display`, and takes `hour12`, `showSeconds`,
|
|
614
|
-
`showDate` and a `label` for the zone. It ticks on the second boundary. A world clock is a grid of
|
|
615
|
-
`Clock`s.
|
|
616
|
-
- `Countdown` has the variants `tiles`, `inline` and `compact`. `onComplete` is called once, at zero,
|
|
617
|
-
and the countdown stops there. `labels`, `shortLabels`, `label` and `completedLabel` translate its
|
|
618
|
-
text.
|
|
619
|
-
|
|
620
|
-
`Clock` and `Countdown` are `role="timer"`, which a screen reader does not announce as it changes.
|
|
621
|
-
Every one of them takes `now`, which freezes it at that moment and sets no timer: use it for
|
|
622
|
-
stories, screenshots and tests.
|
|
623
|
-
|
|
624
|
-
## Health checks
|
|
625
|
-
|
|
626
|
-
`HealthSummary`, `HealthCheckList` and `HealthMatrix` show whether a system and its parts are
|
|
627
|
-
healthy, and why not. They build on `HealthStatus` (`OK`, `WARN`, `CRIT`, `UNKNOWN`, `INFO`) and on
|
|
628
|
-
the dots and badges of the same folder.
|
|
629
|
-
|
|
630
|
-
A check is plain data. A group holds checks and, below them, other groups: one level per dependency
|
|
631
|
-
reads as a tree. A group has no status of its own, it is the worst status under it.
|
|
632
|
-
|
|
633
|
-
```tsx
|
|
634
|
-
const checks: HealthCheck[] = [
|
|
635
|
-
{
|
|
636
|
-
id: 'queue',
|
|
637
|
-
name: 'Job queue',
|
|
638
|
-
status: 'WARN',
|
|
639
|
-
target: 'redis://queue:6379/0',
|
|
640
|
-
message: '312 jobs waiting (limit 200)',
|
|
641
|
-
latency: 4, // milliseconds
|
|
642
|
-
checkedAt: '2026-01-15T09:59:30Z', // a Date, a timestamp or an ISO string
|
|
643
|
-
output: 'queue=default depth=312 oldest=94s', // shown in a code block, on demand
|
|
644
|
-
},
|
|
645
|
-
];
|
|
646
|
-
const groups: HealthCheckGroup[] = [{ id: 'queue', name: 'Queue', checks, groups: [] }];
|
|
647
|
-
|
|
648
|
-
<HealthSummary checks={flattenChecks(groups)} refresh={{ onRefresh, autoRefreshMs, onIntervalChange }} />
|
|
649
|
-
<HealthCheckList groups={groups} sort="severity" />
|
|
650
|
-
<HealthMatrix ariaLabel="Services by node" columns={nodes} rows={services} />
|
|
651
|
-
```
|
|
652
|
-
|
|
653
|
-
Everything that sorts or rolls up uses one **severity order**, worst first: `CRIT`, `WARN`,
|
|
654
|
-
`UNKNOWN`, `INFO`, `OK`. A check nobody could run weighs more than one that passes. `SEVERITY_ORDER`,
|
|
655
|
-
`compareSeverity` and `worstStatus` are exported, as are `flattenChecks` and `groupWorst`. A set with
|
|
656
|
-
nothing in it is `UNKNOWN`.
|
|
657
|
-
|
|
658
|
-
- `HealthSummary` takes the worst status of its `checks`, and `status` overrides it when you weigh the
|
|
659
|
-
checks differently (the counts still come from `checks`). The headlines are "All systems
|
|
660
|
-
operational", "Degraded performance", "Major outage", "Status unknown" and "Operational, with
|
|
661
|
-
notices"; `labels` replaces any of them. It also shows the count per status, the last check
|
|
662
|
-
(`RelativeTime`, from the latest `checkedAt`, or `checkedAt`) and, with `refresh` (the props of
|
|
663
|
-
`RefreshButton`), a refresh action. `variant="banner"` is full width and tinted by the status,
|
|
664
|
-
`variant="compact"` is one line for a card or a topbar.
|
|
665
|
-
- `HealthCheckList` draws rows with the status in words, the name, target, message, latency and last
|
|
666
|
-
check. A check with an `output` has a toggle on its name. Pass `checks`, `groups` or both. A group
|
|
667
|
-
header shows the worst status and the counts under it and collapses
|
|
668
|
-
(`collapsedGroups` / `defaultCollapsedGroups` / `onCollapsedGroupsChange`, by group id).
|
|
669
|
-
`sort="severity"` puts the failing checks, and the groups holding them, first. `variant="list"` is
|
|
670
|
-
rows, `variant="grid"` one small card per check, for a wall screen.
|
|
671
|
-
- `HealthMatrix` is one cell per service (`rows`) and per environment, node or region (`columns`),
|
|
672
|
-
with sticky headers, in a `role="grid"` that takes one tab stop: arrow keys move between cells,
|
|
673
|
-
Home and End go to the ends of the row. A cell opens its detail in a popover (`message`, `latency`,
|
|
674
|
-
`checkedAt`, and a `detail` node of your own) with Enter, Space or a click; Escape closes it and the
|
|
675
|
-
focus returns to the cell. Each cell is named like "API on par-2b: Warning", and a column with no
|
|
676
|
-
entry is an empty cell. `variant="dots"` is dense, and every dot has a shape as well as a colour: OK a
|
|
677
|
-
filled circle, warning a diamond, critical a square, unknown a hollow ring, info a triangle. The
|
|
678
|
-
legend above the grid shows the same shapes. `variant="pills"` writes the status in each cell. The
|
|
679
|
-
grid scrolls in its own box when it is wider than the screen; `maxHeight` also scrolls it
|
|
680
|
-
vertically. `rowHeader` names the first header cell ("Service" by default; a cluster says "Rack").
|
|
681
|
-
`attentionStrip` adds a "Needs attention" strip above the grid: one chip per row that holds a
|
|
682
|
-
warning or a critical cell, with its counts in words ("2 Critical, 1 Warning"), the worst rows
|
|
683
|
-
first. Picking a chip scrolls its row into view, in the grid's own box when `maxHeight` is set,
|
|
684
|
-
and moves the focus to its worst cell; on a phone the strip scrolls sideways.
|
|
685
|
-
|
|
686
|
-
`HealthSummary`, `HealthMatrix` and `MonitorRow` (see Uptime monitoring) take `stale`, the time of the
|
|
687
|
-
last data, for a source that has stopped answering. The status colours turn grey, the component says
|
|
688
|
-
"Stale · 4 minutes ago" and the stale state is in the accessible text (the headline, each matrix
|
|
689
|
-
cell, the monitor's state chip). The statuses shown stay those of the last data.
|
|
690
|
-
|
|
691
|
-
Every one takes `now`, which freezes the relative times, for stories and tests. One check shown as a
|
|
692
|
-
card, and a dependency tree, need no component of their own: see the stories of the Monitoring group.
|
|
693
|
-
|
|
694
|
-
## Uptime monitoring
|
|
695
|
-
|
|
696
|
-
`HeartbeatBar`, `UptimeBars`, `UptimeStat` and `MonitorRow` draw what a monitor found: its recent
|
|
697
|
-
checks, its uptime over days and periods, and one monitor as a line or a tile. A monitor checks a
|
|
698
|
-
target every N seconds; these show the result, and the host brings the data.
|
|
699
|
-
|
|
700
|
-
A **beat** is one check, and a **day** is one day of history. `time` and `date` take a `Date`, a
|
|
701
|
-
timestamp in milliseconds or an ISO 8601 string (a bare `YYYY-MM-DD` names that calendar day for every
|
|
702
|
-
reader, whatever their time zone).
|
|
703
|
-
|
|
704
|
-
```tsx
|
|
705
|
-
const beats: Beat[] = [
|
|
706
|
-
{ time: '2026-01-15T06:41:10Z', status: 'up', latency: 182 },
|
|
707
|
-
{ time: '2026-01-15T06:42:10Z', status: 'down', latency: 5003, message: 'Timeout after 5 s' },
|
|
708
|
-
]; // oldest first; status: 'up' | 'down' | 'degraded' | 'maintenance' | 'pending'
|
|
709
|
-
const days: UptimeDay[] = [
|
|
710
|
-
{ date: '2026-01-14', uptime: 0.9995, incidents: 1 }, // 0 to 1; null is a day with no data
|
|
711
|
-
{ date: '2026-01-15', uptime: null },
|
|
712
|
-
];
|
|
713
|
-
|
|
714
|
-
<HeartbeatBar beats={beats} />
|
|
715
|
-
<UptimeBars days={days} title="API" legend />
|
|
716
|
-
<UptimeStat periods={[{ label: '30 days', uptime: 0.9991 }]} responseTime={182} certificate={{ daysLeft: 12 }} />
|
|
717
|
-
<MonitorRow name="API" type="HTTP" beats={beats} uptime={0.9992} />
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
**Thresholds.** An uptime is coloured by one shared rule: 99.9 % and up is green, 99 % up to 99.9 %
|
|
721
|
-
is amber, below 99 % is red, and `null` is grey ("No data"). Every component that colours by uptime
|
|
722
|
-
(`UptimeBars`, `UptimeStat`, `MonitorRow`) takes `thresholds`, a partial `{ good, warning }` in shares
|
|
723
|
-
from 0 to 1 (`{ good: 0.9999, warning: 0.999 }`), and the legend of `UptimeBars` follows it. A
|
|
724
|
-
`warning` limit above `good` is lowered to it. `DEFAULT_THRESHOLDS`, `resolveThresholds` and
|
|
725
|
-
`uptimeLevel` are exported, to colour your own figures the same way. The status colours are the
|
|
726
|
-
theme's (`--color-status-ok`, `-warn`, `-crit`), so a theme or an accent repaints them.
|
|
727
|
-
|
|
728
|
-
- `HeartbeatBar` draws one mark per beat, the newest at the end. `variant` is `bars` (default),
|
|
729
|
-
`blocks` or `dots`; `size` is `md` (32px, default) or `sm` (24px, the height inside a `MonitorRow`).
|
|
730
|
-
It holds as many beats as its width allows and drops the oldest, so a 375px screen still shows the
|
|
731
|
-
latest history: give it the width of its container. A `down` beat differs by shape as well as
|
|
732
|
-
colour (a taller bar, a block crossed by a diagonal, a square dot) and every beat is named in words
|
|
733
|
-
(status, time, latency, message). The bar is one tab stop on the newest beat; the arrow keys, Home
|
|
734
|
-
and End move between beats, and hovering or focusing one shows its tooltip (Escape hides it).
|
|
735
|
-
`legend` writes the key of the statuses under the bar.
|
|
736
|
-
- `UptimeBars` draws one bar per day over `period` 30, 60 or 90 days (default 90), coloured by the
|
|
737
|
-
thresholds. The header gives `title` and the uptime of the period (the mean of the days with data,
|
|
738
|
-
or `uptime` when you have the exact figure), the captions read "90 days ago" and "Today". When the
|
|
739
|
-
width cannot hold every day, the oldest are dropped and the caption and the figure describe what is
|
|
740
|
-
drawn. Days are named ("Jan 15, 2026: 99.95% uptime, 1 incident"), reached with the same keyboard
|
|
741
|
-
model as `HeartbeatBar`, and `legend` writes the thresholds. `compact` is the form for a card: lower
|
|
742
|
-
bars, no header, captions or legend.
|
|
743
|
-
- `UptimeStat` has two variants. `row` (default) puts the uptime of each of your `periods` side by
|
|
744
|
-
side (`{ label: '24 hours', uptime }`: you name the periods), then `responseTime` and the
|
|
745
|
-
`certificate`: days left, a warning (an icon, and the amber colour) under `warnBelow` days, 14 by
|
|
746
|
-
default. `card` is one figure for a dashboard tile: the first period, and the downtime it stands for
|
|
747
|
-
("39 min down") when the period has a `duration` in milliseconds, with `days` drawn as compact bars.
|
|
748
|
-
It is a variant of its own, not a `KpiCard` preset.
|
|
749
|
-
- `MonitorRow` shows the state (the status of the last beat, `status` to force it, `paused` to
|
|
750
|
-
pause it), the name (a link with `href`) and `type` (any string: `HTTP`, `TCP`, `DNS`, `ping`,
|
|
751
|
-
`keyword`), a 24px `HeartbeatBar`, the uptime over 24 hours, and the current latency (default: the
|
|
752
|
-
last beat's). `variant="row"` is a line of a list, which the host draws: it stacks on a narrow
|
|
753
|
-
screen. `variant="card"` is a tile for a grid. `stale` (with `now` to freeze its text) greys the
|
|
754
|
-
chip, the beats and the uptime and adds a "Stale · 4 minutes ago" tag under the name.
|
|
755
|
-
|
|
756
|
-
The response time (a `LineAreaChart` from `@sckyzo/cosmos-charts`, its `referenceBands` shading the
|
|
757
|
-
down periods; the stories draw a plain SVG, since this package has no chart dependency), the timeline
|
|
758
|
-
of an incident (`Timeline`) and a maintenance notice (`AlertBanner`) need no component of their own:
|
|
759
|
-
see the stories of the Monitoring group, and the two example pages (a public status page and a
|
|
760
|
-
monitors dashboard).
|
|
761
|
-
|
|
762
|
-
## Auth layouts
|
|
763
|
-
|
|
764
|
-
`AuthLayout` wraps the sign-in, sign-up, reset and two-step forms. `variant` picks the page:
|
|
765
|
-
`card` (default) is a centred card; `split` puts the form beside a brand panel; `cover`
|
|
766
|
-
centres the card over a full-bleed image.
|
|
767
|
-
|
|
768
|
-
```tsx
|
|
769
|
-
<AuthLayout variant="split" logo={Activity} title="Cosmos Cloud" subtitle="Monitor every rack.">
|
|
770
|
-
<SignInForm />
|
|
771
|
-
</AuthLayout>
|
|
772
|
-
<AuthLayout variant="cover" cover={{ src: '/login.jpg' }} logo={Activity} title="Cosmos Cloud">
|
|
773
|
-
<SignInForm />
|
|
774
|
-
</AuthLayout>
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
In `split`, `aside` replaces the default panel (an accent surface with the `logo`, `title` and
|
|
778
|
-
`subtitle`; `null` for none), `asidePosition` (`start` | `end`, default `end`) picks its side. The
|
|
779
|
-
panel shows from `lg` up; below, the brand block sits above the form. In `cover`, `cover.src` is
|
|
780
|
-
drawn under a dark scrim (`cover.alt` is empty unless set), and without it the card sits on a
|
|
781
|
-
gradient of the accent. In every variant the built-in title is a paragraph: the forms carry the
|
|
782
|
-
page's `h1`.
|
|
783
|
-
|
|
784
|
-
## Record pager
|
|
785
|
-
|
|
786
|
-
`RecordPager` moves through the records of a list from a detail page: "12 of 340", Previous, Next
|
|
787
|
-
and an optional "Back to list". `Pagination` stays for pages of a list; the order and filters of the
|
|
788
|
-
list are the host's to keep (URL or state).
|
|
789
|
-
|
|
790
|
-
```tsx
|
|
791
|
-
<RecordPager
|
|
792
|
-
position={12}
|
|
793
|
-
total={340}
|
|
794
|
-
noun="order"
|
|
795
|
-
previousHref="/orders/20430"
|
|
796
|
-
nextHref="/orders/20432"
|
|
797
|
-
backHref="/orders"
|
|
798
|
-
hotkeys={{ previous: '[', next: ']' }}
|
|
799
|
-
/>
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
- Links when `previousHref` / `nextHref` are given (through `LinkProvider`), buttons with
|
|
803
|
-
`onPrevious` / `onNext` otherwise. A side with nowhere to go, or at the first or last record, is a
|
|
804
|
-
disabled button, never a link.
|
|
805
|
-
- `noun` builds the labels ("Previous order"); `labels` (`previous`, `next`, `back`, `count`)
|
|
806
|
-
replaces any of them, for another language.
|
|
807
|
-
- `variant="compact"` draws icon buttons (still named) and the count, for a `PageHeader`'s `actions`.
|
|
808
|
-
- Below `sm`, the default variant shows the icons only too, so a long label never wraps; the label
|
|
809
|
-
stays the button's name.
|
|
810
|
-
- Shortcuts are off unless `hotkeys` is given. They listen on the document, do nothing while a
|
|
811
|
-
field is being typed in, and click the control, so a link goes through your router. List them
|
|
812
|
-
yourself in a `HelpOverlay`.
|
|
813
|
-
|
|
814
|
-
## Files and uploads
|
|
815
|
-
|
|
816
|
-
`FileTile` is one file or folder as a `<li>`: `layout="tile"` (the default) is the card of a grid, with a
|
|
817
|
-
128px preview, `layout="row"` the line of a list. `kind` (`folder`, `image`, `video`, `audio`, `pdf`,
|
|
818
|
-
`document`, `spreadsheet`, `archive`, `code`, `other`) picks the icon and its tint; a `thumbnail` takes
|
|
819
|
-
the icon's place and falls back to it if it fails to load. The name is the only link (`href`) or button
|
|
820
|
-
(`onOpen`) of the tile; the star (`starred` / `defaultStarred` / `onStarredChange`, named "Star {name}")
|
|
821
|
-
and `actions`, typically a `Menu`, are siblings of it. `shared` adds a "Shared" badge and `selected`
|
|
822
|
-
draws the tile as picked.
|
|
823
|
-
|
|
824
|
-
```tsx
|
|
825
|
-
<GridList cols={4}>
|
|
826
|
-
<FileTile
|
|
827
|
-
name="Q3 report.pdf"
|
|
828
|
-
kind="pdf"
|
|
829
|
-
meta="2.4 MB · Edited yesterday"
|
|
830
|
-
defaultStarred
|
|
831
|
-
onOpen={() => open(file)}
|
|
832
|
-
actions={<FileMenu file={file} />}
|
|
833
|
-
/>
|
|
834
|
-
</GridList>
|
|
835
|
-
```
|
|
836
|
-
|
|
837
|
-
`UploadQueue` lists uploads in progress, failed and done in one card. It does no network I/O: the app
|
|
838
|
-
owns the transfer, keeps `items` (`id`, `name`, `size`, `status`, `progress`, `error`) up to date and
|
|
839
|
-
answers `onRetry(id)` and `onRemove(id)`. A row says its status in text first, and a polite live region
|
|
840
|
-
announces the rows that finish or fail. Pair it with a `Dropzone` set to `hideFileList`:
|
|
841
|
-
`onFilesAdded(files)` gets only the files just accepted, so the app can start uploading them, and
|
|
842
|
-
`onReject(rejections)` keeps the valid files of a drop and reports the others one by one with their
|
|
843
|
-
reason, where without it one invalid file rejects the whole drop.
|
|
844
|
-
|
|
845
|
-
```tsx
|
|
846
|
-
<Dropzone multiple hideFileList accept=".pdf,.png" maxSize={25 * 1024 * 1024}
|
|
847
|
-
onFilesAdded={(files) => files.forEach(upload)}
|
|
848
|
-
onReject={(rejections) => report(rejections)} />
|
|
849
|
-
<UploadQueue items={uploads} onRetry={retry} onRemove={cancelOrDismiss} />
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
## Chat
|
|
853
|
-
|
|
854
|
-
Building blocks for a conversation view: `ChatThread` (a `role="log"` live
|
|
855
|
-
region that stays scrolled to the newest message), `ChatMessage`,
|
|
856
|
-
`ChatDaySeparator`, `ChatTypingIndicator`, `ChatComposer` (Enter sends,
|
|
857
|
-
Shift+Enter adds a line) and `ChatConversationList` for a two-pane layout.
|
|
858
|
-
Message text is always rendered as text, never HTML; http(s) URLs become links.
|
|
859
|
-
|
|
860
|
-
`Chatbot` is a floating widget built on them: a launcher at the bottom right
|
|
861
|
-
opens a panel (full-screen under 480px). It has no network code, the app owns
|
|
862
|
-
the messages and answers `onSend`:
|
|
863
|
-
|
|
864
|
-
```tsx
|
|
865
|
-
<Chatbot
|
|
866
|
-
messages={messages}
|
|
867
|
-
onSend={(text) => ask(text)}
|
|
868
|
-
typing={waiting}
|
|
869
|
-
suggestions={['Show my alerts']}
|
|
870
|
-
error={failed ? 'The assistant could not be reached.' : undefined}
|
|
871
|
-
onRetry={retry}
|
|
872
|
-
/>
|
|
873
|
-
```
|
|
874
|
-
|
|
875
|
-
A bot message with `streaming: true` shows a caret while its text grows.
|
|
876
|
-
|
|
877
|
-
### Rich replies (AI assistant, generators)
|
|
878
|
-
|
|
879
|
-
The same blocks build an assistant page; the app owns the conversation and renders any
|
|
880
|
-
Markdown itself.
|
|
881
|
-
|
|
882
|
-
- `ChatMessage` takes `children` in place of `text` (rendered nodes: a `CodeBlock`, a list, an
|
|
883
|
-
`ImageGrid`). `bubble={false}` drops the bubble of an incoming reply so it takes the whole
|
|
884
|
-
column, with the avatar and name on top. While `streaming` the message is `aria-busy`.
|
|
885
|
-
- `actions={<ChatMessageActions text={reply} onRegenerate={…} feedback={…} onFeedbackChange={…} />}`
|
|
886
|
-
adds Copy reply, Regenerate and Good/Bad response under the reply (`aria-pressed`, also
|
|
887
|
-
`defaultFeedback`). With a pointer they show on hover and focus; on touch they always show.
|
|
888
|
-
- `ChatSuggestions` is the row of chips `Chatbot` uses (`suggestions`, `onSelect`).
|
|
889
|
-
- `ChatComposer` takes `footer` (a line under the field: attach, a model `Select`, a hint) and
|
|
890
|
-
`generating` + `onStop`: the send button becomes Stop (`stopLabel`), and Enter does not send.
|
|
891
|
-
- `ChatToolCall` (`name`, `status` of `running | done | failed`, `duration`, `input`, `output`)
|
|
892
|
-
is a disclosure row for what the assistant did; `ChatSources` (`sources`, `label`) lists where
|
|
893
|
-
an answer comes from. Only http(s) sources become links.
|
|
894
|
-
- `Clipboard plain` is the borderless copy button of that row.
|
|
895
|
-
|
|
896
|
-
## Comments
|
|
897
|
-
|
|
898
|
-
`CommentThread`, `Comment` and `CommentComposer` build the discussion under a record (a ticket, a
|
|
899
|
-
task, an order). The host owns the data: the components take callbacks and keep only which
|
|
900
|
-
replies are unfolded and which composer is open. Body text is rendered as text, never HTML, and
|
|
901
|
-
only http(s) URLs become links.
|
|
902
|
-
|
|
903
|
-
```tsx
|
|
904
|
-
<CommentThread composer={<CommentComposer onSubmit={(text) => add(text)} />}>
|
|
905
|
-
{comments.map((c) => (
|
|
906
|
-
<Comment
|
|
907
|
-
key={c.id}
|
|
908
|
-
author={c.author}
|
|
909
|
-
role={c.role}
|
|
910
|
-
time={<time dateTime={c.createdAt}>{format(c.createdAt)}</time>}
|
|
911
|
-
edited={c.edited}
|
|
912
|
-
variant={c.internal ? 'internal' : 'default'}
|
|
913
|
-
text={c.text}
|
|
914
|
-
onReply={(text) => reply(c.id, text)}
|
|
915
|
-
onEdit={c.mine ? (text) => edit(c.id, text) : undefined}
|
|
916
|
-
onDelete={c.mine ? () => remove(c.id) : undefined}
|
|
917
|
-
replies={c.replies.map((r) => (
|
|
918
|
-
<Comment key={r.id} author={r.author} text={r.text} />
|
|
919
|
-
))}
|
|
920
|
-
/>
|
|
921
|
-
))}
|
|
922
|
-
</CommentThread>
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
- An action shows only when its callback is given, so a host lets people edit only their own
|
|
926
|
-
comments. Reply and Edit open a `CommentComposer` in place (the edit one starts with the
|
|
927
|
-
comment's text; `editText` when the body is `children`) and call back with the trimmed text;
|
|
928
|
-
Cancel and Escape return the focus to the button that opened it. Delete calls back at once:
|
|
929
|
-
confirm in the host if needed.
|
|
930
|
-
- Replies are one level, `Comment`s passed as `replies`, folded beyond `maxReplies` (default
|
|
931
|
-
`2`) behind a "Show 3 more replies" button. A reply has no Reply of its own. For a ticket, where
|
|
932
|
-
the latest exchanges matter, `keepReplies="last"` keeps the last `maxReplies` visible and folds
|
|
933
|
-
the older ones behind a button above them ("Show 3 earlier replies", `labels.showEarlierReplies`).
|
|
934
|
-
- `variant="internal"` tints a note and labels it "Internal note" with a lock, so it reads as one
|
|
935
|
-
without the color.
|
|
936
|
-
- `CommentComposer` is a `Textarea` with `autoResize`: Enter adds a line, Ctrl or Cmd+Enter and the
|
|
937
|
-
submit button send, Escape cancels when `onCancel` is given. Submit is disabled while the text
|
|
938
|
-
is empty or `submitting`. `value` / `defaultValue` / `onChange` work as on other fields.
|
|
939
|
-
- `modes` gives the composer a segmented control above the field, for what is being written
|
|
940
|
-
(`{ value: 'reply', label: 'Reply to customer' }`, `{ value: 'note', label: 'Internal note',
|
|
941
|
-
internal: true }`). `onSubmit(text, mode)` receives the selected mode's `value`; without `modes`
|
|
942
|
-
it gets the text alone, as before. `mode` / `defaultMode` / `onModeChange` work as on other
|
|
943
|
-
fields, and a mode may carry its own `placeholder` and `submitLabel`. An `internal` mode tints
|
|
944
|
-
the field and the submit button like `variant="internal"` tints a comment, with a lock and the
|
|
945
|
-
line `internalNotice` ("Internal note: only your team sees this."), announced when the mode
|
|
946
|
-
changes. `modeLabel` names the control.
|
|
947
|
-
- `time` is a node, as in `Timeline`: a `RelativeTime`, a `<time>` or your own text.
|
|
948
|
-
- Every text can be translated: `labels` on `CommentThread` reaches all its comments (a
|
|
949
|
-
comment's own `labels` win), `emptyTitle` and `label` on the thread, and the composer's own
|
|
950
|
-
`label`, `placeholder`, `submitLabel` and `cancelLabel`.
|
|
951
|
-
|
|
952
|
-
## Log views
|
|
953
|
-
|
|
954
|
-
`LogViewer`, `LogToolbar`, `LogLevelBadge` and `LogEntryDetail` read a log: the live tail, its
|
|
955
|
-
controls, a level label and one record in full. The host owns the data: it fetches the snapshot,
|
|
956
|
-
streams the new lines (SSE, WebSocket), trims the buffer and decides what "clear" and "download"
|
|
957
|
-
do. The components draw what they are given.
|
|
958
|
-
|
|
959
|
-
```tsx
|
|
960
|
-
const [lines, setLines] = useState<LogLine[]>([]); // oldest first, ids unique and stable
|
|
961
|
-
const [follow, setFollow] = useState(true);
|
|
962
|
-
const [levels, setLevels] = useState<readonly LogLevelKey[]>(['debug', 'info', 'warn', 'error']);
|
|
963
|
-
const [search, setSearch] = useState('');
|
|
964
|
-
const [active, setActive] = useState(0);
|
|
965
|
-
const [matches, setMatches] = useState(0);
|
|
966
|
-
|
|
967
|
-
<LogToolbar
|
|
968
|
-
live={follow}
|
|
969
|
-
onLiveChange={setFollow}
|
|
970
|
-
levels={['debug', 'info', 'warn', 'error']}
|
|
971
|
-
levelCounts={countLogLevels(lines)}
|
|
972
|
-
activeLevels={levels}
|
|
973
|
-
onActiveLevelsChange={setLevels}
|
|
974
|
-
search={search}
|
|
975
|
-
onSearchChange={(text) => {
|
|
976
|
-
setSearch(text);
|
|
977
|
-
setActive(0); // a new search starts at its first match
|
|
978
|
-
}}
|
|
979
|
-
matchCount={matches}
|
|
980
|
-
activeMatch={active}
|
|
981
|
-
onActiveMatchChange={setActive}
|
|
982
|
-
onClear={() => setLines([])}
|
|
983
|
-
/>
|
|
984
|
-
<LogViewer
|
|
985
|
-
aria-label="Backend logs"
|
|
986
|
-
lines={lines}
|
|
987
|
-
follow={follow}
|
|
988
|
-
onFollowChange={setFollow}
|
|
989
|
-
activeLevels={levels}
|
|
990
|
-
search={search}
|
|
991
|
-
activeMatch={active}
|
|
992
|
-
onMatchCountChange={setMatches}
|
|
993
|
-
/>
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
**Data model.** A `LogLine` is `{ id, time, level?, source?, message, fields?, stack? }`. `time` is
|
|
997
|
-
a `Date`, a timestamp in milliseconds or an ISO string, shown with milliseconds. `level` is
|
|
998
|
-
`trace`, `debug`, `info`, `warn`, `error` or `fatal` in any case; anything else (`warning`,
|
|
999
|
-
`notice`, nothing) is `unknown`. `lines` is always oldest first, whatever the `order` it is drawn
|
|
1000
|
-
in, and each `id` must stay the same from one render to the next: the viewer uses it to key rows
|
|
1001
|
-
and to count the lines that arrived while it was paused.
|
|
1002
|
-
|
|
1003
|
-
**`LogViewer`**
|
|
1004
|
-
|
|
1005
|
-
- `tone`: `terminal` (default) is a dark panel in every theme, as a console is; `surface` follows
|
|
1006
|
-
the theme, for a card on a dashboard. `density`: `compact` (default) is one line per record,
|
|
1007
|
-
`comfortable` puts the message under its time, level and source. `columns` hides parts of the
|
|
1008
|
-
row (`['time']`), `lineNumbers` numbers the lines in the order of `lines`.
|
|
1009
|
-
- `follow` / `defaultFollow` / `onFollowChange`: it stays at the newest line while lines arrive.
|
|
1010
|
-
Scrolling away from the end pauses it and shows "Jump to latest (12 new)"; the button, or
|
|
1011
|
-
scrolling back to the end, resumes it; a filter that shortens the list does not. `order` puts
|
|
1012
|
-
the newest line last (`newest-last`, the default, a tail) or first.
|
|
1013
|
-
- The row follows the width of the viewer, not the screen's: the viewer is a container. Under
|
|
1014
|
-
28rem the source column is hidden; without `wrap` the row stays on one line and the viewer
|
|
1015
|
-
scrolls sideways inside itself, with `wrap` the message goes on a line of its own under the
|
|
1016
|
-
time and the level. Give the viewer a width (it fills a block parent; in a flex row, a `flex-1`
|
|
1017
|
-
or a width class), since a container takes none from its content.
|
|
1018
|
-
- `activeLevels` and `activeSources` filter the lines (left out or, for sources, empty, shows
|
|
1019
|
-
all). `search` highlights a case-insensitive term in the messages and sources, `searchRegex`
|
|
1020
|
-
reads it as a pattern (one that does not compile finds nothing). `activeMatch` is the active
|
|
1021
|
-
match, counted from 0 among the lines that match; it is drawn stronger and scrolled into view
|
|
1022
|
-
when it changes. `onMatchCountChange` reports how many lines match. Reset `activeMatch` to 0
|
|
1023
|
-
when the search changes, as the example does; `LogToolbar` steps it with previous and next
|
|
1024
|
-
(and Enter / Shift+Enter), wrapping around the ends.
|
|
1025
|
-
- A row with `fields` or a `stack` has a button that expands it in place
|
|
1026
|
-
(`expandedIds` / `defaultExpandedIds` / `onExpandedIdsChange`). `onOpenLine` adds an "Open
|
|
1027
|
-
details" button to the expanded row, to open a `LogEntryDetail` in a drawer.
|
|
1028
|
-
- Messages, sources, fields and stacks are text, never HTML. A bidirectional control character
|
|
1029
|
-
stays inside its own message.
|
|
1030
|
-
|
|
1031
|
-
**`LogToolbar`.** Each control is there when its props are given: Live / Paused
|
|
1032
|
-
(`onLiveChange`), level toggles with counts (`levels`), search (`onSearchChange`) with a regex
|
|
1033
|
-
toggle (`onSearchRegexChange`) and previous / next (`matchCount` and `onActiveMatchChange`), a
|
|
1034
|
-
source filter on `MultiSelect` (`sources`), `onWrapChange`, `onOrderChange`, `onDownload` and
|
|
1035
|
-
`onClear`. From `sm` up it is one bar. Below, Live / Paused and the search stay in the bar and
|
|
1036
|
-
the levels, sources and view options move into a "Filters" popover; its button shows how many
|
|
1037
|
-
filters are active (levels narrowed, sources chosen). The state pairs (`live`, `activeLevels`,
|
|
1038
|
-
`search`, `searchRegex`, `activeSources`, `wrap`, `order`) work controlled or not, as on other
|
|
1039
|
-
fields. It holds none of the log: pass the same filters to the viewer.
|
|
1040
|
-
|
|
1041
|
-
**`LogLevelBadge`** is the level as a word in its colour (`ERROR`, `WARN`), on the `surface` tone
|
|
1042
|
-
by default or the `terminal` one. Use it in a table cell, a card or a detail.
|
|
1043
|
-
|
|
1044
|
-
**`LogEntryDetail`** shows one record in full for a drawer or a side panel: level, time, source
|
|
1045
|
-
and message, the fields as key / value pairs with a copy button each, the stack trace, the raw
|
|
1046
|
-
JSON, and "Show context", the lines around it (a `context` prop, or fetched through
|
|
1047
|
-
`onShowContext`). `onFilterValue` and `onExcludeValue` add "Filter on this value" and "Exclude
|
|
1048
|
-
this value" buttons to each field. A structured table is a `DataTable` with a `LogLevelBadge`
|
|
1049
|
-
column and, once rows can expand, a `LogEntryDetail` in them.
|
|
1050
|
-
|
|
1051
|
-
**Accessibility.** The viewer is `role="log"` with `aria-live="off"`: a fast stream read aloud
|
|
1052
|
-
line by line cannot be followed, so new lines are not announced. `announceErrors` reads the
|
|
1053
|
-
error and fatal lines that arrive (three at most, then the count of the rest) in a polite live
|
|
1054
|
-
region of its own. The scroller takes a tab stop, so the arrow keys scroll it, and every level is
|
|
1055
|
-
a word, not only a colour. Each level colour holds 4.5:1 on both tones in the four themes.
|
|
1056
|
-
|
|
1057
|
-
**Limits.**
|
|
1058
|
-
|
|
1059
|
-
- The default mode is windowed: only the rows in view are drawn, so tens of thousands of lines
|
|
1060
|
-
stay smooth. The row height is measured, so it follows the text size, and a row that scrolls out
|
|
1061
|
-
of view and back is redrawn: a button on it loses the focus when it leaves the window.
|
|
1062
|
-
- An expanded row takes a fixed room of up to 12 rows (a row per field and per stack line); a
|
|
1063
|
-
longer detail scrolls inside it.
|
|
1064
|
-
- `wrap` lines have no fixed height, so they cannot be windowed: all of them are drawn (the
|
|
1065
|
-
browser skips the ones out of view with `content-visibility`). Keep the buffer to a few
|
|
1066
|
-
thousand lines with `wrap` on, as rackscope does with 2,000. Newest first and paused, wrapped
|
|
1067
|
-
lines do not stay put when new ones arrive.
|
|
1068
|
-
- Without `wrap` a long line scrolls sideways: the scroll width is that of the widest row drawn.
|
|
1069
|
-
- The labels ("Jump to latest", "Filters"…) are English.
|
|
1070
|
-
- Not here: the log volume chart (stacked `BarChart` of `@sckyzo/cosmos-charts`, as the lines per
|
|
1071
|
-
level over time) and a structured logs table. The output of a job is `JobLog`, below.
|
|
1072
|
-
|
|
1073
|
-
## Job logs
|
|
1074
|
-
|
|
1075
|
-
`JobLog` is the output of a CI job or a build: collapsible steps, each with its status and
|
|
1076
|
-
duration, and the lines of each step numbered and coloured. It is read differently from a live
|
|
1077
|
-
tail, hence a component of its own; it shares the tones, the clock format and the windowing of
|
|
1078
|
-
`LogViewer`. The host owns the data and the clock: it passes the steps, their durations (`JobLog`
|
|
1079
|
-
runs no timer) and the new lines as they come.
|
|
1080
|
-
|
|
1081
|
-
```tsx
|
|
1082
|
-
const steps: JobLogStep[] = [
|
|
1083
|
-
{ id: 'install', name: 'Install dependencies', status: 'passed', duration: 41_000, lines: [...] },
|
|
1084
|
-
{
|
|
1085
|
-
id: 'run-tests',
|
|
1086
|
-
name: 'Run tests',
|
|
1087
|
-
status: 'failed',
|
|
1088
|
-
duration: 72_000,
|
|
1089
|
-
startedAt: '2026-01-15T09:30:44Z',
|
|
1090
|
-
// A string, or { text, timestamp } when the line has a time.
|
|
1091
|
-
lines: ['$ pnpm test', '\x1b[31m✗ sortRows keeps empty values last\x1b[0m', ...],
|
|
1092
|
-
},
|
|
1093
|
-
{ id: 'build', name: 'Build', status: 'skipped', lines: [] },
|
|
1094
|
-
];
|
|
1095
|
-
|
|
1096
|
-
<JobLog title="Build #1287" meta="main · 4f2c1a9" status="failed" duration={123_000} steps={steps} />
|
|
1097
|
-
```
|
|
1098
|
-
|
|
1099
|
-
- **Steps.** `status` is `queued`, `running`, `passed`, `failed` or `skipped`. `duration` is in
|
|
1100
|
-
milliseconds; `startedAt` is the origin of the elapsed times (it defaults to the first
|
|
1101
|
-
timestamp of the step). `id` is unique and stays the same from one render to the next. The
|
|
1102
|
-
optional header (`title`, `status`, `meta`, `duration`) is drawn when any of them is given.
|
|
1103
|
-
- **Open steps.** The failed and the running steps start open, the others closed, and a step that
|
|
1104
|
-
starts running later opens. `expandedSteps` / `defaultExpandedSteps` / `onExpandedStepsChange`
|
|
1105
|
-
work as on other controls. The header of an open step sticks to the top of the log while its
|
|
1106
|
-
lines scroll, and each step is windowed on its own, so a step of tens of thousands of lines
|
|
1107
|
-
stays smooth.
|
|
1108
|
-
- **Line numbers.** They restart at 1 in each step and are links: `href="#<idPrefix><step id>-L<n>"`.
|
|
1109
|
-
A click calls `onCurrentLineChange({ step, line })` and does not touch `location`; a click with a
|
|
1110
|
-
modifier key is the browser's own, so "open in a new tab" works. `currentLine` /
|
|
1111
|
-
`defaultCurrentLine` is the line in focus: when it changes its step opens, the line scrolls into
|
|
1112
|
-
view and takes `aria-current="location"`. One line at a time. To make the links shareable, sync
|
|
1113
|
-
it with the URL hash in the host: read `parseJobLogHash(location.hash, idPrefix)` into
|
|
1114
|
-
`currentLine` on load and on `hashchange`, and write `jobLogLineHref(idPrefix, line)` back from
|
|
1115
|
-
`onCurrentLineChange` (the `Monitoring/JobLog` "Synced with the URL hash" story does).
|
|
1116
|
-
- **Toolbar.** Search across all the steps (a match in a closed step opens it) with the count and
|
|
1117
|
-
previous / next, a timestamps toggle, wrap, expand or collapse all, and download as plain text
|
|
1118
|
-
(ANSI codes stripped, `fileName`). `controls` picks which are drawn (`['search']`; `[]` leaves
|
|
1119
|
-
the toolbar out). Below `md` of the log's own width, as a container, the search stays and the
|
|
1120
|
-
rest moves into a "More options" menu. `timestamps`, `wrap` and `search` each come with a
|
|
1121
|
-
`default*` and an `on*Change`.
|
|
1122
|
-
- **Timestamps.** Off by default. `timestampFormat="clock"` (default) is the time of each line, as
|
|
1123
|
-
`LogViewer` writes it (`locale`, `timeZone`); `"elapsed"` is the time since its step started,
|
|
1124
|
-
`+0:04.2`.
|
|
1125
|
-
- **Following.** While a step runs the log stays at the end, pauses when the reader scrolls up,
|
|
1126
|
-
and has a footer button: Pause, then "Jump to latest (12 new)". `follow` / `defaultFollow` /
|
|
1127
|
-
`onFollowChange`. Moving to a line (a `currentLine`, a search match) leaves the end too.
|
|
1128
|
-
- **Tone.** `terminal` (default) is a dark panel in every theme, `surface` follows the theme.
|
|
1129
|
-
|
|
1130
|
-
**ANSI.** Colour codes are read into styled spans, never HTML. Kept: the 16 colours and their
|
|
1131
|
-
bright variants as a foreground and as a background, bold, dim, italic, underline and the resets.
|
|
1132
|
-
A 256-colour or truecolor code lands on the nearest of the 16. Every other escape sequence (cursor
|
|
1133
|
-
moves, window titles, links) and control character is dropped, and a carriage return keeps only the
|
|
1134
|
-
last segment of the line, so a progress bar reads as its last state. Black and white are mapped to
|
|
1135
|
-
what reads on the tone (a black text on the terminal panel is the muted gray). Each colour holds
|
|
1136
|
-
4.5:1 on both tones in the four themes. On a background colour, the text takes the colour that
|
|
1137
|
-
reads on that background and its own is dropped (`31;47` is dark text on a light fill), and dim
|
|
1138
|
-
mutes only the default text: a coloured text keeps its colour.
|
|
1139
|
-
|
|
1140
|
-
**Accessibility.** The log is a named `region` with a tab stop. A step header is a button with
|
|
1141
|
-
`aria-expanded` and `aria-controls`, its status an image with a text name (never a colour alone),
|
|
1142
|
-
and the lines of an open step a named group. A line number is a link named "Line 6".
|
|
1143
|
-
|
|
1144
|
-
**Limits.**
|
|
1145
|
-
|
|
1146
|
-
- The windowing needs a row of a fixed height, so it is off with `wrap`: all the lines of an open
|
|
1147
|
-
step are then drawn (the browser skips the ones out of view). Keep a step to a few thousand lines
|
|
1148
|
-
with `wrap` on. A line link that scrolls out of the window loses the focus.
|
|
1149
|
-
- Without `wrap`, the lines of a step scroll sideways inside the step, under its header.
|
|
1150
|
-
- A search compiles the plain text of every line of every step: for logs of hundreds of thousands
|
|
1151
|
-
of lines, filter on the host.
|
|
1152
|
-
- The labels ("Jump to latest", "More options"…) are English.
|
|
1153
|
-
|
|
1154
|
-
## Slider
|
|
1155
|
-
|
|
1156
|
-
`Slider` is a position on a scale, or a range of it. One `value` (with `defaultValue` and
|
|
1157
|
-
`onChange(value)`): a number draws one handle, a pair `[minimum, maximum]` draws two, which can meet
|
|
1158
|
-
but never cross. `min`, `max` and `step` default to 0, 100 and 1. `label` sits on the left of the
|
|
1159
|
-
header, the current value on the right, and `min` and `max` under the track; `formatValue(n)` writes
|
|
1160
|
-
all of them and the value a screen reader announces. `label` also names the handles ("Price minimum"
|
|
1161
|
-
and "Price maximum" for a range): without a visible label, give `aria-label`. `marks` draws a tick at
|
|
1162
|
-
every `step`, `intent` colors the fill (`brand`, `success`, `warning` or `danger`).
|
|
1163
|
-
|
|
1164
|
-
```tsx
|
|
1165
|
-
<Slider
|
|
1166
|
-
label="Price"
|
|
1167
|
-
defaultValue={[200, 800]}
|
|
1168
|
-
max={1000}
|
|
1169
|
-
step={10}
|
|
1170
|
-
formatValue={(n) => `$${n}`}
|
|
1171
|
-
onChange={(range) => setPrice(range)}
|
|
1172
|
-
/>
|
|
1173
|
-
```
|
|
1174
|
-
|
|
1175
|
-
## Colour and phone fields
|
|
1176
|
-
|
|
1177
|
-
`ColorInput` picks a colour from named swatches (a radio group, arrows move between them) or a hex
|
|
1178
|
-
field with a preview square. The value is the lowercase `#rrggbb`: the field accepts `#abc`, `abc`
|
|
1179
|
-
and `#aabbcc`, and only a valid hex reaches `onChange(hex)`. Leaving the field with an invalid draft
|
|
1180
|
-
shows an error and keeps the value. `presets` replaces the eight default swatches (`[]` for the hex
|
|
1181
|
-
field alone); each is a `{ value, label }`, and the label is what a screen reader announces.
|
|
1182
|
-
|
|
1183
|
-
`PhoneInput` joins a country list and the number field in one border. The value is the E.164 number
|
|
1184
|
-
(`+33612345678`) or `''`: `onChange(value)` gets `''` until the number is valid, which the light
|
|
1185
|
-
check decides (digits only, 4 or more, 15 at most with the country code). The list searches by
|
|
1186
|
-
name, ISO code or calling code; names come from `Intl.DisplayNames` in the page language.
|
|
1187
|
-
`defaultCountry` (an ISO 3166-1 code, `US` by default) sets the country until a number says
|
|
1188
|
-
otherwise, and `countries` limits the list. Typing `+` and a known calling code switches the
|
|
1189
|
-
country, picking a country keeps the digits. A calling code shared by several countries keeps the
|
|
1190
|
-
current one if it has it, else takes the code's main country. The package carries no phone metadata:
|
|
1191
|
-
`format(country, national)` shapes the digits as they are typed and `validate(value)` returns the
|
|
1192
|
-
message for a number it rejects, so a host can plug `libphonenumber-js` in.
|
|
1193
|
-
|
|
1194
|
-
```tsx
|
|
1195
|
-
<ColorInput label="Event colour" value={colour} onChange={setColour} />
|
|
1196
|
-
|
|
1197
|
-
<PhoneInput
|
|
1198
|
-
label="Phone number"
|
|
1199
|
-
defaultCountry="FR"
|
|
1200
|
-
value={phone}
|
|
1201
|
-
onChange={setPhone}
|
|
1202
|
-
validate={(v) => (isValidPhoneNumber(v) ? undefined : 'Enter a valid phone number.')}
|
|
1203
|
-
/>
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
## Application shell
|
|
1207
|
-
|
|
1208
|
-
`Shell` lays out a `Topbar`, a `Sidebar` and the page, and owns the sidebar's
|
|
1209
|
-
collapsed state: `defaultSidebarCollapsed`, or `sidebarCollapsed` and
|
|
1210
|
-
`onCollapsedChange` to control it. The Topbar shows the toggle, or the bottom of
|
|
1211
|
-
the Sidebar does with `collapseControl="sidebar"`.
|
|
1212
|
-
|
|
1213
|
-
Below `lg` the sidebar leaves the layout and the Topbar's hamburger opens it as a 288px
|
|
1214
|
-
drawer over a scrim (`defaultSidebarOpen`, or `sidebarOpen` and `onSidebarOpenChange`
|
|
1215
|
-
to control it). It is always expanded there, with a close button in `Sidebar.Brand`, and it
|
|
1216
|
-
closes on the scrim, Escape, a swipe toward its edge, a click on a `Sidebar.Item` and when the
|
|
1217
|
-
viewport grows past `lg`. Pass the product logo as the Topbar's `brand` to keep it in
|
|
1218
|
-
view while the sidebar is off screen. Any `Drawer` can close on a swipe too, with
|
|
1219
|
-
`swipeToClose`: toward its edge on `side="start"` and `side="end"` (the left and right, swapped
|
|
1220
|
-
under `dir="rtl"`), downward on `side="bottom"`, the
|
|
1221
|
-
bottom sheet (full width, as tall as its content up to 85% of the viewport, a grab `handle`
|
|
1222
|
-
you can turn off with `handle={false}`, and a `Drawer.Body` that scrolls past that height).
|
|
1223
|
-
|
|
1224
|
-
The expanded `Sidebar` follows its content: from `width` (264px, its minimum) it grows to
|
|
1225
|
-
fit its widest visible row, the deep levels of a tree included, up to `maxWidth` (320px). A
|
|
1226
|
-
label that needs more ends in an ellipsis and its row shows the full label in a tooltip. Set
|
|
1227
|
-
`maxWidth` to the same value as `width` for a fixed width. The collapsed rail (`collapsedWidth`)
|
|
1228
|
-
and the drawer keep their own widths.
|
|
1229
|
-
|
|
1230
|
-
A collapsed `Sidebar` can open while the pointer rests on it, with `expandOnHover`:
|
|
1231
|
-
`"overlay"` opens it above the page after 150 ms, with a shadow, and the page does not move;
|
|
1232
|
-
`"push"` widens it and the page makes room. Keyboard focus entering the sidebar does the same,
|
|
1233
|
-
and Escape closes it. It only applies from `lg` up, on a pointer that can hover, never in the
|
|
1234
|
-
drawer, and it does not touch the collapsed state: `onCollapsedChange` is not called and the
|
|
1235
|
-
toggles keep their state.
|
|
1236
|
-
|
|
1237
|
-
`SidebarToggle` is that toggle, to place anywhere else in the Shell (a header of
|
|
1238
|
-
your own, the Topbar's `startActions`, the Sidebar's `footer`) with no props: it
|
|
1239
|
-
reads the Shell's state, and every toggle stays in sync. Outside a Shell, pass it
|
|
1240
|
-
`collapsed` and `onCollapsedChange`.
|
|
1241
|
-
|
|
1242
|
-
```tsx
|
|
1243
|
-
<Shell
|
|
1244
|
-
topbar={<Topbar pageTitle="Servers" />}
|
|
1245
|
-
sidebar={<Sidebar footer={<SidebarToggle variant="panel" />}>{/* items */}</Sidebar>}
|
|
1246
|
-
>
|
|
1247
|
-
{/* page */}
|
|
1248
|
-
</Shell>
|
|
1249
|
-
```
|
|
1250
|
-
|
|
1251
|
-
### Search in the Topbar
|
|
1252
|
-
|
|
1253
|
-
`CommandPaletteTrigger` is a button drawn like a search field (a magnifier, a placeholder and the
|
|
1254
|
-
hotkey as a key hint, `⌘K` on Apple platforms and `Ctrl K` elsewhere) that opens a
|
|
1255
|
-
`CommandPalette`. It is not an input: the palette holds the field, so a screen reader announces a
|
|
1256
|
-
button that opens a dialog. Put it in the Topbar's `center` slot. Below `sm` it is a square icon
|
|
1257
|
-
button, and the placeholder stays as its accessible name. `size` takes the form fields' `sm`, `md`
|
|
1258
|
-
and `lg`.
|
|
1259
|
-
|
|
1260
|
-
The palette owns the hotkey (`hotkey`, `mod+k` by default) and the open state; the button
|
|
1261
|
-
registers no listener of its own. Open the palette from its `onClick`, and pass the same hotkey
|
|
1262
|
-
as `shortcut` so the hint is the right one (`shortcut={null}` for a palette without a hotkey).
|
|
1263
|
-
|
|
1264
|
-
```tsx
|
|
1265
|
-
const [open, setOpen] = useState(false);
|
|
1266
|
-
|
|
1267
|
-
<Topbar
|
|
1268
|
-
pageTitle="Projects"
|
|
1269
|
-
center={<CommandPaletteTrigger placeholder="Search projects" onClick={() => setOpen(true)} />}
|
|
1270
|
-
/>
|
|
1271
|
-
<CommandPalette open={open} onOpenChange={setOpen}>
|
|
1272
|
-
<CommandPalette.Input placeholder="Search…" />
|
|
1273
|
-
<CommandPalette.List>{/* items */}</CommandPalette.List>
|
|
1274
|
-
</CommandPalette>
|
|
1275
|
-
```
|
|
1276
|
-
|
|
1277
|
-
### Layout modes
|
|
1278
|
-
|
|
1279
|
-
`Sidebar variant="compact"` is a fixed 88px rail with the label under each icon, on two lines
|
|
1280
|
-
at most (a tooltip carries the full name of a label that is cut). A `Sidebar.SubMenu` opens in a
|
|
1281
|
-
flyout, as in the collapsed rail. It has no collapsed state, so `collapsed`, `expandOnHover` and
|
|
1282
|
-
the bottom button do nothing: inside a Shell, set `collapseControl="sidebar"` so the Topbar shows
|
|
1283
|
-
no hamburger that would do nothing. Below `lg` the drawer shows the default sidebar.
|
|
1284
|
-
|
|
1285
|
-
`Shell container="boxed"` centres the topbar, the sidebar and the page in a column of at most
|
|
1286
|
-
1440px, with the page background on both sides. `fluid`, the default, spans the window.
|
|
1287
|
-
|
|
1288
|
-
A top navigation is a composition: a `Shell` with no `sidebar` and a `Navbar` in `topbar`. Its
|
|
1289
|
-
`Navbar.Items` take `Navbar.Item` and `Navbar.Menu`, a dropdown group of links (`Menu.Item` with
|
|
1290
|
-
an `href`, a `Menu.Group`, a `Menu.Separator`) that follows the menu button pattern: Enter, Space
|
|
1291
|
-
and the arrows open it, the arrows move between the links, Escape closes it and gives the focus
|
|
1292
|
-
back to the trigger, Tab leaves it. Mark the group `active` while one of its links is the current
|
|
1293
|
-
page, and that link with `aria-current="page"`. Items that do not fit move, from the last, into a
|
|
1294
|
-
"More" menu (`moreLabel` renames it) and come back when there is room. Only `Navbar.Item` and
|
|
1295
|
-
`Navbar.Menu` placed directly in `Navbar.Items` move; `Navbar.Items` inside a `Navbar.Brand` takes
|
|
1296
|
-
the room the bar leaves, and anywhere else it needs a bounded width (`min-w-0 flex-1`) to measure
|
|
1297
|
-
against. Below `md` the items leave the bar and `Navbar.MobileMenuButton` opens your own menu.
|
|
1298
|
-
|
|
1299
|
-
```tsx
|
|
1300
|
-
<Shell
|
|
1301
|
-
container="boxed"
|
|
1302
|
-
topbar={
|
|
1303
|
-
<Navbar>
|
|
1304
|
-
<Navbar.Brand>
|
|
1305
|
-
<Navbar.MobileMenuButton />
|
|
1306
|
-
<Navbar.Items>
|
|
1307
|
-
<Navbar.Item href="/" active>
|
|
1308
|
-
Dashboard
|
|
1309
|
-
</Navbar.Item>
|
|
1310
|
-
<Navbar.Menu label="Settings">
|
|
1311
|
-
<Menu.Item href="/profile">Profile</Menu.Item>
|
|
1312
|
-
<Menu.Item href="/team">Team</Menu.Item>
|
|
1313
|
-
</Navbar.Menu>
|
|
1314
|
-
</Navbar.Items>
|
|
1315
|
-
</Navbar.Brand>
|
|
1316
|
-
</Navbar>
|
|
1317
|
-
}
|
|
1318
|
-
>
|
|
1319
|
-
{/* page */}
|
|
1320
|
-
</Shell>
|
|
1321
|
-
```
|
|
1322
|
-
|
|
1323
|
-
## Split panes
|
|
1324
|
-
|
|
1325
|
-
`SplitView` puts two or three `SplitView.Pane` side by side in one bordered frame (mail,
|
|
1326
|
-
chat, a file manager). It fills the height of its parent and each pane scrolls on its own;
|
|
1327
|
-
`width` is a Tailwind class (`w-56`), and a pane without one takes the remaining space. Below
|
|
1328
|
-
`collapseBelow` (`md`, `lg` by default, `xl`) only the active pane shows. Control it with
|
|
1329
|
-
`activePane` and `onActivePaneChange`, or start it with `defaultActivePane` and switch from
|
|
1330
|
-
inside with `useSplitView()`; the pane that appears takes focus. A Back button is yours to
|
|
1331
|
-
place.
|
|
1332
|
-
|
|
1333
|
-
```tsx
|
|
1334
|
-
<SplitView activePane={pane} onActivePaneChange={setPane}>
|
|
1335
|
-
<SplitView.Pane id="list" label="Messages" width="w-80">
|
|
1336
|
-
{/* list */}
|
|
1337
|
-
</SplitView.Pane>
|
|
1338
|
-
<SplitView.Pane id="detail" label="Message">
|
|
1339
|
-
{/* detail */}
|
|
1340
|
-
</SplitView.Pane>
|
|
1341
|
-
</SplitView>
|
|
1342
|
-
```
|
|
1343
|
-
|
|
1344
|
-
## Code
|
|
1345
|
-
|
|
1346
|
-
`CodeBlock` shows a snippet with a copy button. It needs no highlighter, and takes a few variants:
|
|
1347
|
-
`lineNumbers`, `highlightLines={[3, '5-7']}`, `filename` (a header with the name, the language and
|
|
1348
|
-
the copy button), `wrap`, `maxHeight` (the block scrolls inside, takes a Tab stop and scrolls with the
|
|
1349
|
-
arrow keys), `diff` (the first column of each line is its sign, `+`, `-` or a space; changes are
|
|
1350
|
-
tinted and marked, the sign column is not copied) and `variant="terminal"` (a line starting with
|
|
1351
|
-
`$` and a space is a command, the others are its output; only the commands are copied). A block that a long line
|
|
1352
|
-
makes scroll sideways is a Tab stop too. The copy button copies the code, never the line numbers, the
|
|
1353
|
-
signs or the prompts.
|
|
1354
|
-
|
|
1355
|
-
To colour it, use the `/code` entry. It needs [Shiki](https://shiki.style), an optional peer
|
|
1356
|
-
dependency that only the apps using this entry install:
|
|
1357
|
-
|
|
1358
|
-
```bash
|
|
1359
|
-
pnpm add shiki
|
|
1360
|
-
```
|
|
1361
|
-
|
|
1362
|
-
```tsx
|
|
1363
|
-
import { CodeBlock } from '@sckyzo/cosmos-react/code';
|
|
1364
|
-
|
|
1365
|
-
<CodeBlock language="yaml" filename="deployment.yaml" code={manifest} lineNumbers />;
|
|
1366
|
-
```
|
|
1367
|
-
|
|
1368
|
-
The colours are the `--color-code-*` tokens of the theme, so they follow the theme, accent and text
|
|
1369
|
-
size. The JavaScript regex engine is used (no WebAssembly), and each language is its own chunk,
|
|
1370
|
-
loaded when a block shows it: until then, and for a language Cosmos has no grammar for, the block
|
|
1371
|
-
shows plain text. The languages are shell (`bash`, `sh`, `zsh`), `shellsession`, `yaml`, `json`,
|
|
1372
|
-
`toml`, `ini`, `dockerfile`, `sql`, `typescript`, `javascript`, `tsx`, `jsx`, `python`, `go`, `html`,
|
|
1373
|
-
`xml`, `css`, `diff`, `nginx`, `hcl`, `terraform`, `rust`, `java`, `php`, `markdown` and `promql`.
|
|
1374
|
-
A `highlight` prop still replaces the built-in highlighter. The main entry never imports Shiki:
|
|
1375
|
-
an app that does not use `/code` ships none of it. Imported without `shiki` installed, a block
|
|
1376
|
-
throws an error that names the package.
|
|
1377
|
-
|
|
1378
|
-
## Editor
|
|
1379
|
-
|
|
1380
|
-
`RichTextEditor` is a formatted text field: bold, italic, strike, headings, bulleted and numbered
|
|
1381
|
-
lists, quotes, code, links, undo and redo. It lives in the `/editor` entry and is built on
|
|
1382
|
-
[Tiptap](https://tiptap.dev) (MIT), whose packages are optional peer dependencies that only the apps
|
|
1383
|
-
using this entry install:
|
|
1384
|
-
|
|
1385
|
-
```bash
|
|
1386
|
-
pnpm add @tiptap/react @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extensions
|
|
1387
|
-
```
|
|
1388
|
-
|
|
1389
|
-
```tsx
|
|
1390
|
-
import { RichTextEditor } from '@sckyzo/cosmos-react/editor';
|
|
1391
|
-
|
|
1392
|
-
<RichTextEditor
|
|
1393
|
-
label="Reply"
|
|
1394
|
-
placeholder="Write a reply…"
|
|
1395
|
-
value={html}
|
|
1396
|
-
onChange={setHtml}
|
|
1397
|
-
maxLength={2000}
|
|
1398
|
-
/>;
|
|
1399
|
-
```
|
|
1400
|
-
|
|
1401
|
-
`value`, `defaultValue` and `onChange` carry HTML by default, or the ProseMirror JSON document with
|
|
1402
|
-
`format="json"`; an empty HTML document is `''`. `variant` picks the chrome: `toolbar` (default,
|
|
1403
|
-
buttons on top; `toolbar={['bold', 'italic', 'link']}` picks them), `bubble` (a small toolbar over
|
|
1404
|
-
the selection; Alt+F10 moves the focus into it, Escape gives it back) or `minimal` (no toolbar, the
|
|
1405
|
-
shortcuts and Markdown-like input rules only: `##` and a space, `-` and a space, `**bold**`).
|
|
1406
|
-
`labels` replaces every text the editor draws, tooltips included. `readOnly`, `disabled`, `rows`,
|
|
1407
|
-
`autoFocus` and `name` (a hidden input, for a native form) work as on a Textarea, and so do `label`,
|
|
1408
|
-
`description`, `error` and `required`: the label and the error are linked to the editable text.
|
|
1409
|
-
`maxLength` counts the text characters and stops typing and pasting at the limit.
|
|
1410
|
-
|
|
1411
|
-
Links accept `http(s):`, `mailto:` and relative URLs only: a
|
|
1412
|
-
`javascript:` link is refused whether it is typed in the link field, typed as Markdown, pasted or
|
|
1413
|
-
passed in `value`. The schema holds paragraphs, headings 2 and 3, lists, quotes, hard breaks and the
|
|
1414
|
-
marks above; images, tables and anything else are dropped on paste. **That is the editor's
|
|
1415
|
-
guarantee, not yours: sanitise the stored HTML again on the server before you render it elsewhere.**
|
|
1416
|
-
The main entry never imports Tiptap: an app that does not use `/editor` ships none of it.
|
|
1417
|
-
|
|
1418
|
-
## Map
|
|
1419
|
-
|
|
1420
|
-
`MapView` draws places on a map: pins in the theme's colours with an icon or a number, a popup per
|
|
1421
|
-
pin, groups of close pins as a counted circle, zoom buttons and a fit to the markers. It lives in
|
|
1422
|
-
the `/map` entry and needs [MapLibre GL JS](https://maplibre.org) (BSD-3), an optional peer
|
|
1423
|
-
dependency that only the apps using this entry install:
|
|
1424
|
-
|
|
1425
|
-
```bash
|
|
1426
|
-
pnpm add maplibre-gl
|
|
1427
|
-
```
|
|
1428
|
-
|
|
1429
|
-
MapLibre draws in a web worker. Since `maplibre-gl` 6, an app built with a bundler tells it where
|
|
1430
|
-
the worker is, once, before the first map; without that the map stays empty. With Vite:
|
|
1431
|
-
|
|
1432
|
-
```ts
|
|
1433
|
-
import { setWorkerUrl } from 'maplibre-gl';
|
|
1434
|
-
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
|
1435
|
-
|
|
1436
|
-
setWorkerUrl(workerUrl);
|
|
1437
|
-
```
|
|
1438
|
-
|
|
1439
|
-
MapLibre's [installation guide](https://maplibre.org/maplibre-gl-js/docs/#installation) gives the
|
|
1440
|
-
line for webpack, Rollup, esbuild and Next.js, and the extra setting for server rendering.
|
|
1441
|
-
`maplibre-gl` 5 needs none of this.
|
|
1442
|
-
|
|
1443
|
-
Load MapLibre's stylesheet once in the app, and import the component from `/map`:
|
|
1444
|
-
|
|
1445
|
-
```tsx
|
|
1446
|
-
import { useState } from 'react';
|
|
1447
|
-
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
1448
|
-
import { MapMarkerList, MapView, type MapMarker } from '@sckyzo/cosmos-react/map';
|
|
1449
|
-
|
|
1450
|
-
const stores: MapMarker[] = [
|
|
1451
|
-
{ id: 'harbour', label: 'Harbour', position: [4.81, 45.71], description: '12 quay street' },
|
|
1452
|
-
{ id: 'station', label: 'Station', position: [4.9, 45.75], intent: 'success', icon: 3 },
|
|
1453
|
-
];
|
|
1454
|
-
|
|
1455
|
-
export const Stores = () => {
|
|
1456
|
-
const [selected, setSelected] = useState<string | null>(null);
|
|
1457
|
-
return (
|
|
1458
|
-
<>
|
|
1459
|
-
<MapMarkerList markers={stores} selectedId={selected} onSelect={setSelected} />
|
|
1460
|
-
<MapView
|
|
1461
|
-
markers={stores}
|
|
1462
|
-
style="https://tiles.example.com/styles/light.json"
|
|
1463
|
-
darkStyle="https://tiles.example.com/styles/dark.json"
|
|
1464
|
-
ariaLabel="Stores"
|
|
1465
|
-
selectedId={selected}
|
|
1466
|
-
onSelectedIdChange={setSelected}
|
|
1467
|
-
/>
|
|
1468
|
-
</>
|
|
1469
|
-
);
|
|
1470
|
-
};
|
|
1471
|
-
```
|
|
1472
|
-
|
|
1473
|
-
- **The base map is yours.** Cosmos ships no tiles: a tile server has its own terms and sees every
|
|
1474
|
-
visitor. `style` is a MapLibre style (a URL or an object) and its attribution stays visible.
|
|
1475
|
-
`darkStyle` is optional: under the dark themes (`.dark`, `.deep-blue`) the map switches to it,
|
|
1476
|
-
and without it keeps `style`. Pass constants, not a new object on each render. A markers array
|
|
1477
|
-
is the same: define it once, or the pins are redrawn each time.
|
|
1478
|
-
- **Pins, popups and buttons are React and HTML**, coloured by `intent` with the theme's classes,
|
|
1479
|
-
so a theme, accent or text size switch repaints them with no code. A title or description with
|
|
1480
|
-
markup shows as text.
|
|
1481
|
-
- **`variant="static"`** is a fixed view for a card or a detail page: no gesture, no zoom buttons,
|
|
1482
|
-
no focus stop, no popup, and the pins are pictures.
|
|
1483
|
-
- **Keyboard and screen readers.** The interactive map is one focus stop with the name given by
|
|
1484
|
-
`ariaLabel`: the arrow keys pan, `+` and `-` zoom. A map alone cannot be used with a screen
|
|
1485
|
-
reader, so show the same `markers` as a `MapMarkerList` next to it: choosing a row opens the
|
|
1486
|
-
popup on the map. On a static map, the list is the only way to the places.
|
|
1487
|
-
- `cluster` (default on) groups close pins; a click on a group zooms in to split it. `fit` frames
|
|
1488
|
-
the markers on the first draw and when the set of markers changes; otherwise `initialCenter` and
|
|
1489
|
-
`initialZoom` give the view. `onViewChange` reports each pan or zoom.
|
|
1490
|
-
- A browser without WebGL shows `unavailableMessage` in place of the map. The main entry never
|
|
1491
|
-
imports MapLibre: an app that does not use `/map` ships none of it. Imported without
|
|
1492
|
-
`maplibre-gl` installed, a map throws an error that names the package.
|
|
1493
|
-
|
|
1494
|
-
## Right to left
|
|
1495
|
-
|
|
1496
|
-
Set the native `dir` attribute on `<html>`, or on any ancestor of the components, and
|
|
1497
|
-
they follow it. There is no prop and no `data-` attribute:
|
|
1498
|
-
|
|
1499
|
-
```html
|
|
1500
|
-
<html lang="ar" dir="rtl"></html>
|
|
1501
|
-
```
|
|
1502
|
-
|
|
1503
|
-
What mirrors is what follows the reading direction: the side an icon, a ribbon, a floating
|
|
1504
|
-
panel or a status dot sits on, the padding and margins, the corners of a button group, a
|
|
1505
|
-
chevron that points forward, the `start` and `end` alignments of panels. A prop that names
|
|
1506
|
-
a side of the line takes `start` or `end`, never `left` or `right` (`Ribbon`'s
|
|
1507
|
-
`position="top-end"`, `BackToTopFab`'s `position="bottom-end"`, `iconPosition="start"`,
|
|
1508
|
-
`align="end"`), so the side swaps under `dir="rtl"`. `top` and `bottom` stay as they are.
|
|
1509
|
-
Left to right renders as it always did.
|
|
1510
|
-
|
|
1511
|
-
Text that comes from you and sits inside a label or a sentence (a name, a value, a key) is
|
|
1512
|
-
wrapped in `<bdi>`, so a Latin name in an Arabic page, or the reverse, keeps its own order
|
|
1513
|
-
and does not drag the punctuation around it. The package's own copy stays in English, as it
|
|
1514
|
-
is everywhere else. A Latin text cut short by an ellipsis in a right-to-left page (a long file
|
|
1515
|
-
name in a narrow tile) shows its end rather than its beginning: the line ends on the left, where
|
|
1516
|
-
Latin text starts.
|
|
1517
|
-
|
|
1518
|
-
What you put in a slot (a table cell, a card's title, a list item, a toast's message) is drawn
|
|
1519
|
-
as you pass it. A Latin value that starts with a number reorders in a right-to-left line, "248 W"
|
|
1520
|
-
showing as "W 248": wrap it in `<bdi>` yourself.
|
|
1521
|
-
|
|
1522
|
-
```tsx
|
|
1523
|
-
<TableCell>
|
|
1524
|
-
<bdi>248 W</bdi>
|
|
1525
|
-
</TableCell>
|
|
1526
|
-
```
|
|
1527
|
-
|
|
1528
|
-
A text field (`Input`, `SearchInput`, `Textarea`, the inputs of `Combobox`, `MultiSelect` and
|
|
1529
|
-
`TagInput`) follows the direction of what is typed in it: Arabic aligns and flows right to
|
|
1530
|
-
left, a URL left to right, each paragraph of a `Textarea` on its own. It does that with
|
|
1531
|
-
`unicode-bidi: plaintext` rather than `dir="auto"`, which would also turn the field's own
|
|
1532
|
-
direction around for a Latin text and leave the room reserved for a trailing icon or button
|
|
1533
|
-
on the wrong side. A `PasswordInput` does the same once its value is shown; hidden, it keeps
|
|
1534
|
-
the page's direction, as HTML leaves password fields out of `dir="auto"`, so how it aligns
|
|
1535
|
-
says nothing of what is typed. A value that is a code reads left to right in any page: the cells of
|
|
1536
|
-
`OtpInput`, the country button and number of `PhoneInput`, and the swatch and hex of
|
|
1537
|
-
`ColorInput` are `dir="ltr"` islands. `Slider` and `Calendar` hand the direction to Radix and
|
|
1538
|
-
react-day-picker, which lay out the handles and read the arrow keys from a `dir` prop and not
|
|
1539
|
-
from the page.
|
|
1540
|
-
|
|
1541
|
-
Tailwind's `rtl:` variant (redefined by the theme to follow the nearest `dir`) is the tool
|
|
1542
|
-
for what has no logical form, such as `rtl:-scale-x-100` on an arrow. In your own markup,
|
|
1543
|
-
prefer the logical utilities (`ms-`, `pe-`, `text-start`, `border-s`, `rounded-e`, `inset-s-`)
|
|
1544
|
-
to the physical ones.
|
|
1545
|
-
|
|
1546
|
-
A component draws the icon you pass it as it is. An icon that points along the line (a
|
|
1547
|
-
forward arrow, a back chevron, undo) turns around through its own class:
|
|
1548
|
-
|
|
1549
|
-
```tsx
|
|
1550
|
-
import { clsx } from 'clsx';
|
|
1551
|
-
import { ArrowRight, type LucideProps } from 'lucide-react';
|
|
1552
|
-
|
|
1553
|
-
const Forward = ({ className, ...props }: LucideProps) => (
|
|
1554
|
-
<ArrowRight {...props} className={clsx(className, 'rtl:-scale-x-100')} />
|
|
1555
|
-
);
|
|
1556
|
-
|
|
1557
|
-
<Button icon={Forward} iconPosition="end">
|
|
1558
|
-
Continue
|
|
1559
|
-
</Button>;
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
The overlays name their sides `start` and `end`, which follow the reading direction: left and
|
|
1563
|
-
right under `dir="ltr"`, right and left under `dir="rtl"`. That is `Drawer`'s `side`
|
|
1564
|
-
(`start` | `end` | `bottom`, default `end`), the `align` of `Menu`, `Dropdown`, `UserMenu` and
|
|
1565
|
-
`NotificationsPanel` (`start` | `end`), and the `position` of `Tooltip` and `Popover.Content`
|
|
1566
|
-
(`top` | `bottom` | `start` | `end`). A panel that opens next to a trigger takes the direction of
|
|
1567
|
-
that trigger, even though it renders at the end of `<body>`, and flips to the other side as before
|
|
1568
|
-
when the screen would cut it. A `Drawer`, a `Modal`, a `ConfirmationModal` and a `CommandPalette` are
|
|
1569
|
-
not next to anything: they read the `dir` of `<html>`, so set it there rather than on a wrapper.
|
|
1570
|
-
`Toaster` stacks its toasts at the bottom corner at the end of the line (bottom left under
|
|
1571
|
-
`dir="rtl"`, from its `dir` prop or `<html>`); sonner's own `position` is a corner of the screen,
|
|
1572
|
-
so one you pass is kept as it is. A keyboard shortcut (the `shortcut` of a `Menu.Item`,
|
|
1573
|
-
`CommandPalette.Item` or `Tooltip`, the keys of a `HelpOverlay.Shortcut`) keeps the order the keys
|
|
1574
|
-
are pressed in.
|
|
1575
|
-
|
|
1576
|
-
Two things stay as they are on purpose. A chevron that points down, or turns over when a
|
|
1577
|
-
panel opens (`Accordion`), and a trend arrow that points up or down, are not turned around.
|
|
1578
|
-
And the arrow keys that move a card across a `KanbanBoard` follow where the columns are, not
|
|
1579
|
-
the reading direction: with the first column on the right, ArrowLeft takes the card to the
|
|
1580
|
-
next one.
|
|
1581
|
-
|
|
1582
|
-
The page layout follows the line too. A `Shell` puts its `Sidebar` at the start (on the right
|
|
1583
|
-
under `dir="rtl"`, with its edge, its collapse toggle and the drawer of the phone layout on that
|
|
1584
|
-
side) and its `secondary` panel at the end; `SplitView` runs its panes from the start. `Tabs`
|
|
1585
|
-
and the settings pickers swap ArrowLeft and ArrowRight with the direction, and a scrolling strip
|
|
1586
|
-
fades the side with more to see. The pinned columns of `DataTable` (`pin: 'start'` | `'end'`)
|
|
1587
|
-
and `Table`'s `stickyFirstColumn` stick to the start or end edge, with their shadow on the side
|
|
1588
|
-
that faces the columns scrolling under them. In a `Chat`, a person's own messages sit at the end
|
|
1589
|
-
of the line and the others' at the start, and what was typed in a message or the composer reads in its own script. The `hotkeys` of a
|
|
1590
|
-
`RecordPager` are literal keys and do not follow the direction: give Previous the arrow that
|
|
1591
|
-
points toward it where the page reads right to left.
|
|
1592
|
-
|
|
1593
|
-
Output keeps its order. Code (`CodeBlock`), logs (`LogViewer`, `JobLog`, and the message, fields
|
|
1594
|
-
and stack of `LogEntryDetail`) and keyboard keys (`Kbd`) read left to right in any page: they are
|
|
1595
|
-
`dir="ltr"` islands, so a number gutter stays at the left of its lines and a log scrolls from the
|
|
1596
|
-
left. What surrounds them follows the page: the title bar and copy button of a code block, the
|
|
1597
|
-
toolbar and the "Jump to latest" button of a log, the step headers of a `JobLog` (their chevron
|
|
1598
|
-
turns around). Time keeps its order too, as the axis of a chart does: `HeartbeatBar` and
|
|
1599
|
-
`UptimeBars` draw their oldest mark on the left and their newest on the right in both directions,
|
|
1600
|
-
with "N days ago" at the left and "Today" at the right, and ArrowLeft moves to the older mark. A
|
|
1601
|
-
percentage, a latency or a count the components write (`99.95%`, `182 ms`, `3 of 12`) is wrapped in
|
|
1602
|
-
`<bdi>`, so it reads in order inside an Arabic line.
|
|
1603
|
-
|
|
1604
|
-
`Carousel` hands the direction to Embla, which lays the slides out from the end of the line: the
|
|
1605
|
-
first slide is at the start, the next one comes in from the end, a swipe follows, and the arrows
|
|
1606
|
-
and their chevrons sit on their sides. The play triangle of a `VideoEmbed` points right in both
|
|
1607
|
-
directions, as the time of a video runs from the left. `MapView` does not mirror the map; its zoom
|
|
1608
|
-
buttons and the popup of a pin follow the page. Arabic and Hebrew labels in the tiles of a map
|
|
1609
|
-
need MapLibre's RTL text plugin (`setRTLTextPlugin` in `maplibre-gl`), which a host sets once
|
|
1610
|
-
for the page: Cosmos adds no dependency for it. In `RichTextEditor`, each block follows what is
|
|
1611
|
-
typed in it (the same `unicode-bidi: plaintext` as a `Textarea`), lists are indented and quotes
|
|
1612
|
-
edged on the start side, the arrow keys along the toolbar swap, and the link field reads left to
|
|
1613
|
-
right.
|
|
1614
|
-
|
|
1615
|
-
The pages follow the same rules. The back arrow of `ResetPasswordForm` and `TwoStepVerificationForm`
|
|
1616
|
-
turns around, the address or number a form says it sent the code to is wrapped in `<bdi>`, and
|
|
1617
|
-
`AuthLayout`'s `asidePosition` (`start` | `end`) puts the panel at the start or the end of the line.
|
|
1618
|
-
|
|
1619
|
-
Every group of the Storybook sidebar is converted: **Atoms**, **Actions**, **Forms**, **Data**,
|
|
1620
|
-
**Lists**, **Feedback**, **Overlays**, **Navigation**, **Layout**, **Chat**, **Tables**,
|
|
1621
|
-
**Settings**, **Monitoring**, **Media**, **Status**, **Typography**, **Foundations** and
|
|
1622
|
-
**Examples**. A check in the repository refuses a physical inline utility (`ml-`, `pr-`, `left-`,
|
|
1623
|
-
`text-left`…) in any source file of the package, and every story is rendered right to left in CI.
|
|
1624
|
-
In Storybook, the **Direction** menu of the toolbar (`&globals=direction:rtl`) renders any story
|
|
1625
|
-
right to left.
|
|
1626
|
-
|
|
1627
|
-
## Themes and accents
|
|
1628
|
-
|
|
1629
|
-
The theme is a class on `<html>`; the accent, the font, the text size, the
|
|
1630
|
-
density and the corner radius are the `data-accent`, `data-font`,
|
|
1631
|
-
`data-text-size`, `data-density` and `data-radius` attributes, see the
|
|
1632
|
-
[theme README](../theme#themes--accents). `AppearanceSettings` renders a picker
|
|
1633
|
-
for each, and `applyAppearance` sets them on the document:
|
|
1634
|
-
|
|
1635
|
-
```tsx
|
|
1636
|
-
import { useEffect, useState } from 'react';
|
|
1637
|
-
import { AppearanceSettings, applyAppearance, type CosmosAppearance } from '@sckyzo/cosmos-react';
|
|
1638
|
-
|
|
1639
|
-
function Appearance() {
|
|
1640
|
-
const [appearance, setAppearance] = useState<CosmosAppearance>({
|
|
1641
|
-
theme: 'light',
|
|
1642
|
-
accent: 'blue',
|
|
1643
|
-
font: 'outfit',
|
|
1644
|
-
textSize: 'md',
|
|
1645
|
-
density: 'default',
|
|
1646
|
-
});
|
|
1647
|
-
useEffect(() => applyAppearance(appearance), [appearance]);
|
|
1648
|
-
const update = (patch: Partial<CosmosAppearance>) => setAppearance((a) => ({ ...a, ...patch }));
|
|
1649
|
-
return (
|
|
1650
|
-
<AppearanceSettings
|
|
1651
|
-
{...appearance}
|
|
1652
|
-
onThemeChange={(theme) => update({ theme })}
|
|
1653
|
-
onAccentChange={(accent) => update({ accent })}
|
|
1654
|
-
onFontChange={(font) => update({ font })}
|
|
1655
|
-
onTextSizeChange={(textSize) => update({ textSize })}
|
|
1656
|
-
onDensityChange={(density) => update({ density })}
|
|
1657
|
-
/>
|
|
1658
|
-
);
|
|
1659
|
-
}
|
|
1660
|
-
```
|
|
1661
|
-
|
|
1662
|
-
The font, text size and density groups show only when their handlers are set. Pass
|
|
1663
|
-
`fonts` to offer only the fonts whose CSS the app imports
|
|
1664
|
-
(`@sckyzo/cosmos-theme/fonts/<name>`).
|
|
1665
|
-
|
|
1666
|
-
For a top bar, `AppearanceMenu` takes the same props and puts the pickers in a
|
|
1667
|
-
panel behind an icon button, for example in `Topbar`'s `endActions`. Set `onReset`
|
|
1668
|
-
to add a Reset button under the groups.
|
|
1669
|
-
|
|
1670
|
-
To offer a **System** choice that follows the device's `prefers-color-scheme`,
|
|
1671
|
-
add `COSMOS_SYSTEM_THEME` to the themes and apply again when the device switches
|
|
1672
|
-
(it resolves to `light` or `dark`; set `systemThemes` for another pair). Nothing
|
|
1673
|
-
changes for an app that doesn't opt in (`ThemeToggleTri` takes a `system` prop for the same
|
|
1674
|
-
choice at the end of its cycle):
|
|
1675
|
-
|
|
1676
|
-
```tsx
|
|
1677
|
-
const [appearance, setAppearance] = useState<CosmosAppearance>({ theme: 'system', accent: 'blue' });
|
|
1678
|
-
useEffect(() => {
|
|
1679
|
-
applyAppearance(appearance);
|
|
1680
|
-
return appearance.theme === 'system'
|
|
1681
|
-
? watchSystemTheme(() => applyAppearance(appearance))
|
|
1682
|
-
: undefined;
|
|
1683
|
-
}, [appearance]);
|
|
1684
|
-
// …
|
|
1685
|
-
<AppearanceSettings {...appearance} themes={[COSMOS_SYSTEM_THEME, ...COSMOS_THEMES]} … />
|
|
1686
|
-
```
|
|
1687
|
-
|
|
1688
|
-
Saving the choice (local storage, user profile) is left to the app.
|
|
1689
|
-
|
|
1690
|
-
`applyAppearance` also takes a `direction` (`'ltr'` or `'rtl'`): it sets `dir="rtl"` on
|
|
1691
|
-
`<html>`, and removes `dir` for `ltr`, like the other defaults.
|
|
1692
|
-
|
|
1693
|
-
Charts live in a separate package, [`@sckyzo/cosmos-charts`](../charts), so apps
|
|
1694
|
-
that don't render charts don't ship a chart library.
|
|
1695
|
-
|
|
1696
|
-
### Customizer
|
|
1697
|
-
|
|
1698
|
-
`Customizer` is a panel on the end side (a `Drawer`) that puts the choices above in one place,
|
|
1699
|
-
plus the shell's layout, sidebar mode and container, with optional presets, a Reset and a
|
|
1700
|
-
"Copy config" that copies the value as JSON. It only **holds** the choice: it is controlled
|
|
1701
|
-
(`value`, `onChange`, with the whole new value on each click) and applies nothing itself. The
|
|
1702
|
-
host applies it on every change:
|
|
1703
|
-
|
|
1704
|
-
```tsx
|
|
1705
|
-
const [value, setValue] = useState<CustomizerValue>(CUSTOMIZER_DEFAULT_VALUE);
|
|
1706
|
-
const [open, setOpen] = useState(false);
|
|
1707
|
-
useEffect(() => applyAppearance(value), [value]);
|
|
1708
|
-
const { layout, sidebar, container } = { ...CUSTOMIZER_DEFAULT_VALUE, ...value };
|
|
1709
|
-
|
|
1710
|
-
<Shell
|
|
1711
|
-
container={container}
|
|
1712
|
-
sidebarCollapsed={sidebar === 'collapsed' || sidebar === 'hover'}
|
|
1713
|
-
topbar={layout === 'horizontal' ? <Navbar>…</Navbar> : <Topbar />}
|
|
1714
|
-
sidebar={
|
|
1715
|
-
layout === 'horizontal' ? undefined : (
|
|
1716
|
-
<Sidebar
|
|
1717
|
-
variant={sidebar === 'compact' ? 'compact' : 'default'}
|
|
1718
|
-
expandOnHover={sidebar === 'hover' ? 'overlay' : false}
|
|
1719
|
-
>
|
|
1720
|
-
…
|
|
1721
|
-
</Sidebar>
|
|
1722
|
-
)
|
|
1723
|
-
}
|
|
1724
|
-
>
|
|
1725
|
-
…
|
|
1726
|
-
</Shell>
|
|
1727
|
-
<Customizer open={open} onClose={() => setOpen(false)} value={value} onChange={setValue} />
|
|
1728
|
-
```
|
|
1729
|
-
|
|
1730
|
-
| Value | What the host does |
|
|
1731
|
-
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
1732
|
-
| `theme`, `accent`, `font`, `textSize`, `density`, `radius`, `direction` | `applyAppearance(value)` |
|
|
1733
|
-
| `container` | `Shell`'s `container` |
|
|
1734
|
-
| `sidebar: 'collapsed'` | `Shell`'s `sidebarCollapsed` |
|
|
1735
|
-
| `sidebar: 'hover'` | collapsed, plus `Sidebar`'s `expandOnHover="overlay"` |
|
|
1736
|
-
| `sidebar: 'compact'` | `Sidebar`'s `variant="compact"` (and `collapseControl="sidebar"` on the `Shell`) |
|
|
1737
|
-
| `layout: 'horizontal'` | a `Navbar` in `Shell`'s `topbar` in place of the `Sidebar` |
|
|
65
|
+
## Documentation
|
|
1738
66
|
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
67
|
+
- [Getting started](https://github.com/SckyzO/cosmos/blob/main/docs/getting-started.md): the setup explained, with a Vite and a Next.js recipe.
|
|
68
|
+
- [Appearance](https://github.com/SckyzO/cosmos/blob/main/docs/guides/appearance.md): themes, accents, fonts, density, radius, and the pickers.
|
|
69
|
+
- [Optional entries](https://github.com/SckyzO/cosmos/blob/main/docs/guides/optional-entries.md): `@sckyzo/cosmos-react/code`, `/editor` and `/map`.
|
|
70
|
+
- [Right to left](https://github.com/SckyzO/cosmos/blob/main/docs/guides/right-to-left.md) and [refs and routing](https://github.com/SckyzO/cosmos/blob/main/docs/guides/refs-and-routing.md).
|
|
71
|
+
- [Component notes](https://github.com/SckyzO/cosmos/blob/main/docs/README.md#components): tables, monitoring, logs, chat, the application shell and more.
|
|
72
|
+
- [Charts](https://github.com/SckyzO/cosmos/blob/main/docs/guides/charts.md) live in `@sckyzo/cosmos-charts`, so apps that don't render charts
|
|
73
|
+
don't ship a chart library.
|
|
1746
74
|
|
|
1747
75
|
## License
|
|
1748
76
|
|
|
1749
|
-
MIT
|
|
77
|
+
MIT, see [LICENSE](https://github.com/SckyzO/cosmos/blob/main/LICENSE).
|