@sckyzo/cosmos-react 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +741 -2
- package/calendar.css +4 -0
- package/dist/index.cjs +18593 -8437
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3856 -980
- package/dist/index.d.ts +3856 -980
- package/dist/index.js +18449 -8348
- package/dist/index.js.map +1 -1
- package/package.json +25 -16
package/README.md
CHANGED
|
@@ -39,8 +39,16 @@ In your main CSS file:
|
|
|
39
39
|
|
|
40
40
|
/* Optional: the Outfit + JetBrains Mono webfonts. */
|
|
41
41
|
@import '@sckyzo/cosmos-theme/fonts';
|
|
42
|
+
|
|
43
|
+
/* Only if you use Calendar, DatePicker or a component built on them. */
|
|
44
|
+
@import '@sckyzo/cosmos-react/calendar.css';
|
|
42
45
|
```
|
|
43
46
|
|
|
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
|
+
|
|
44
52
|
If you also use `@sckyzo/cosmos-charts`, add a second `@source` line for its
|
|
45
53
|
`dist` the same way.
|
|
46
54
|
|
|
@@ -64,6 +72,718 @@ function Example() {
|
|
|
64
72
|
}
|
|
65
73
|
```
|
|
66
74
|
|
|
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
|
+
- `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".
|
|
129
|
+
- `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.
|
|
130
|
+
- `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.
|
|
131
|
+
- `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.
|
|
132
|
+
- `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.
|
|
133
|
+
- `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.
|
|
134
|
+
- `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.
|
|
135
|
+
- `ProgressBar intent="neutral"`: a gray fill for a gauge that means nothing (space used, a quota). Segments and `ProgressRing` take it too.
|
|
136
|
+
- `Badge size="lg"` and `shape="rounded"`: a larger badge, and `rounded-md` corners in place of the pill.
|
|
137
|
+
- `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.
|
|
138
|
+
- `Ribbon shape="pill"`: a fully rounded label set inside the card, 12px from the corner of its `position`.
|
|
139
|
+
|
|
140
|
+
## Tables
|
|
141
|
+
|
|
142
|
+
`DataTable` sorts, filters, searches and paginates its rows. Give it a height
|
|
143
|
+
(`className="max-h-96"`) and the rows scroll under the header, in an area the
|
|
144
|
+
keyboard can focus and scroll while it overflows; `aria-label` or
|
|
145
|
+
`aria-labelledby` names the table, and that area as a region. For a short
|
|
146
|
+
read-only table, compose the table primitives instead: `Table`, `TableCaption`,
|
|
147
|
+
`TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` and
|
|
148
|
+
`TableCell` render the native elements with the Cosmos styling and no logic.
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import {
|
|
152
|
+
Table,
|
|
153
|
+
TableBody,
|
|
154
|
+
TableCaption,
|
|
155
|
+
TableCell,
|
|
156
|
+
TableHead,
|
|
157
|
+
TableHeaderCell,
|
|
158
|
+
TableRow,
|
|
159
|
+
} from '@sckyzo/cosmos-react';
|
|
160
|
+
|
|
161
|
+
<Table striped>
|
|
162
|
+
<TableCaption>Nodes</TableCaption>
|
|
163
|
+
<TableHead>
|
|
164
|
+
<TableRow>
|
|
165
|
+
<TableHeaderCell>Name</TableHeaderCell>
|
|
166
|
+
<TableHeaderCell numeric>CPU</TableHeaderCell>
|
|
167
|
+
</TableRow>
|
|
168
|
+
</TableHead>
|
|
169
|
+
<TableBody>
|
|
170
|
+
<TableRow>
|
|
171
|
+
<TableCell>node-101</TableCell>
|
|
172
|
+
<TableCell numeric>42%</TableCell>
|
|
173
|
+
</TableRow>
|
|
174
|
+
</TableBody>
|
|
175
|
+
</Table>;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`DataTable` shows a muted two-way icon on every sortable column, keeps its
|
|
179
|
+
header (and the width of its selection and action columns) while it loads, is
|
|
180
|
+
empty or fails, and takes `loadingRows` for the number of placeholder rows. Its
|
|
181
|
+
rows can act: `onRowClick(row)` makes them focusable (Enter and Space) and
|
|
182
|
+
clickable, `rowHref(row)` makes each one a link (a real `<a>` in the first
|
|
183
|
+
column, drawn over the row, so middle click and "Open in a new tab" work; keep
|
|
184
|
+
buttons and links out of that column). A click on a control inside the row (the
|
|
185
|
+
checkbox, the action column, a link or button in another cell) never counts as a
|
|
186
|
+
row click. `RowActionsMenu` is the ⋮ button and menu of a row, for
|
|
187
|
+
`renderRowAction`: give it `Menu.Item`s, a destructive one with
|
|
188
|
+
`intent="danger"`. On a narrow screen the edge of a table that scrolls sideways
|
|
189
|
+
fades, and `stickyFirstColumn` pins its first column (after the checkbox on
|
|
190
|
+
`DataTable`). `TableIdentityCell`, `TableIdCell`, `TableDescriptionCell` and
|
|
191
|
+
`TableAmountCell` are the cells tables keep rebuilding.
|
|
192
|
+
|
|
193
|
+
`DataTable`'s column filters can be controlled: `filters`, `defaultFilters` and
|
|
194
|
+
`onFiltersChange(filters)` hold a record of column `key` to the set of selected
|
|
195
|
+
values. `filterTabs` (`{ columnKey, options: [{ value, label, count }], allLabel,
|
|
196
|
+
allCount }`) puts underlined status tabs at the top of the card, each setting
|
|
197
|
+
that column's filter ("All" clears it) and staying in sync with the active
|
|
198
|
+
filters bar; the counts come from you, the table never computes them.
|
|
199
|
+
|
|
200
|
+
`DataTable` takes `mobileLayout="cards"` to show each row as a block below `sm`
|
|
201
|
+
instead of scrolling: the checkbox, the column marked `mobile: 'title'` and the
|
|
202
|
+
row action on the first line, then a label/value pair per other column
|
|
203
|
+
(`mobileLabel` renames a label, `mobile: 'hidden'` drops a column). The toolbar
|
|
204
|
+
gets a "Sort by" select for the sortable columns. The default, `scroll`, keeps
|
|
205
|
+
the table.
|
|
206
|
+
|
|
207
|
+
`DataTable`'s `density` is controllable like the rest: `density`, `defaultDensity`
|
|
208
|
+
and `onDensityChange(density)`. As with `pageSize`, a `density` passed without
|
|
209
|
+
`onDensityChange` is a controlled value that never moves, so a saved view
|
|
210
|
+
reports its density and the table keeps the one you pass.
|
|
211
|
+
|
|
212
|
+
`exportable` adds an Export button before Columns (the toolbar shows even
|
|
213
|
+
without a search field). It downloads a CSV of every page of the rows that the
|
|
214
|
+
search, filters and sort keep, in the visible columns and their order; the
|
|
215
|
+
selection and action columns stay out. A header is the column's `label` when it
|
|
216
|
+
is a string or a number, else `mobileLabel` when that is a string, else the
|
|
217
|
+
`key`. A cell takes `exportValue(row)`, else `sortValue`, `searchValue` or
|
|
218
|
+
`filterValue` (the first the column has); `null` is an empty cell. The file is
|
|
219
|
+
named by `exportFileName` (default `export.csv`) and is UTF-8 with a byte order
|
|
220
|
+
mark (U+FEFF), so that spreadsheets read accents. Fields follow RFC 4180
|
|
221
|
+
(quoted when they hold `"`, `,` or a line break, records end with CRLF), and a
|
|
222
|
+
header or string value that starts with `=`, `+`, `-`, `@`, a tab or a carriage
|
|
223
|
+
return gets a `'` prefix so a spreadsheet never runs it as a formula; a
|
|
224
|
+
`number` is left alone. To export on the server, `onExport(query)` receives `{ search, filters,
|
|
225
|
+
sort, columns }` (the visible column keys, in order) and builds no file.
|
|
226
|
+
`toCsv(rows, columns)` gives the same text, without the byte order mark, for an
|
|
227
|
+
export outside a table (prepend U+FEFF for a file meant for spreadsheets).
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
<DataTable
|
|
231
|
+
rows={rows}
|
|
232
|
+
columns={[
|
|
233
|
+
{ key: 'name', label: 'Name', render: (r) => r.name, sortValue: (r) => r.name },
|
|
234
|
+
{
|
|
235
|
+
key: 'cpu',
|
|
236
|
+
label: 'CPU %',
|
|
237
|
+
render: (r) => <Gauge value={r.cpu} />,
|
|
238
|
+
exportValue: (r) => r.cpu,
|
|
239
|
+
},
|
|
240
|
+
]}
|
|
241
|
+
rowKey={(r) => r.id}
|
|
242
|
+
exportable
|
|
243
|
+
exportFileName="nodes.csv"
|
|
244
|
+
/>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
On paper a `DataTable` drops its toolbar, bulk-action bar, filter tabs, pager
|
|
248
|
+
and the selection and action columns, prints every row of the page instead of
|
|
249
|
+
scrolling, repeats the header on each page and keeps a row on one page. There is
|
|
250
|
+
no Print button: use the browser's. The theme is not switched, so a dark theme
|
|
251
|
+
prints its own colours: pick a light one for print (or `@media print` in your CSS).
|
|
252
|
+
|
|
253
|
+
Saved views: `views` (or `defaultViews`) shows a Views button in the toolbar.
|
|
254
|
+
A view is plain JSON, `{ id, name, search, filters, sort, columnVisibility,
|
|
255
|
+
pageSize, density }` (`filters` maps a column key to an array of values, `sort`
|
|
256
|
+
is `{ key, direction }` or `null`, `pageSize` is a number or `'all'` for "Show
|
|
257
|
+
all"), so you can keep the list anywhere: the table never stores it. Pass
|
|
258
|
+
`views` and `onViewsChange(views)`, or `defaultViews` and listen to
|
|
259
|
+
`onViewsChange`; `activeView` / `defaultActiveView` / `onActiveViewChange(id)`
|
|
260
|
+
hold the `id` of the applied view, or `null`. The menu applies a view, saves the
|
|
261
|
+
current state as a new one ("Save as…"), renames the active one or deletes it
|
|
262
|
+
(after a confirmation); a name must be filled and not used by another view.
|
|
263
|
+
Applying a view sets each part through the table's own state, so a part you
|
|
264
|
+
control reports its own `on*Change`, and the table goes back to the first page.
|
|
265
|
+
The button shows the active view's name, with "(modified)" once the table no
|
|
266
|
+
longer matches it. The `DataTableView`, `DataTableSort` and `DataTableExportQuery`
|
|
267
|
+
types are exported.
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
const [views, setViews] = useState<DataTableView[]>(() => load());
|
|
271
|
+
<DataTable
|
|
272
|
+
rows={rows}
|
|
273
|
+
columns={columns}
|
|
274
|
+
rowKey={(r) => r.id}
|
|
275
|
+
views={views}
|
|
276
|
+
onViewsChange={(next) => {
|
|
277
|
+
setViews(next);
|
|
278
|
+
save(next);
|
|
279
|
+
}}
|
|
280
|
+
/>;
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Server mode: `manual` turns the in-memory search, filters, sort and pagination
|
|
284
|
+
off. `rows` is the page you fetched, drawn in its order, and the controls only
|
|
285
|
+
report: `onSearchChange`, `onFiltersChange`, `onSortChange`, `onPageChange` and
|
|
286
|
+
`onPageSizeChange` (a change of search, filters, sort or page size goes back to
|
|
287
|
+
page 1 first). `rowCount` is the total across every page, for the pager and its
|
|
288
|
+
"Showing X to Y of Z"; without it the pager counts `rows.length`. A filter
|
|
289
|
+
dropdown lists the values found in `rows`, which in `manual` are one page, so a
|
|
290
|
+
filterable column takes `filterOptions` (`['Paid', 'Pending']`, in the order to
|
|
291
|
+
show) instead; it works without `manual` too. "Show all" is not offered, and
|
|
292
|
+
the built-in CSV export only holds the loaded rows: pass `onExport` to export on
|
|
293
|
+
the server.
|
|
294
|
+
|
|
295
|
+
The sort and the page are controlled with or without `manual`: `sort`,
|
|
296
|
+
`defaultSort` and `onSortChange(sort)` hold a `DataTableSort` (`{ key,
|
|
297
|
+
direction }` or `null`), and `page`, `defaultPage` and `onPageChange(page)` are
|
|
298
|
+
1-indexed like `Pagination`. A controlled table shows a new value once you pass
|
|
299
|
+
it back, and a change that resets the page calls `onPageChange(1)` (not when it
|
|
300
|
+
is on page 1 already).
|
|
301
|
+
|
|
302
|
+
`loading` with rows on screen keeps them, dimmed and inert (nothing in them can
|
|
303
|
+
be clicked meanwhile), under a centred spinner, with the pager still there and
|
|
304
|
+
`aria-busy` on the table; with no rows yet it draws the `loadingRows`
|
|
305
|
+
placeholders as before. Use it for every request, the first or the next page.
|
|
306
|
+
|
|
307
|
+
```tsx
|
|
308
|
+
const [sort, setSort] = useState<DataTableSort>(null);
|
|
309
|
+
const [page, setPage] = useState(1);
|
|
310
|
+
const { data, isFetching } = useOrders({ sort, page });
|
|
311
|
+
<DataTable
|
|
312
|
+
manual
|
|
313
|
+
rows={data?.rows ?? []}
|
|
314
|
+
rowCount={data?.total}
|
|
315
|
+
loading={isFetching}
|
|
316
|
+
columns={columns}
|
|
317
|
+
rowKey={(o) => o.id}
|
|
318
|
+
sort={sort}
|
|
319
|
+
onSortChange={setSort}
|
|
320
|
+
page={page}
|
|
321
|
+
onPageChange={setPage}
|
|
322
|
+
/>;
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`virtualize` draws only the rows in view and a few around them, for thousands of
|
|
326
|
+
rows on one page (a large `pageSize`, or "Show all"). Two `aria-hidden` spacer
|
|
327
|
+
rows stand for the rest, so the scroll bar measures the whole list, and the
|
|
328
|
+
header stays sticky. The table says where the window is (`aria-rowcount`, and
|
|
329
|
+
`aria-rowindex` on each drawn row), and the row that holds the keyboard focus
|
|
330
|
+
stays drawn while it is scrolled out of view, so focus and selection survive
|
|
331
|
+
scrolling. Its limits:
|
|
332
|
+
|
|
333
|
+
- The table needs a height (`className="max-h-96"`): its own area is what
|
|
334
|
+
scrolls, and without a height every row is in view.
|
|
335
|
+
- Every row has one fixed height, from `density`: 49px, or 61px when
|
|
336
|
+
`comfortable`, at the default text size, and growing with the text size
|
|
337
|
+
setting. Content taller than that is clipped, so keep a cell to a line of text
|
|
338
|
+
or a control, as in a standard row.
|
|
339
|
+
- It applies to the table layout only: with `mobileLayout="cards"` below `sm`
|
|
340
|
+
the cards keep the pagination.
|
|
341
|
+
- It is not for expandable rows.
|
|
342
|
+
- On paper only the drawn rows print.
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
<DataTable
|
|
346
|
+
rows={tenThousandRows}
|
|
347
|
+
columns={columns}
|
|
348
|
+
rowKey={(r) => r.id}
|
|
349
|
+
pageSize={10_000}
|
|
350
|
+
virtualize
|
|
351
|
+
className="max-h-96"
|
|
352
|
+
/>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`Table` takes `density` (`default` or `comfortable`, as on `DataTable`),
|
|
356
|
+
`striped`, `stickyHeader` (give the wrapper a height with
|
|
357
|
+
`containerClassName`, e.g. `max-h-80`) and `stickyFirstColumn`. While data
|
|
358
|
+
loads, `TableSkeletonRows` fills the `TableBody` under the header row. Cells take `align` and `numeric`
|
|
359
|
+
(right-aligned, tabular figures). `TableHeaderCell` sets `scope` to `col` in the
|
|
360
|
+
head and `row` elsewhere. The wrapper scrolls horizontally, so a wide table
|
|
361
|
+
stays inside the page on small screens.
|
|
362
|
+
|
|
363
|
+
## Audit diff
|
|
364
|
+
|
|
365
|
+
`AuditDiff` shows what an audit log entry changed: `changes` is a list of
|
|
366
|
+
`{ field, label?, before?, after? }`. The default `table` variant is a real
|
|
367
|
+
table (Field, Before, After) that stacks each change as a block below `sm`; the
|
|
368
|
+
old value is struck through on a red tint and the new one sits on a green tint.
|
|
369
|
+
`variant="inline"` is one line per field (`Role: Viewer → Admin`), for an entry
|
|
370
|
+
of an `ActivityFeed` or a `Timeline`.
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
import { AuditDiff } from '@sckyzo/cosmos-react';
|
|
374
|
+
|
|
375
|
+
<AuditDiff
|
|
376
|
+
variant="inline"
|
|
377
|
+
changes={[
|
|
378
|
+
{ field: 'role', label: 'Role', before: 'Viewer', after: 'Admin' },
|
|
379
|
+
{ field: 'phone', label: 'Phone', after: '+33 1 23 45 67 89' },
|
|
380
|
+
]}
|
|
381
|
+
/>;
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Values are rendered as text (markup in one shows as written): strings as they
|
|
385
|
+
are, numbers and booleans with `String`, objects and arrays as compact JSON
|
|
386
|
+
(there is no nested diff). `null`, `undefined` and `''` mean the field was
|
|
387
|
+
empty on that side and show "—", which a screen reader reads as "empty".
|
|
388
|
+
`formatValue(value, change, side)` draws a value yourself. When both sides are
|
|
389
|
+
strings of a few words or more, the cells mark the words that moved with `<del>`
|
|
390
|
+
and `<ins>`, each with a visually hidden "removed:" / "added:"; past about 500
|
|
391
|
+
tokens on each side the whole values are shown instead. `labels` rewords every
|
|
392
|
+
text the component writes (`field`, `before`, `after`, `empty`, `removed`,
|
|
393
|
+
`added`), for another language.
|
|
394
|
+
|
|
395
|
+
## Progress
|
|
396
|
+
|
|
397
|
+
`ProgressBar` shows one value (`value`, `max`, `intent`) or a list of parts
|
|
398
|
+
(`segments`), never both. Each segment has a `value`, a `label` and an `intent`
|
|
399
|
+
you pick yourself; the parts sit 2px apart, and their sum is held to `max`, so
|
|
400
|
+
show the overflow yourself ("112%"). `legend` adds a row of swatch, label and value
|
|
401
|
+
under the bar, with what is left on the track. `aria-valuetext` lists the parts
|
|
402
|
+
("Documents 40, Images 25, Free 35"); `valueLabel` puts a unit on a part.
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
<ProgressBar
|
|
406
|
+
aria-label="Storage"
|
|
407
|
+
legend
|
|
408
|
+
segments={[
|
|
409
|
+
{ label: 'Documents', value: 40, valueLabel: '40 GB' },
|
|
410
|
+
{ label: 'Images', value: 25, intent: 'success', valueLabel: '25 GB' },
|
|
411
|
+
]}
|
|
412
|
+
free={{ valueLabel: '35 GB' }}
|
|
413
|
+
/>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`ProgressRing` is the same meter as an SVG ring (no dependency): `value`, `max`,
|
|
417
|
+
`intent`, `size` (`sm` 32px, `md` 56px, `lg` 96px, or a number of pixels), `thickness`,
|
|
418
|
+
and `children` for the centre (a value, an icon). It is a `progressbar` too, so name it
|
|
419
|
+
with `aria-label` or `aria-labelledby`; the centre is not read a second time. `indeterminate`
|
|
420
|
+
spins a quarter arc, except under reduced motion.
|
|
421
|
+
|
|
422
|
+
```tsx
|
|
423
|
+
<ProgressRing aria-label="Course progress" value={68} intent="success">
|
|
424
|
+
68%
|
|
425
|
+
</ProgressRing>
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## Kanban board
|
|
429
|
+
|
|
430
|
+
`KanbanBoard` shows columns of cards that move between columns: a sprint board, a pipeline, tasks by
|
|
431
|
+
status. Cards are plain data (`id`, `title`, an optional `description`, and `badges` and `footer`
|
|
432
|
+
slots for `Tag`s and an `AvatarGroup`), columns are `{ id, title, cards }`. It follows the usual
|
|
433
|
+
controlled/uncontrolled pair (`value`, `defaultValue`, `onChange(columns)`), and owns no data: the
|
|
434
|
+
host saves the move. `onCardMove` carries just the move, after `onChange`, which is what a server
|
|
435
|
+
needs.
|
|
436
|
+
|
|
437
|
+
```tsx
|
|
438
|
+
<KanbanBoard
|
|
439
|
+
ariaLabel="Sprint board"
|
|
440
|
+
defaultValue={columns}
|
|
441
|
+
onChange={setColumns}
|
|
442
|
+
onCardMove={({ cardId, fromColumnId, toColumnId, index }) => save(cardId, toColumnId, index)}
|
|
443
|
+
onCardOpen={(card) => setOpen(card)}
|
|
444
|
+
onAddCard={(columnId) => openForm(columnId)}
|
|
445
|
+
/>
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
A card is dragged by its grip, with a pointer or a finger. From the keyboard, focus the grip, press
|
|
449
|
+
Space or Enter to pick the card up, the arrow keys to move it between positions and columns, Space
|
|
450
|
+
or Enter to drop it, Escape to put it back; a live region announces each step. Every card also has
|
|
451
|
+
an actions menu ("Move to" the other columns, "Move up", "Move down"), so the board works without
|
|
452
|
+
dragging. `onCardOpen` makes the whole card a button, `onAddCard` adds an "Add card" button to each
|
|
453
|
+
column, `renderCard` replaces what is inside a card (the grip and the actions menu stay), and
|
|
454
|
+
`loading` shows skeleton columns. A column takes an `intent` for the dot before its title and an
|
|
455
|
+
`empty` node for when it has no cards. Below `sm` the columns scroll sideways, one column wide.
|
|
456
|
+
|
|
457
|
+
Drag and drop is built on [`@dnd-kit/react`](https://dndkit.com), pinned to an exact version since it
|
|
458
|
+
is pre-1.0. It is a regular dependency of the package, not a peer.
|
|
459
|
+
|
|
460
|
+
## Dates, clocks and countdowns
|
|
461
|
+
|
|
462
|
+
`DateTime`, `RelativeTime`, `Clock` and `Countdown` write a moment the same way everywhere, with
|
|
463
|
+
`Intl` and in the user's language. They render in the browser only, since the server knows neither
|
|
464
|
+
the locale nor the zone, and keep the room of their text until then.
|
|
465
|
+
|
|
466
|
+
```tsx
|
|
467
|
+
<DateTime value={order.createdAt} format="datetime" timeZone="Europe/Paris" />
|
|
468
|
+
<RelativeTime value={event.at} tooltip />
|
|
469
|
+
<Clock variant="card" timeZone="Asia/Tokyo" label="Tokyo" showDate />
|
|
470
|
+
<Countdown target="2026-03-01T09:00:00Z" onComplete={() => reload()} />
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
- `DateTime` takes a `format` (`date`, `time`, `datetime`, `long`, `full`, or `Intl.DateTimeFormat`
|
|
474
|
+
options), a `locale` and a `timeZone`, and draws a `<time dateTime>`. `tooltip` adds the full date
|
|
475
|
+
and zone, and makes the `<time>` a tab stop so a keyboard reaches it.
|
|
476
|
+
- `RelativeTime` reads "5 minutes ago", "yesterday", "in 2 days". Every instance shares one timer and
|
|
477
|
+
wakes only when its text changes: each second in the first minute, each minute in the first hour,
|
|
478
|
+
and rarely after, so a long list costs nothing.
|
|
479
|
+
- `Clock` has the variants `inline`, `card` and `display`, and takes `hour12`, `showSeconds`,
|
|
480
|
+
`showDate` and a `label` for the zone. It ticks on the second boundary. A world clock is a grid of
|
|
481
|
+
`Clock`s.
|
|
482
|
+
- `Countdown` has the variants `tiles`, `inline` and `compact`. `onComplete` is called once, at zero,
|
|
483
|
+
and the countdown stops there. `labels`, `shortLabels`, `label` and `completedLabel` translate its
|
|
484
|
+
text.
|
|
485
|
+
|
|
486
|
+
`Clock` and `Countdown` are `role="timer"`, which a screen reader does not announce as it changes.
|
|
487
|
+
Every one of them takes `now`, which freezes it at that moment and sets no timer: use it for
|
|
488
|
+
stories, screenshots and tests.
|
|
489
|
+
|
|
490
|
+
## Auth layouts
|
|
491
|
+
|
|
492
|
+
`AuthLayout` wraps the sign-in, sign-up, reset and two-step forms. `variant` picks the page:
|
|
493
|
+
`card` (default) is a centred card; `split` puts the form beside a brand panel; `cover`
|
|
494
|
+
centres the card over a full-bleed image.
|
|
495
|
+
|
|
496
|
+
```tsx
|
|
497
|
+
<AuthLayout variant="split" logo={Activity} title="Cosmos Cloud" subtitle="Monitor every rack.">
|
|
498
|
+
<SignInForm />
|
|
499
|
+
</AuthLayout>
|
|
500
|
+
<AuthLayout variant="cover" cover={{ src: '/login.jpg' }} logo={Activity} title="Cosmos Cloud">
|
|
501
|
+
<SignInForm />
|
|
502
|
+
</AuthLayout>
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
In `split`, `aside` replaces the default panel (an accent surface with the `logo`, `title` and
|
|
506
|
+
`subtitle`; `null` for none), `asidePosition` (`start` | `end`, default `end`) picks its side. The
|
|
507
|
+
panel shows from `lg` up; below, the brand block sits above the form. In `cover`, `cover.src` is
|
|
508
|
+
drawn under a dark scrim (`cover.alt` is empty unless set), and without it the card sits on a
|
|
509
|
+
gradient of the accent. In every variant the built-in title is a paragraph: the forms carry the
|
|
510
|
+
page's `h1`.
|
|
511
|
+
|
|
512
|
+
## Record pager
|
|
513
|
+
|
|
514
|
+
`RecordPager` moves through the records of a list from a detail page: "12 of 340", Previous, Next
|
|
515
|
+
and an optional "Back to list". `Pagination` stays for pages of a list; the order and filters of the
|
|
516
|
+
list are the host's to keep (URL or state).
|
|
517
|
+
|
|
518
|
+
```tsx
|
|
519
|
+
<RecordPager
|
|
520
|
+
position={12}
|
|
521
|
+
total={340}
|
|
522
|
+
noun="order"
|
|
523
|
+
previousHref="/orders/20430"
|
|
524
|
+
nextHref="/orders/20432"
|
|
525
|
+
backHref="/orders"
|
|
526
|
+
hotkeys={{ previous: '[', next: ']' }}
|
|
527
|
+
/>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- Links when `previousHref` / `nextHref` are given (through `LinkProvider`), buttons with
|
|
531
|
+
`onPrevious` / `onNext` otherwise. A side with nowhere to go, or at the first or last record, is a
|
|
532
|
+
disabled button, never a link.
|
|
533
|
+
- `noun` builds the labels ("Previous order"); `labels` (`previous`, `next`, `back`, `count`)
|
|
534
|
+
replaces any of them, for another language.
|
|
535
|
+
- `variant="compact"` draws icon buttons (still named) and the count, for a `PageHeader`'s `actions`.
|
|
536
|
+
- Below `sm`, the default variant shows the icons only too, so a long label never wraps; the label
|
|
537
|
+
stays the button's name.
|
|
538
|
+
- Shortcuts are off unless `hotkeys` is given. They listen on the document, do nothing while a
|
|
539
|
+
field is being typed in, and click the control, so a link goes through your router. List them
|
|
540
|
+
yourself in a `HelpOverlay`.
|
|
541
|
+
|
|
542
|
+
## Files and uploads
|
|
543
|
+
|
|
544
|
+
`FileTile` is one file or folder as a `<li>`: `layout="tile"` (the default) is the card of a grid, with a
|
|
545
|
+
128px preview, `layout="row"` the line of a list. `kind` (`folder`, `image`, `video`, `audio`, `pdf`,
|
|
546
|
+
`document`, `spreadsheet`, `archive`, `code`, `other`) picks the icon and its tint; a `thumbnail` takes
|
|
547
|
+
the icon's place and falls back to it if it fails to load. The name is the only link (`href`) or button
|
|
548
|
+
(`onOpen`) of the tile; the star (`starred` / `defaultStarred` / `onStarredChange`, named "Star {name}")
|
|
549
|
+
and `actions`, typically a `Menu`, are siblings of it. `shared` adds a "Shared" badge and `selected`
|
|
550
|
+
draws the tile as picked.
|
|
551
|
+
|
|
552
|
+
```tsx
|
|
553
|
+
<GridList cols={4}>
|
|
554
|
+
<FileTile
|
|
555
|
+
name="Q3 report.pdf"
|
|
556
|
+
kind="pdf"
|
|
557
|
+
meta="2.4 MB · Edited yesterday"
|
|
558
|
+
defaultStarred
|
|
559
|
+
onOpen={() => open(file)}
|
|
560
|
+
actions={<FileMenu file={file} />}
|
|
561
|
+
/>
|
|
562
|
+
</GridList>
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
`UploadQueue` lists uploads in progress, failed and done in one card. It does no network I/O: the app
|
|
566
|
+
owns the transfer, keeps `items` (`id`, `name`, `size`, `status`, `progress`, `error`) up to date and
|
|
567
|
+
answers `onRetry(id)` and `onRemove(id)`. A row says its status in text first, and a polite live region
|
|
568
|
+
announces the rows that finish or fail. Pair it with a `Dropzone` set to `hideFileList`:
|
|
569
|
+
`onFilesAdded(files)` gets only the files just accepted, so the app can start uploading them, and
|
|
570
|
+
`onReject(rejections)` keeps the valid files of a drop and reports the others one by one with their
|
|
571
|
+
reason, where without it one invalid file rejects the whole drop.
|
|
572
|
+
|
|
573
|
+
```tsx
|
|
574
|
+
<Dropzone multiple hideFileList accept=".pdf,.png" maxSize={25 * 1024 * 1024}
|
|
575
|
+
onFilesAdded={(files) => files.forEach(upload)}
|
|
576
|
+
onReject={(rejections) => report(rejections)} />
|
|
577
|
+
<UploadQueue items={uploads} onRetry={retry} onRemove={cancelOrDismiss} />
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## Chat
|
|
581
|
+
|
|
582
|
+
Building blocks for a conversation view: `ChatThread` (a `role="log"` live
|
|
583
|
+
region that stays scrolled to the newest message), `ChatMessage`,
|
|
584
|
+
`ChatDaySeparator`, `ChatTypingIndicator`, `ChatComposer` (Enter sends,
|
|
585
|
+
Shift+Enter adds a line) and `ChatConversationList` for a two-pane layout.
|
|
586
|
+
Message text is always rendered as text, never HTML; http(s) URLs become links.
|
|
587
|
+
|
|
588
|
+
`Chatbot` is a floating widget built on them: a launcher at the bottom right
|
|
589
|
+
opens a panel (full-screen under 480px). It has no network code, the app owns
|
|
590
|
+
the messages and answers `onSend`:
|
|
591
|
+
|
|
592
|
+
```tsx
|
|
593
|
+
<Chatbot
|
|
594
|
+
messages={messages}
|
|
595
|
+
onSend={(text) => ask(text)}
|
|
596
|
+
typing={waiting}
|
|
597
|
+
suggestions={['Show my alerts']}
|
|
598
|
+
error={failed ? 'The assistant could not be reached.' : undefined}
|
|
599
|
+
onRetry={retry}
|
|
600
|
+
/>
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
A bot message with `streaming: true` shows a caret while its text grows.
|
|
604
|
+
|
|
605
|
+
### Rich replies (AI assistant, generators)
|
|
606
|
+
|
|
607
|
+
The same blocks build an assistant page; the app owns the conversation and renders any
|
|
608
|
+
Markdown itself.
|
|
609
|
+
|
|
610
|
+
- `ChatMessage` takes `children` in place of `text` (rendered nodes: a `CodeBlock`, a list, an
|
|
611
|
+
`ImageGrid`). `bubble={false}` drops the bubble of an incoming reply so it takes the whole
|
|
612
|
+
column, with the avatar and name on top. While `streaming` the message is `aria-busy`.
|
|
613
|
+
- `actions={<ChatMessageActions text={reply} onRegenerate={…} feedback={…} onFeedbackChange={…} />}`
|
|
614
|
+
adds Copy reply, Regenerate and Good/Bad response under the reply (`aria-pressed`, also
|
|
615
|
+
`defaultFeedback`). With a pointer they show on hover and focus; on touch they always show.
|
|
616
|
+
- `ChatSuggestions` is the row of chips `Chatbot` uses (`suggestions`, `onSelect`).
|
|
617
|
+
- `ChatComposer` takes `footer` (a line under the field: attach, a model `Select`, a hint) and
|
|
618
|
+
`generating` + `onStop`: the send button becomes Stop (`stopLabel`), and Enter does not send.
|
|
619
|
+
- `ChatToolCall` (`name`, `status` of `running | done | failed`, `duration`, `input`, `output`)
|
|
620
|
+
is a disclosure row for what the assistant did; `ChatSources` (`sources`, `label`) lists where
|
|
621
|
+
an answer comes from. Only http(s) sources become links.
|
|
622
|
+
- `Clipboard plain` is the borderless copy button of that row.
|
|
623
|
+
|
|
624
|
+
## Comments
|
|
625
|
+
|
|
626
|
+
`CommentThread`, `Comment` and `CommentComposer` build the discussion under a record (a ticket, a
|
|
627
|
+
task, an order). The host owns the data: the components take callbacks and keep only which
|
|
628
|
+
replies are unfolded and which composer is open. Body text is rendered as text, never HTML, and
|
|
629
|
+
only http(s) URLs become links.
|
|
630
|
+
|
|
631
|
+
```tsx
|
|
632
|
+
<CommentThread composer={<CommentComposer onSubmit={(text) => add(text)} />}>
|
|
633
|
+
{comments.map((c) => (
|
|
634
|
+
<Comment
|
|
635
|
+
key={c.id}
|
|
636
|
+
author={c.author}
|
|
637
|
+
role={c.role}
|
|
638
|
+
time={<time dateTime={c.createdAt}>{format(c.createdAt)}</time>}
|
|
639
|
+
edited={c.edited}
|
|
640
|
+
variant={c.internal ? 'internal' : 'default'}
|
|
641
|
+
text={c.text}
|
|
642
|
+
onReply={(text) => reply(c.id, text)}
|
|
643
|
+
onEdit={c.mine ? (text) => edit(c.id, text) : undefined}
|
|
644
|
+
onDelete={c.mine ? () => remove(c.id) : undefined}
|
|
645
|
+
replies={c.replies.map((r) => (
|
|
646
|
+
<Comment key={r.id} author={r.author} text={r.text} />
|
|
647
|
+
))}
|
|
648
|
+
/>
|
|
649
|
+
))}
|
|
650
|
+
</CommentThread>
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
- An action shows only when its callback is given, so a host lets people edit only their own
|
|
654
|
+
comments. Reply and Edit open a `CommentComposer` in place (the edit one starts with the
|
|
655
|
+
comment's text; `editText` when the body is `children`) and call back with the trimmed text;
|
|
656
|
+
Cancel and Escape return the focus to the button that opened it. Delete calls back at once:
|
|
657
|
+
confirm in the host if needed.
|
|
658
|
+
- Replies are one level, `Comment`s passed as `replies`, folded beyond `maxReplies` (default
|
|
659
|
+
`2`) behind a "Show 3 more replies" button. A reply has no Reply of its own.
|
|
660
|
+
- `variant="internal"` tints a note and labels it "Internal note" with a lock, so it reads as one
|
|
661
|
+
without the color.
|
|
662
|
+
- `CommentComposer` is a `Textarea` with `autoResize`: Enter adds a line, Ctrl or Cmd+Enter and the
|
|
663
|
+
submit button send, Escape cancels when `onCancel` is given. Submit is disabled while the text
|
|
664
|
+
is empty or `submitting`. `value` / `defaultValue` / `onChange` work as on other fields.
|
|
665
|
+
- `time` is a node, as in `Timeline`: a `RelativeTime`, a `<time>` or your own text.
|
|
666
|
+
- Every text can be translated: `labels` on `CommentThread` reaches all its comments (a
|
|
667
|
+
comment's own `labels` win), `emptyTitle` and `label` on the thread, and the composer's own
|
|
668
|
+
`label`, `placeholder`, `submitLabel` and `cancelLabel`.
|
|
669
|
+
|
|
670
|
+
## Slider
|
|
671
|
+
|
|
672
|
+
`Slider` is a position on a scale, or a range of it. One `value` (with `defaultValue` and
|
|
673
|
+
`onChange(value)`): a number draws one handle, a pair `[minimum, maximum]` draws two, which can meet
|
|
674
|
+
but never cross. `min`, `max` and `step` default to 0, 100 and 1. `label` sits on the left of the
|
|
675
|
+
header, the current value on the right, and `min` and `max` under the track; `formatValue(n)` writes
|
|
676
|
+
all of them and the value a screen reader announces. `label` also names the handles ("Price minimum"
|
|
677
|
+
and "Price maximum" for a range): without a visible label, give `aria-label`. `marks` draws a tick at
|
|
678
|
+
every `step`, `intent` colors the fill (`brand`, `success`, `warning` or `danger`).
|
|
679
|
+
|
|
680
|
+
```tsx
|
|
681
|
+
<Slider
|
|
682
|
+
label="Price"
|
|
683
|
+
defaultValue={[200, 800]}
|
|
684
|
+
max={1000}
|
|
685
|
+
step={10}
|
|
686
|
+
formatValue={(n) => `$${n}`}
|
|
687
|
+
onChange={(range) => setPrice(range)}
|
|
688
|
+
/>
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
## Colour and phone fields
|
|
692
|
+
|
|
693
|
+
`ColorInput` picks a colour from named swatches (a radio group, arrows move between them) or a hex
|
|
694
|
+
field with a preview square. The value is the lowercase `#rrggbb`: the field accepts `#abc`, `abc`
|
|
695
|
+
and `#aabbcc`, and only a valid hex reaches `onChange(hex)`. Leaving the field with an invalid draft
|
|
696
|
+
shows an error and keeps the value. `presets` replaces the eight default swatches (`[]` for the hex
|
|
697
|
+
field alone); each is a `{ value, label }`, and the label is what a screen reader announces.
|
|
698
|
+
|
|
699
|
+
`PhoneInput` joins a country list and the number field in one border. The value is the E.164 number
|
|
700
|
+
(`+33612345678`) or `''`: `onChange(value)` gets `''` until the number is valid, which the light
|
|
701
|
+
check decides (digits only, 4 or more, 15 at most with the country code). The list searches by
|
|
702
|
+
name, ISO code or calling code; names come from `Intl.DisplayNames` in the page language.
|
|
703
|
+
`defaultCountry` (an ISO 3166-1 code, `US` by default) sets the country until a number says
|
|
704
|
+
otherwise, and `countries` limits the list. Typing `+` and a known calling code switches the
|
|
705
|
+
country, picking a country keeps the digits. A calling code shared by several countries keeps the
|
|
706
|
+
current one if it has it, else takes the code's main country. The package carries no phone metadata:
|
|
707
|
+
`format(country, national)` shapes the digits as they are typed and `validate(value)` returns the
|
|
708
|
+
message for a number it rejects, so a host can plug `libphonenumber-js` in.
|
|
709
|
+
|
|
710
|
+
```tsx
|
|
711
|
+
<ColorInput label="Event colour" value={colour} onChange={setColour} />
|
|
712
|
+
|
|
713
|
+
<PhoneInput
|
|
714
|
+
label="Phone number"
|
|
715
|
+
defaultCountry="FR"
|
|
716
|
+
value={phone}
|
|
717
|
+
onChange={setPhone}
|
|
718
|
+
validate={(v) => (isValidPhoneNumber(v) ? undefined : 'Enter a valid phone number.')}
|
|
719
|
+
/>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
## Application shell
|
|
723
|
+
|
|
724
|
+
`Shell` lays out a `Topbar`, a `Sidebar` and the page, and owns the sidebar's
|
|
725
|
+
collapsed state: `defaultSidebarCollapsed`, or `sidebarCollapsed` and
|
|
726
|
+
`onCollapsedChange` to control it. The Topbar shows the toggle, or the bottom of
|
|
727
|
+
the Sidebar does with `collapseControl="sidebar"`.
|
|
728
|
+
|
|
729
|
+
Below `lg` the sidebar leaves the layout and the Topbar's hamburger opens it as a 288px
|
|
730
|
+
drawer over a scrim (`defaultSidebarOpen`, or `sidebarOpen` and `onSidebarOpenChange`
|
|
731
|
+
to control it). It is always expanded there, with a close button in `Sidebar.Brand`, and it
|
|
732
|
+
closes on the scrim, Escape, a swipe to the left, a click on a `Sidebar.Item` and when the
|
|
733
|
+
viewport grows past `lg`. Pass the product logo as the Topbar's `brand` to keep it in
|
|
734
|
+
view while the sidebar is off screen. Any `Drawer` can close on a swipe too, with
|
|
735
|
+
`swipeToClose`: toward its edge on `left` and `right`, downward on `side="bottom"`, the
|
|
736
|
+
bottom sheet (full width, as tall as its content up to 85% of the viewport, a grab `handle`
|
|
737
|
+
you can turn off with `handle={false}`, and a `Drawer.Body` that scrolls past that height).
|
|
738
|
+
|
|
739
|
+
The expanded `Sidebar` follows its content: from `width` (264px, its minimum) it grows to
|
|
740
|
+
fit its widest visible row, the deep levels of a tree included, up to `maxWidth` (320px). A
|
|
741
|
+
label that needs more ends in an ellipsis and its row shows the full label in a tooltip. Set
|
|
742
|
+
`maxWidth` to the same value as `width` for a fixed width. The collapsed rail (`collapsedWidth`)
|
|
743
|
+
and the drawer keep their own widths.
|
|
744
|
+
|
|
745
|
+
A collapsed `Sidebar` can open while the pointer rests on it, with `expandOnHover`:
|
|
746
|
+
`"overlay"` opens it above the page after 150 ms, with a shadow, and the page does not move;
|
|
747
|
+
`"push"` widens it and the page makes room. Keyboard focus entering the sidebar does the same,
|
|
748
|
+
and Escape closes it. It only applies from `lg` up, on a pointer that can hover, never in the
|
|
749
|
+
drawer, and it does not touch the collapsed state: `onCollapsedChange` is not called and the
|
|
750
|
+
toggles keep their state.
|
|
751
|
+
|
|
752
|
+
`SidebarToggle` is that toggle, to place anywhere else in the Shell (a header of
|
|
753
|
+
your own, the Topbar's `leftActions`, the Sidebar's `footer`) with no props: it
|
|
754
|
+
reads the Shell's state, and every toggle stays in sync. Outside a Shell, pass it
|
|
755
|
+
`collapsed` and `onCollapsedChange`.
|
|
756
|
+
|
|
757
|
+
```tsx
|
|
758
|
+
<Shell
|
|
759
|
+
topbar={<Topbar pageTitle="Servers" />}
|
|
760
|
+
sidebar={<Sidebar footer={<SidebarToggle variant="panel" />}>{/* items */}</Sidebar>}
|
|
761
|
+
>
|
|
762
|
+
{/* page */}
|
|
763
|
+
</Shell>
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
## Split panes
|
|
767
|
+
|
|
768
|
+
`SplitView` puts two or three `SplitView.Pane` side by side in one bordered frame (mail,
|
|
769
|
+
chat, a file manager). It fills the height of its parent and each pane scrolls on its own;
|
|
770
|
+
`width` is a Tailwind class (`w-56`), and a pane without one takes the remaining space. Below
|
|
771
|
+
`collapseBelow` (`md`, `lg` by default, `xl`) only the active pane shows. Control it with
|
|
772
|
+
`activePane` and `onActivePaneChange`, or start it with `defaultActivePane` and switch from
|
|
773
|
+
inside with `useSplitView()`; the pane that appears takes focus. A Back button is yours to
|
|
774
|
+
place.
|
|
775
|
+
|
|
776
|
+
```tsx
|
|
777
|
+
<SplitView activePane={pane} onActivePaneChange={setPane}>
|
|
778
|
+
<SplitView.Pane id="list" label="Messages" width="w-80">
|
|
779
|
+
{/* list */}
|
|
780
|
+
</SplitView.Pane>
|
|
781
|
+
<SplitView.Pane id="detail" label="Message">
|
|
782
|
+
{/* detail */}
|
|
783
|
+
</SplitView.Pane>
|
|
784
|
+
</SplitView>
|
|
785
|
+
```
|
|
786
|
+
|
|
67
787
|
## Themes and accents
|
|
68
788
|
|
|
69
789
|
The theme is a class on `<html>`; the accent, the font and the text size are
|
|
@@ -101,12 +821,31 @@ The font and text size groups show only when their handlers are set. Pass
|
|
|
101
821
|
(`@sckyzo/cosmos-theme/fonts/<name>`).
|
|
102
822
|
|
|
103
823
|
For a top bar, `AppearanceMenu` takes the same props and puts the pickers in a
|
|
104
|
-
panel behind an icon button, for example in `Topbar`'s `rightActions`.
|
|
824
|
+
panel behind an icon button, for example in `Topbar`'s `rightActions`. Set `onReset`
|
|
825
|
+
to add a Reset button under the groups.
|
|
826
|
+
|
|
827
|
+
To offer a **System** choice that follows the device's `prefers-color-scheme`,
|
|
828
|
+
add `COSMOS_SYSTEM_THEME` to the themes and apply again when the device switches
|
|
829
|
+
(it resolves to `light` or `dark`; set `systemThemes` for another pair). Nothing
|
|
830
|
+
changes for an app that doesn't opt in (`ThemeToggleTri` takes a `system` prop for the same
|
|
831
|
+
choice at the end of its cycle):
|
|
832
|
+
|
|
833
|
+
```tsx
|
|
834
|
+
const [appearance, setAppearance] = useState<CosmosAppearance>({ theme: 'system', accent: 'blue' });
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
applyAppearance(appearance);
|
|
837
|
+
return appearance.theme === 'system'
|
|
838
|
+
? watchSystemTheme(() => applyAppearance(appearance))
|
|
839
|
+
: undefined;
|
|
840
|
+
}, [appearance]);
|
|
841
|
+
// …
|
|
842
|
+
<AppearanceSettings {...appearance} themes={[COSMOS_SYSTEM_THEME, ...COSMOS_THEMES]} … />
|
|
843
|
+
```
|
|
105
844
|
|
|
106
845
|
Saving the choice (local storage, user profile) is left to the app.
|
|
107
846
|
|
|
108
847
|
Charts live in a separate package, [`@sckyzo/cosmos-charts`](../charts), so apps
|
|
109
|
-
that don't render charts don't ship
|
|
848
|
+
that don't render charts don't ship a chart library.
|
|
110
849
|
|
|
111
850
|
## License
|
|
112
851
|
|