@close-by/clay 0.2.16 → 0.2.18
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/docs/README.md +1 -0
- package/docs/api.md +41 -1
- package/docs/elements.md +3 -1
- package/docs/examples.md +7 -1
- package/docs/ops-patterns.md +31 -3
- package/docs/production.md +29 -4
- package/docs/protocol.md +13 -1
- package/docs/updating-in-place.md +124 -0
- package/package.json +5 -5
- package/src/index.ts +26 -8
package/docs/README.md
CHANGED
|
@@ -15,6 +15,7 @@ For coding agents: the repo-root [llms.txt](../llms.txt) is a short map of these
|
|
|
15
15
|
| [Tailwind](./tailwind.md) | Zero-config utilities from page modules; optional tokens |
|
|
16
16
|
| [Sample app](./tutorial.md) | Step-by-step tasks list, then two pages + shell |
|
|
17
17
|
| [Concepts](./concepts.md) | Mental model: sessions, elements, patches; **canonical `state`/`auto`/`timer` recipe**; reactive `let` alternative |
|
|
18
|
+
| [Updating in place](./updating-in-place.md) | Patch instead of rebuild: `setText`, keyed `setRows`, keyed `refreshable` / `auto` lists (no flicker) |
|
|
18
19
|
| [Browser APIs](./browser-apis.md) | Prefer `ui.clipboard` / hash / search / `openExternal` — never `window` in page code |
|
|
19
20
|
| [URL search](./url-search.md) | `ui.urlState` + query helpers for shareable filters |
|
|
20
21
|
| [Production & security](./production.md) | Auth, server exposure, rate limits, boot parity |
|
package/docs/api.md
CHANGED
|
@@ -1801,6 +1801,9 @@ onClick: async () => {
|
|
|
1801
1801
|
| `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
|
|
1802
1802
|
| `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
|
|
1803
1803
|
| `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
|
|
1804
|
+
| `ui.theme.palette(name)` | `void` | Switch colour palette for this client (`kiln`, `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, `sky`, `navy`, `indigo`, `denim`, `midnight`, or a custom one); remembered across reloads |
|
|
1805
|
+
| `ui.theme.getPalette()` | `ThemePalette \| null` | Last palette set, else the one the client reported on connect |
|
|
1806
|
+
| `ui.theme.palettes` | `readonly string[]` | Built-in palette names |
|
|
1804
1807
|
| `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
|
|
1805
1808
|
| `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
|
|
1806
1809
|
| `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
|
|
@@ -1911,6 +1914,7 @@ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
|
|
|
1911
1914
|
| `ui.row` | Horizontal flex |
|
|
1912
1915
|
| `ui.column` | Vertical flex |
|
|
1913
1916
|
| `ui.container` | Width-constrained wrapper |
|
|
1917
|
+
| `ui.grid` | Responsive grid that fills the width (auto-fit columns, or fixed `cols`) |
|
|
1914
1918
|
| `ui.hero` | Centered hero region |
|
|
1915
1919
|
| `ui.card` | Card; callback receives the card element |
|
|
1916
1920
|
| `ui.app` | SPA shell: sidebar nav + centered main content |
|
|
@@ -1965,8 +1969,44 @@ Shared layout props:
|
|
|
1965
1969
|
| `className` | `string` | | Extra classes |
|
|
1966
1970
|
| `centered` | `boolean` | | `mx-auto` (container) |
|
|
1967
1971
|
| `width` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| '4xl' \| '5xl' \| '6xl' \| '7xl' \| 'full'` | `'lg'` for container | Max width |
|
|
1972
|
+
| `align` | `'start' \| 'center' \| 'end' \| 'baseline' \| 'stretch'` | row: `center` (`stretch` when the row grows) | Cross-axis alignment |
|
|
1973
|
+
| `justify` | `'start' \| 'center' \| 'end' \| 'between' \| 'around'` | | Main-axis distribution |
|
|
1974
|
+
| `wrap` | `boolean` | row: `true` | Flex wrap |
|
|
1975
|
+
| `grow` | `boolean \| number` | | Take the remaining space in the parent row/column. A number sets the share (`grow: 2` gets twice `grow: 1`) |
|
|
1976
|
+
| `scroll` | `boolean \| 'x' \| 'y'` | | Scroll inside this element instead of growing the page. On a card, the body scrolls and the header stays |
|
|
1977
|
+
| `fillViewport` | `boolean` | | Height = visible page area (viewport minus the app shell header/padding). Use on the outermost page column |
|
|
1968
1978
|
|
|
1969
|
-
Card also accepts `title?: string
|
|
1979
|
+
Card also accepts `title?: string` and `description?: string`; `grow` / `scroll` / `fillViewport` work on cards too.
|
|
1980
|
+
|
|
1981
|
+
#### `ui.grid(props, fn)`
|
|
1982
|
+
|
|
1983
|
+
| Prop | Type | Default | Description |
|
|
1984
|
+
|------|------|---------|-------------|
|
|
1985
|
+
| `minColWidth` | `string \| number` | `'16rem'` | Auto-fit as many columns as fit, each at least this wide (numbers are px). Wraps to one column on phones |
|
|
1986
|
+
| `cols` | `number` | | Fixed column count instead of auto-fit (one column below `sm`) |
|
|
1987
|
+
| `gap` | `number` | `4` | Gap scale |
|
|
1988
|
+
|
|
1989
|
+
```typescript
|
|
1990
|
+
ui.grid({ minColWidth: '14rem' }, () => {
|
|
1991
|
+
for (const kpi of kpis) ui.card({ title: kpi.label }, () => ui.label(kpi.value));
|
|
1992
|
+
});
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
#### Filling the page
|
|
1996
|
+
|
|
1997
|
+
Make a page fill the window and let panels scroll instead of the page:
|
|
1998
|
+
|
|
1999
|
+
```typescript
|
|
2000
|
+
ui.column({ fillViewport: true, gap: 4 }, () => {
|
|
2001
|
+
ui.grid({ minColWidth: '12rem' }, () => { /* KPI cards — natural height */ });
|
|
2002
|
+
ui.row({ grow: true, wrap: false, gap: 4 }, () => { // takes the remaining height
|
|
2003
|
+
ui.card({ title: 'Inbox', scroll: true, className: 'w-80 shrink-0' }, () => { /* list */ });
|
|
2004
|
+
ui.card({ title: 'Detail', grow: true, scroll: true }, () => { /* body */ });
|
|
2005
|
+
});
|
|
2006
|
+
});
|
|
2007
|
+
```
|
|
2008
|
+
|
|
2009
|
+
Without `fillViewport`, `grow` still stretches to fill free space (the app shell content area is a flex column), but the page scrolls when content is taller than the window.
|
|
1970
2010
|
|
|
1971
2011
|
```typescript
|
|
1972
2012
|
ui.container(() => {
|
package/docs/elements.md
CHANGED
|
@@ -211,4 +211,6 @@ ui.label('Hello').classes('text-3xl font-bold text-muted-foreground');
|
|
|
211
211
|
|
|
212
212
|
Layout `gap` uses a small numeric scale mapped to `gap-0` … `gap-8` in the client.
|
|
213
213
|
|
|
214
|
-
The client ships a light ShadCN-style
|
|
214
|
+
The client ships the "kiln" theme — warm neutrals, a fired-clay primary, vivid chart colours, light + dark (ShadCN-style CSS variables in `packages/client/src/index.css`, Plus Jakarta Sans bundled). Override tokens at runtime with `ui.run({ css: './globals.css' })`; Clay extras include `--clay-brand-from` / `--clay-brand-to` and the `clay-brand-gradient`, `clay-brand-text`, `clay-ambient` classes.
|
|
215
|
+
|
|
216
|
+
Twelve more palettes ship alongside kiln — `cobalt`, `ocean`, `forest`, `orchid`, `citrus`, `graphite`, `neon`, plus the blues `sky`, `navy`, `indigo`, `denim`, `midnight` (each light + dark, in `packages/client/src/palettes.css`). Pick the app default with `ui.run({ palette: 'ocean' })` or switch per session with `ui.theme.palette('neon')`. Custom palettes: define `:root[data-palette="mine"] { … }` and `:root.dark[data-palette="mine"] { … }` in your `css` file, then `ui.theme.palette('mine')`.
|
package/docs/examples.md
CHANGED
|
@@ -5,11 +5,15 @@ Pages: [`apps/demo/src/examples/`](../apps/demo/src/examples/) (auto-loaded via
|
|
|
5
5
|
|
|
6
6
|
After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http://localhost:4000.
|
|
7
7
|
|
|
8
|
+
## Sidebar groups
|
|
9
|
+
|
|
10
|
+
Each page sets `group` / `groupIcon` in its `pageMeta`, so the sidebar and the home page's catalog share one structure: **Start here** (themes, fill layout, counter, todo, kitchen sink) → **Reactivity** → **Data** → **Forms & controls** → **Overlays & layout** → **Apps** → **Account** → **ReUI batches**. Order within a group is `pageMeta.order`.
|
|
11
|
+
|
|
8
12
|
## Catalog
|
|
9
13
|
|
|
10
14
|
| Route | File | Patterns shown |
|
|
11
15
|
|-------|------|----------------|
|
|
12
|
-
| `/` | `Home.ts` |
|
|
16
|
+
| `/` | `Home.ts` | Landing page: hero, live counter + source side by side (`ui.codeBlock`), feature grid, catalog grouped via `ui.navFromPages()` (same as sidebar) |
|
|
13
17
|
| `/examples/counter` | `Counter.ts` | `setText`, `refreshable`, button variants |
|
|
14
18
|
| `/examples/todo` | `Todo.ts` | `reactive`, `bindValue`, list `refreshable`, filters |
|
|
15
19
|
| `/examples/chat` | `Chat.ts` | `ui.storage.app` (persisted messages + ephemeral presence), async `get`/`set` |
|
|
@@ -23,6 +27,8 @@ After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http:
|
|
|
23
27
|
| `/examples/reui-batch-7` | `ReuiBatch7Demo.ts` | ReUI batch 7: `inputGroup`, `toggle`, `descriptionList`, `staticTable`, `aspectRatio`, `itemList`, `checkboxGroup` |
|
|
24
28
|
| `/examples/charts` | `ChartDemo.ts` | `ui.chart.*`, `ui.sparkline`, composed/scatter charts |
|
|
25
29
|
| `/examples/slider-demo` | `SliderDemo.ts` | Slider, checkbox, select + bindings |
|
|
30
|
+
| `/examples/themes` | `ThemesDemo.ts` | Palette gallery: `ui.theme.palette`, light/dark, component preview |
|
|
31
|
+
| `/examples/layout` | `LayoutDemo.ts` | Fill layout: `fillViewport`, `grow`, `scroll`, `ui.grid` |
|
|
26
32
|
| `/examples/feedback` | `FeedbackDemo.ts` | Alerts, progress, timer, `ui.theme`, `ui.runJavaScript` / `ui.scroll` |
|
|
27
33
|
| `/examples/form-demo` | `FormDemo.ts` | Form + validate + `ui.draft` + rating / colorPicker / tags |
|
|
28
34
|
| `/examples/auth` | `Auth.ts` (+ login/admin/change-password) | Signed cookie session, hashed passwords, login lockout, role-gated admin + audit log |
|
package/docs/ops-patterns.md
CHANGED
|
@@ -220,6 +220,28 @@ Still redact before display when possible. Prefer VPN + auth for hubs that surfa
|
|
|
220
220
|
Nav filtering is UX only — always enforce roles in the page builder.
|
|
221
221
|
|
|
222
222
|
```ts
|
|
223
|
+
import { ui } from '@close-by/clay';
|
|
224
|
+
import { getCurrentSession } from '@close-by/clay-core';
|
|
225
|
+
import { resolveUserIdFromAuthCookie } from '@close-by/clay-server';
|
|
226
|
+
import { createAuthGuards } from '@close-by/clay-auth';
|
|
227
|
+
|
|
228
|
+
type User = { role: 'admin' | 'member' };
|
|
229
|
+
const userById = new Map<string, User>(); // Your app's user store, keyed by user ID.
|
|
230
|
+
const authSecret = process.env.CLAY_AUTH_SECRET!;
|
|
231
|
+
const anonymousUserId = '__clay_anonymous__';
|
|
232
|
+
const resolveSignedUserId = resolveUserIdFromAuthCookie(authSecret);
|
|
233
|
+
|
|
234
|
+
function getSessionUser(): User | null {
|
|
235
|
+
const userId = getCurrentSession()?.userId;
|
|
236
|
+
if (!userId || userId === anonymousUserId) return null;
|
|
237
|
+
return userById.get(userId) ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { requireRole } = createAuthGuards({
|
|
241
|
+
getUser: getSessionUser,
|
|
242
|
+
onUnauthenticated: () => ui.navigate('/login'),
|
|
243
|
+
});
|
|
244
|
+
|
|
223
245
|
// pageMeta
|
|
224
246
|
export const pageMeta = {
|
|
225
247
|
label: 'Admin',
|
|
@@ -230,20 +252,26 @@ export const pageMeta = {
|
|
|
230
252
|
|
|
231
253
|
// shell
|
|
232
254
|
ui.run({
|
|
233
|
-
authSecret
|
|
255
|
+
authSecret,
|
|
256
|
+
resolveUserId: (context) => resolveSignedUserId(context) ?? anonymousUserId,
|
|
234
257
|
app: {
|
|
235
258
|
title: 'Hub',
|
|
236
|
-
nav
|
|
259
|
+
get nav() {
|
|
260
|
+
const user = getSessionUser();
|
|
261
|
+
return ui.navFromPages(user ? { role: user.role } : {});
|
|
262
|
+
},
|
|
237
263
|
},
|
|
238
264
|
});
|
|
239
265
|
|
|
240
266
|
// page
|
|
241
267
|
ui.page('/admin', () => {
|
|
242
|
-
requireRole('admin');
|
|
268
|
+
if (!requireRole('admin')) return;
|
|
243
269
|
// …
|
|
244
270
|
});
|
|
245
271
|
```
|
|
246
272
|
|
|
273
|
+
`get nav()` is evaluated while each page shell is built, so it filters routes for the current session. Reserve `__clay_anonymous__` outside `userById`: the resolver substitutes it for missing or invalid cookies, and `getSessionUser` treats it as signed out. Replace `userById` with your app's user store.
|
|
274
|
+
|
|
247
275
|
See `/examples/auth` and [API — auth](./api.md).
|
|
248
276
|
|
|
249
277
|
## `ui.ai.chat` and state
|
package/docs/production.md
CHANGED
|
@@ -18,22 +18,47 @@ Clay ships hooks for real identity beyond anonymous `userId`:
|
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
import { ui } from '@close-by/clay';
|
|
21
|
-
import {
|
|
21
|
+
import { getCurrentSession } from '@close-by/clay-core';
|
|
22
|
+
import { resolveUserIdFromAuthCookie } from '@close-by/clay-server';
|
|
23
|
+
import { createAuthGuards } from '@close-by/clay-auth';
|
|
24
|
+
|
|
25
|
+
type User = { role: 'admin' | 'member' };
|
|
26
|
+
const userById = new Map<string, User>(); // Your app's user store, keyed by user ID.
|
|
27
|
+
const authSecret = process.env.CLAY_AUTH_SECRET!;
|
|
28
|
+
const anonymousUserId = '__clay_anonymous__';
|
|
29
|
+
const resolveSignedUserId = resolveUserIdFromAuthCookie(authSecret);
|
|
30
|
+
|
|
31
|
+
function getSessionUser(): User | null {
|
|
32
|
+
const userId = getCurrentSession()?.userId;
|
|
33
|
+
if (!userId || userId === anonymousUserId) return null;
|
|
34
|
+
return userById.get(userId) ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { requireRole } = createAuthGuards({
|
|
38
|
+
getUser: getSessionUser,
|
|
39
|
+
onUnauthenticated: () => ui.navigate('/login'),
|
|
40
|
+
});
|
|
22
41
|
|
|
23
42
|
ui.run({
|
|
24
|
-
authSecret
|
|
43
|
+
authSecret,
|
|
44
|
+
resolveUserId: (context) => resolveSignedUserId(context) ?? anonymousUserId,
|
|
25
45
|
app: {
|
|
26
46
|
title: 'Hub',
|
|
27
|
-
nav
|
|
47
|
+
get nav() {
|
|
48
|
+
const user = getSessionUser();
|
|
49
|
+
return ui.navFromPages(user ? { role: user.role } : {});
|
|
50
|
+
},
|
|
28
51
|
},
|
|
29
52
|
});
|
|
30
53
|
|
|
31
54
|
ui.page('/admin', () => {
|
|
32
|
-
requireRole('admin');
|
|
55
|
+
if (!requireRole('admin')) return;
|
|
33
56
|
// …
|
|
34
57
|
});
|
|
35
58
|
```
|
|
36
59
|
|
|
60
|
+
`get nav()` is evaluated while each page shell is built, so it filters routes for the current session. Reserve `__clay_anonymous__` outside `userById`: the resolver substitutes it for missing or invalid cookies, and `getSessionUser` treats it as signed out. Replace `userById` with your app's user store.
|
|
61
|
+
|
|
37
62
|
- `pageMeta.roles` — hide nav items via `ui.navFromPages({ role })` (UX only)
|
|
38
63
|
- `requireRole` / guards in the page builder — **enforce** access before building sensitive UI
|
|
39
64
|
- See [API — auth](./api.md), `/examples/auth`, and [Ops patterns — auth nav](./ops-patterns.md#auth--role-filtered-nav)
|
package/docs/protocol.md
CHANGED
|
@@ -19,7 +19,7 @@ On unexpected socket close, the client reconnects with exponential backoff (500m
|
|
|
19
19
|
{ "op": "hello", "path": "/examples/counter", "userId": "optional-stable-id", "hash": "optional-hash-without-hash-sign", "browserStorage": {}, "clientStorage": {}, "tabStorage": {} }
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Client connects (or sends another `hello` after navigate on the same socket) and identifies the page. Optional `userId` (from localStorage/cookie) enables `ui.storage.user` unless overridden by `resolveUserId`. Optional `hash` (no leading `#`) hydrates `ui.getUrlHash()`. Optional `search` (no leading `?`) hydrates `ui.getUrlSearch()`. Optional `browserStorage` / `clientStorage` / `tabStorage` bags hydrate `ui.storage.browser` / `ui.storage.client` / `ui.storage.tab`.
|
|
22
|
+
Client connects (or sends another `hello` after navigate on the same socket) and identifies the page. Optional `userId` (from localStorage/cookie) enables `ui.storage.user` unless overridden by `resolveUserId`. Optional `hash` (no leading `#`) hydrates `ui.getUrlHash()`. Optional `search` (no leading `?`) hydrates `ui.getUrlSearch()`. Optional `browserStorage` / `clientStorage` / `tabStorage` bags hydrate `ui.storage.browser` / `ui.storage.client` / `ui.storage.tab`. Optional `palette` reports the palette the page shows, so `ui.theme.getPalette()` is correct after a reload.
|
|
23
23
|
|
|
24
24
|
Sent on connect and after client-side navigation. The React client keeps matching `app` shell chrome mounted across remounts; only inset content is replaced.
|
|
25
25
|
|
|
@@ -199,6 +199,18 @@ Client runs `window.open(url, '_blank', 'noopener,noreferrer')`. Prefer `ui.open
|
|
|
199
199
|
|
|
200
200
|
Sent by `ui.theme.set(...)`.
|
|
201
201
|
|
|
202
|
+
### `palette`
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{ "op": "palette", "palette": "ocean" }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Field | Type | Notes |
|
|
209
|
+
|-------|------|-------|
|
|
210
|
+
| `palette` | `string` | Sets `<html data-palette>` (`kiln` / empty clears to default); persisted under `clay-palette` and restored before first paint |
|
|
211
|
+
|
|
212
|
+
Sent by `ui.theme.palette(...)`.
|
|
213
|
+
|
|
202
214
|
### `runJavaScript`
|
|
203
215
|
|
|
204
216
|
```json
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Updating in place (no flicker)
|
|
2
|
+
|
|
3
|
+
Clay sends every change over one WebSocket as patches. How your server code changes the element tree decides which patches go out, and therefore what the user sees.
|
|
4
|
+
|
|
5
|
+
New data should patch what is on screen, not rebuild it.
|
|
6
|
+
|
|
7
|
+
## The rule
|
|
8
|
+
|
|
9
|
+
There are two ways to show a new value:
|
|
10
|
+
|
|
11
|
+
- **Rebuild.** You create new `ui.*` elements for the section (a new table, a new row of cards, a new list). Each new element gets a fresh id. The client receives a `setChildren` with those new ids and mounts new DOM. The old DOM is removed. Whatever lived in it is gone: scroll position, focus, what the user typed in an input, the selected row, and any enter animation runs again. The user sees a blank or jumping section.
|
|
12
|
+
- **Patch.** You keep the element you already have and change one prop on it, for example `label.setText(...)`. The client receives a single `updateProps` for that id, such as `{"op":"updateProps","id":"…","props":{"text":"42"}}`. The DOM node stays the same, so nothing else on the screen moves.
|
|
13
|
+
|
|
14
|
+
The flicker is caused by the remount, not by the new value. If a frame carries only the changed prop, the browser has nothing to redraw except that text.
|
|
15
|
+
|
|
16
|
+
## Pick the right tool
|
|
17
|
+
|
|
18
|
+
| Situation | API | What goes out |
|
|
19
|
+
|-----------|-----|---------------|
|
|
20
|
+
| Change one value (a label, badge, or input) | `label.setText(…)`, `el.setValue(…)`, `el.update({ … })` | `updateProps` for that element |
|
|
21
|
+
| Live table data | `table.setRows(rows)` on a table created with a stable `keyField` (add `highlightChanges: true` to flash changed rows) | `updateProps` on the table; rows keyed by `keyField` keep their DOM |
|
|
22
|
+
| Table refetch | `table.setLoading(true)` / `table.withLoading(fn)` | `updateProps` (`loading`); previous rows stay, dimmed, instead of blanking |
|
|
23
|
+
| A section whose structure depends on data | `ui.refreshable(() => …)` + `.refresh()`, or `ui.auto(() => …)` | Reconciled: matched children get `updateProps` only; added, removed, or reordered children cause one `setChildren` per affected parent |
|
|
24
|
+
| Items in a list inside either of the above | `.key(item.id)` on each item | Items keep their identity through reorder, filter, and prepend |
|
|
25
|
+
|
|
26
|
+
Use the first row whenever the shape of the screen does not change. Reach for `refreshable` or `auto` only when children really are added, removed, or reordered.
|
|
27
|
+
|
|
28
|
+
## How reconciliation matches
|
|
29
|
+
|
|
30
|
+
When a `refreshable` or `auto` block re-runs, Clay builds a fresh tree and matches it against the children already on screen:
|
|
31
|
+
|
|
32
|
+
1. **Keyed first.** A new child with `.key(k)` matches the old child with the same key and the same element type. Keyed children are matched only by key.
|
|
33
|
+
2. **Unkeyed by position.** Unkeyed children are matched in order, by element type. If the next old child matches the current new one, the current old child was removed and is skipped. If the current old child matches the next new one, the new child was inserted. Otherwise the pair is treated as a replacement at that position. This lookahead covers a single insert or remove; larger reshuffles should be keyed.
|
|
34
|
+
3. **Matched pairs keep the old element and its id.** The fresh element's handlers and props are copied onto the old one. Only props that actually changed are sent, as one `updateProps`.
|
|
35
|
+
4. **Leftovers.** Old children with no match are destroyed. New children with no match are mounted. The parent sends one `setChildren` only if its list of ids changed. A child whose type changed at a position is replaced; its siblings keep their ids.
|
|
36
|
+
5. **Nested blocks keep their instance.** A nested `refreshable` or `auto` keeps its id and its subscriptions and adopts the new builder, so its own refreshes keep working.
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
### Live orders list (`ui.auto` with keys)
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { ui } from '@close-by/clay';
|
|
44
|
+
|
|
45
|
+
type Order = { id: string; customer: string; status: string; total: number };
|
|
46
|
+
const orders = ui.state({ list: [] as Order[], filter: '' });
|
|
47
|
+
|
|
48
|
+
ui.auto(() => {
|
|
49
|
+
const shown = orders.list.filter((o) => o.status.includes(orders.filter));
|
|
50
|
+
for (const o of shown) {
|
|
51
|
+
ui.row(() => {
|
|
52
|
+
ui.label(o.customer);
|
|
53
|
+
ui.badge(o.status, { size: 'xs' });
|
|
54
|
+
ui.label(`$${o.total}`);
|
|
55
|
+
}).key(o.id);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// From your feed handler. Replacing the array re-runs the block once.
|
|
60
|
+
// Each order keeps its element because its key is unchanged, so a
|
|
61
|
+
// status change is an updateProps on that row's badge.
|
|
62
|
+
orders.list = nextSnapshot;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Keys must be stable. Use the order id, not the array index or a timestamp.
|
|
66
|
+
|
|
67
|
+
### Dashboard KPI row (`setText`)
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
let revenue: ReturnType<typeof ui.label>;
|
|
71
|
+
let openCount: ReturnType<typeof ui.label>;
|
|
72
|
+
|
|
73
|
+
ui.row(() => {
|
|
74
|
+
revenue = ui.label('$0').classes('text-2xl', 'font-semibold');
|
|
75
|
+
openCount = ui.label('0').classes('text-2xl', 'font-semibold');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Called whenever new metrics arrive. Two updateProps frames, no rebuild.
|
|
79
|
+
function onMetrics(m: { revenue: number; open: number }) {
|
|
80
|
+
revenue.setText(`$${m.revenue.toLocaleString()}`);
|
|
81
|
+
openCount.setText(String(m.open));
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Live table (`setRows` + `highlightChanges` + `setLoading`)
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const table = ui.dataTable([], {
|
|
89
|
+
keyField: 'id',
|
|
90
|
+
highlightChanges: true,
|
|
91
|
+
searchable: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// fetchOrders() is your own data call.
|
|
95
|
+
async function refresh() {
|
|
96
|
+
await table.withLoading(async () => {
|
|
97
|
+
table.setRows(await fetchOrders());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`withLoading` sets loading before `fn` runs and clears it afterwards, so the previous rows stay on screen under a dimmed body until `setRows` runs. Rows whose `id` did not change keep their DOM, and changed or new rows flash.
|
|
103
|
+
|
|
104
|
+
If you want columns to stay fixed across refreshes, pass `columns` explicitly. Otherwise columns are inferred again on each `setRows`.
|
|
105
|
+
|
|
106
|
+
## Anti-patterns
|
|
107
|
+
|
|
108
|
+
- **Rebuilding a whole section when one value changes.** Wrapping a whole page in `ui.refreshable` and calling `.refresh()` every second to update a clock. Keep a reference to the clock label and call `setText`.
|
|
109
|
+
- **Unkeyed lists that get sorted or filtered.** Without `.key()`, position is the identity. After a sort, the element in slot 3 is reused for whatever item is now third, so focus and typed text stay in the slot instead of following the row.
|
|
110
|
+
- **Creating a new table instead of `setRows`.** A `ui.dataTable(...)` created inside a refreshable on every tick remounts the table and loses sort, scroll, selection, and search. Create the table once and call `setRows`.
|
|
111
|
+
- **Tables without `keyField`.** Rows are stamped `__rowId` by their index, so inserting or re-sorting the source shifts every row's identity. Set `keyField` to your primary key.
|
|
112
|
+
- **Toggling a constructor option by rebuilding.** Recreating a table with `zebra: on` inside an `auto` block. Use the setters instead: `setZebra`, `setDensity`, `setHighlightChanges`.
|
|
113
|
+
|
|
114
|
+
## Checking it works
|
|
115
|
+
|
|
116
|
+
1. **Devtools, DOM node.** Open the browser Elements panel and select a row, label, or input. Trigger an update (wait for the next feed tick or change the filter). The same node should still be selected and highlighted. For an input, click into it, let an update arrive, and confirm the caret stays. If the node is replaced, the block was rebuilt.
|
|
117
|
+
2. **Devtools, WebSocket frames.** Open the Network panel, filter to WS, select the connection, and watch the frames as data arrives. A value change should show `updateProps` with a small payload for one id. `setChildren` should appear only when rows are added, removed, or reordered. If an unchanged list sends `setChildren` on every tick, the child ids are changing: check that `.key()` values are stable and that the block is not creating new parent elements on each run.
|
|
118
|
+
|
|
119
|
+
## See also
|
|
120
|
+
|
|
121
|
+
- [Concepts: Updates](./concepts.md#updates-settext-vs-refreshable) for the basic `setText` vs `refreshable` split
|
|
122
|
+
- [API reference](./api.md) for `ui.refreshable`, `ui.auto`, `ui.dataTable`, and `Element` methods
|
|
123
|
+
- [Reactive `let`](./reactive-let.md) for the compile-time alternative to hand-written `ui.auto`
|
|
124
|
+
- [WebSocket protocol](./protocol.md) for the patch message formats
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@close-by/clay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "NiceGUI-like ui.* facade for Clay apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@close-by/clay-compiler": "0.2.
|
|
41
|
-
"@close-by/clay-core": "0.2.
|
|
42
|
-
"@close-by/clay-components": "0.2.
|
|
43
|
-
"@close-by/clay-server": "0.2.
|
|
40
|
+
"@close-by/clay-compiler": "0.2.18",
|
|
41
|
+
"@close-by/clay-core": "0.2.18",
|
|
42
|
+
"@close-by/clay-components": "0.2.18",
|
|
43
|
+
"@close-by/clay-server": "0.2.18",
|
|
44
44
|
"@tailwindcss/cli": "^4.3.3",
|
|
45
45
|
"tailwindcss": "^4.3.3"
|
|
46
46
|
}
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
column as columnFactory,
|
|
67
67
|
container as containerFactory,
|
|
68
68
|
hero as heroFactory,
|
|
69
|
+
grid as gridFactory,
|
|
69
70
|
card as cardFactory,
|
|
70
71
|
dialog as dialogFactory,
|
|
71
72
|
dialogStack as dialogStackFactory,
|
|
@@ -242,6 +243,8 @@ import {
|
|
|
242
243
|
type LineChartStyleProps,
|
|
243
244
|
type SparklineProps,
|
|
244
245
|
type CardProps,
|
|
246
|
+
type GridProps,
|
|
247
|
+
type LayoutProps,
|
|
245
248
|
type DialogProps,
|
|
246
249
|
type DialogStackProps,
|
|
247
250
|
type DialogStackStepOptions,
|
|
@@ -471,6 +474,7 @@ import {
|
|
|
471
474
|
TimerHandle,
|
|
472
475
|
type TimerOptions,
|
|
473
476
|
type ThemeMode,
|
|
477
|
+
type ThemePalette,
|
|
474
478
|
type DraftStorage,
|
|
475
479
|
type DraftOptions,
|
|
476
480
|
type UrlStateOptions,
|
|
@@ -512,6 +516,8 @@ export {
|
|
|
512
516
|
|
|
513
517
|
|
|
514
518
|
export type {
|
|
519
|
+
GridProps,
|
|
520
|
+
LayoutProps,
|
|
515
521
|
ButtonProps,
|
|
516
522
|
IconButtonProps,
|
|
517
523
|
ButtonVariant,
|
|
@@ -698,6 +704,7 @@ export type {
|
|
|
698
704
|
NotifyType,
|
|
699
705
|
ToastPosition,
|
|
700
706
|
ThemeMode,
|
|
707
|
+
ThemePalette,
|
|
701
708
|
PageMeta,
|
|
702
709
|
NavFromPagesOptions,
|
|
703
710
|
PageOptions,
|
|
@@ -1255,33 +1262,43 @@ export function sparkline(props: SparklineProps): Element {
|
|
|
1255
1262
|
}
|
|
1256
1263
|
|
|
1257
1264
|
/** Horizontal flex row (`fn` or props-first). Prefer `ui.row`. */
|
|
1258
|
-
export function row(fn: () => void, props?:
|
|
1259
|
-
export function row(props:
|
|
1265
|
+
export function row(fn: () => void, props?: LayoutProps): Element;
|
|
1266
|
+
export function row(props: LayoutProps, fn: () => void): Element;
|
|
1260
1267
|
export function row(a: any, b?: any): Element {
|
|
1261
1268
|
return rowFactory(a, b);
|
|
1262
1269
|
}
|
|
1263
1270
|
|
|
1264
1271
|
/** Vertical flex column (`fn` or props-first). Prefer `ui.column`. */
|
|
1265
|
-
export function column(fn: () => void, props?:
|
|
1266
|
-
export function column(props:
|
|
1272
|
+
export function column(fn: () => void, props?: LayoutProps): Element;
|
|
1273
|
+
export function column(props: LayoutProps, fn: () => void): Element;
|
|
1267
1274
|
export function column(a: any, b?: any): Element {
|
|
1268
1275
|
return columnFactory(a, b);
|
|
1269
1276
|
}
|
|
1270
1277
|
|
|
1271
1278
|
/** Layout container (`fn` or props-first). Prefer `ui.container`. */
|
|
1272
|
-
export function container(fn: () => void, props?:
|
|
1273
|
-
export function container(props:
|
|
1279
|
+
export function container(fn: () => void, props?: LayoutProps): Element;
|
|
1280
|
+
export function container(props: LayoutProps, fn: () => void): Element;
|
|
1274
1281
|
export function container(a: any, b?: any): Element {
|
|
1275
1282
|
return containerFactory(a, b);
|
|
1276
1283
|
}
|
|
1277
1284
|
|
|
1278
1285
|
/** Hero section (`fn` or props-first). Prefer `ui.hero`. */
|
|
1279
|
-
export function hero(fn: () => void, props?:
|
|
1280
|
-
export function hero(props:
|
|
1286
|
+
export function hero(fn: () => void, props?: LayoutProps): Element;
|
|
1287
|
+
export function hero(props: LayoutProps, fn: () => void): Element;
|
|
1281
1288
|
export function hero(a: any, b?: any): Element {
|
|
1282
1289
|
return heroFactory(a, b);
|
|
1283
1290
|
}
|
|
1284
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* Responsive grid that fills the width (`fn` or props-first). Prefer `ui.grid`.
|
|
1294
|
+
* `minColWidth` auto-fits columns (default 16rem); `cols` fixes the count. See {@link GridProps}.
|
|
1295
|
+
*/
|
|
1296
|
+
export function grid(fn: () => void, props?: GridProps): Element;
|
|
1297
|
+
export function grid(props: GridProps, fn: () => void): Element;
|
|
1298
|
+
export function grid(a: any, b?: any): Element {
|
|
1299
|
+
return gridFactory(a, b);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1285
1302
|
/** Card with optional header/footer (`fn` or props-first). Prefer `ui.card`. See {@link CardProps}. */
|
|
1286
1303
|
export function card(fn: (card: Element) => void, props?: CardProps): Element;
|
|
1287
1304
|
export function card(props: CardProps, fn: (card: Element) => void): Element;
|
|
@@ -1925,6 +1942,7 @@ export const ui = {
|
|
|
1925
1942
|
row,
|
|
1926
1943
|
column,
|
|
1927
1944
|
container,
|
|
1945
|
+
grid,
|
|
1928
1946
|
hero,
|
|
1929
1947
|
card,
|
|
1930
1948
|
app,
|