@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/README.md CHANGED
@@ -2,38 +2,36 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@sckyzo/cosmos-react)](https://www.npmjs.com/package/@sckyzo/cosmos-react)
4
4
 
5
- React 19 component library for the Cosmos design system. 135+ themeable,
6
- accessible components, built with Tailwind 4 and shipped as ESM + CJS + types.
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
- pnpm add @sckyzo/cosmos-react @sckyzo/cosmos-theme
14
- pnpm add react react-dom # peers, react 19+
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
- build has to generate those classes. Tailwind only scans your own source by
24
- default, so you **must** point `@source` at the installed package. Without it no
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: let Tailwind scan the compiled components for class names.
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
- ## Refs
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
- Keep the `Customizer` outside what `layout` swaps, so the panel keeps its focus as the shell
1740
- re-renders. Cosmos ships no presets: pass `presets` (`{ id, label, value }`, the value being
1741
- a part of the whole) to show the section. `sections` lists the sections to show (a host with a
1742
- fixed layout leaves out `layout`, `sidebar` and `container`), `defaultValue` is what Reset
1743
- restores, and `themes`, `accents`, `fonts`, `textSizes`, `densities` and `radii` narrow the choices as
1744
- they do on `AppearanceSettings`. The `Settings/Customizer` stories wire all of it, with local
1745
- storage.
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 — see [LICENSE](../../LICENSE).
77
+ MIT, see [LICENSE](https://github.com/SckyzO/cosmos/blob/main/LICENSE).