@close-by/clay 0.2.17 → 0.2.19
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 +2 -0
- package/docs/api.md +83 -1
- package/docs/demo-deployment.md +64 -0
- package/docs/elements.md +3 -1
- package/docs/examples.md +12 -1
- package/docs/maps.md +214 -0
- package/docs/protocol.md +13 -1
- package/docs/updating-in-place.md +124 -0
- package/package.json +5 -5
- package/src/index.ts +61 -8
package/docs/README.md
CHANGED
|
@@ -15,12 +15,14 @@ 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 |
|
|
21
22
|
| [Ops patterns](./ops-patterns.md) | Dense consoles: chips, master–detail, feeds, sensitive logs, auth nav |
|
|
22
23
|
| [API reference](./api.md) | `ui.*`, `Element` methods, `reactive`, helpers |
|
|
23
24
|
| [Elements](./elements.md) | All element types and props |
|
|
25
|
+
| [Maps](./maps.md) | `ui.map`: MapLibre markers, routes, arcs, GeoJSON, clusters; palette theming and in-place updates |
|
|
24
26
|
| [WebSocket protocol](./protocol.md) | Client ↔ server message formats |
|
|
25
27
|
| [Architecture](./architecture.md) | Packages, data flow, and ownership |
|
|
26
28
|
| [Examples](./examples.md) | Demo apps and patterns |
|
package/docs/api.md
CHANGED
|
@@ -91,6 +91,7 @@ ui.run({
|
|
|
91
91
|
| `title` | `string` | `'Clay'` | HTML `<title>` |
|
|
92
92
|
| `clientDir` | `string` | `resolveClayClientDir()` | Built Vite assets (`@close-by/clay-cli/client-dist` when installed). Prefer `resolveClayClientDir()` over hardcoding `node_modules` paths — see [Boot](./boot.md). |
|
|
93
93
|
| `css` | `string \| string[]` | | Extra CSS file path(s) served after the client bundle (absolute or cwd-relative). Override theme tokens without rebuilding the client. |
|
|
94
|
+
| `sound` | `boolean` | | Play short synthesized interface sounds by default (`false`). See [Sound](#sound). |
|
|
94
95
|
| `app` | `AppProps` | | Global dashboard shell; client keeps chrome sticky across navigate |
|
|
95
96
|
| `authSecret` | `string` | | Enables signed auth cookies (`POST`/`DELETE /auth/session`), `ui.establishAuthSession` / `ui.clearAuthSession`, and default `resolveUserId` from the cookie |
|
|
96
97
|
| `authCookieMaxAgeSec` | `number` | `43200` (12h) | Auth cookie Max-Age |
|
|
@@ -1801,6 +1802,13 @@ onClick: async () => {
|
|
|
1801
1802
|
| `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
|
|
1802
1803
|
| `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
|
|
1803
1804
|
| `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
|
|
1805
|
+
| `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 |
|
|
1806
|
+
| `ui.theme.getPalette()` | `ThemePalette \| null` | Last palette set, else the one the client reported on connect |
|
|
1807
|
+
| `ui.theme.palettes` | `readonly string[]` | Built-in palette names |
|
|
1808
|
+
| `ui.sound.set(on)` | `void` | Turn automatic interface sounds on/off for this client; remembered across reloads. See [Sound](#sound) |
|
|
1809
|
+
| `ui.sound.volume(v)` | `void` | Master volume `0`–`1` (default `0.5`); remembered across reloads |
|
|
1810
|
+
| `ui.sound.play(name)` | `void` | Play one cue now, even when automatic sounds are off |
|
|
1811
|
+
| `ui.sound.names` | `readonly string[]` | Built-in cue names |
|
|
1804
1812
|
| `ui.runJavaScript(code)` | `void` | Eval trusted snippet in the browser |
|
|
1805
1813
|
| `ui.scroll.to(opts)` | `void` | Scroll window (`top` / `bottom` / px) |
|
|
1806
1814
|
| `ui.scroll.intoView(selector, opts?)` | `void` | `Element.scrollIntoView` via selector |
|
|
@@ -1911,9 +1919,11 @@ All layout helpers accept either `(fn, props?)` or `(props, fn)`.
|
|
|
1911
1919
|
| `ui.row` | Horizontal flex |
|
|
1912
1920
|
| `ui.column` | Vertical flex |
|
|
1913
1921
|
| `ui.container` | Width-constrained wrapper |
|
|
1922
|
+
| `ui.grid` | Responsive grid that fills the width (auto-fit columns, or fixed `cols`) |
|
|
1914
1923
|
| `ui.hero` | Centered hero region |
|
|
1915
1924
|
| `ui.card` | Card; callback receives the card element |
|
|
1916
1925
|
| `ui.app` | SPA shell: sidebar nav + centered main content |
|
|
1926
|
+
| `ui.map` | Live map (MapLibre): markers, routes, arcs, GeoJSON, clusters, popup. See [Maps](./maps.md) |
|
|
1917
1927
|
|
|
1918
1928
|
#### `ui.app(props, fn)`
|
|
1919
1929
|
|
|
@@ -1965,8 +1975,44 @@ Shared layout props:
|
|
|
1965
1975
|
| `className` | `string` | | Extra classes |
|
|
1966
1976
|
| `centered` | `boolean` | | `mx-auto` (container) |
|
|
1967
1977
|
| `width` | `'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' \| '4xl' \| '5xl' \| '6xl' \| '7xl' \| 'full'` | `'lg'` for container | Max width |
|
|
1978
|
+
| `align` | `'start' \| 'center' \| 'end' \| 'baseline' \| 'stretch'` | row: `center` (`stretch` when the row grows) | Cross-axis alignment |
|
|
1979
|
+
| `justify` | `'start' \| 'center' \| 'end' \| 'between' \| 'around'` | | Main-axis distribution |
|
|
1980
|
+
| `wrap` | `boolean` | row: `true` | Flex wrap |
|
|
1981
|
+
| `grow` | `boolean \| number` | | Take the remaining space in the parent row/column. A number sets the share (`grow: 2` gets twice `grow: 1`) |
|
|
1982
|
+
| `scroll` | `boolean \| 'x' \| 'y'` | | Scroll inside this element instead of growing the page. On a card, the body scrolls and the header stays |
|
|
1983
|
+
| `fillViewport` | `boolean` | | Height = visible page area (viewport minus the app shell header/padding). Use on the outermost page column |
|
|
1968
1984
|
|
|
1969
|
-
Card also accepts `title?: string
|
|
1985
|
+
Card also accepts `title?: string` and `description?: string`; `grow` / `scroll` / `fillViewport` work on cards too.
|
|
1986
|
+
|
|
1987
|
+
#### `ui.grid(props, fn)`
|
|
1988
|
+
|
|
1989
|
+
| Prop | Type | Default | Description |
|
|
1990
|
+
|------|------|---------|-------------|
|
|
1991
|
+
| `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 |
|
|
1992
|
+
| `cols` | `number` | | Fixed column count instead of auto-fit (one column below `sm`) |
|
|
1993
|
+
| `gap` | `number` | `4` | Gap scale |
|
|
1994
|
+
|
|
1995
|
+
```typescript
|
|
1996
|
+
ui.grid({ minColWidth: '14rem' }, () => {
|
|
1997
|
+
for (const kpi of kpis) ui.card({ title: kpi.label }, () => ui.label(kpi.value));
|
|
1998
|
+
});
|
|
1999
|
+
```
|
|
2000
|
+
|
|
2001
|
+
#### Filling the page
|
|
2002
|
+
|
|
2003
|
+
Make a page fill the window and let panels scroll instead of the page:
|
|
2004
|
+
|
|
2005
|
+
```typescript
|
|
2006
|
+
ui.column({ fillViewport: true, gap: 4 }, () => {
|
|
2007
|
+
ui.grid({ minColWidth: '12rem' }, () => { /* KPI cards — natural height */ });
|
|
2008
|
+
ui.row({ grow: true, wrap: false, gap: 4 }, () => { // takes the remaining height
|
|
2009
|
+
ui.card({ title: 'Inbox', scroll: true, className: 'w-80 shrink-0' }, () => { /* list */ });
|
|
2010
|
+
ui.card({ title: 'Detail', grow: true, scroll: true }, () => { /* body */ });
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
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
2016
|
|
|
1971
2017
|
```typescript
|
|
1972
2018
|
ui.container(() => {
|
|
@@ -1976,6 +2022,15 @@ ui.container(() => {
|
|
|
1976
2022
|
}, { centered: true, width: 'md' });
|
|
1977
2023
|
```
|
|
1978
2024
|
|
|
2025
|
+
#### `ui.map(props?)`
|
|
2026
|
+
|
|
2027
|
+
A live map: MapLibre with markers, routes, arcs, GeoJSON layers, clusters and a popup. Setters (`setMarkers`, `upsertMarker`, `updateRoute`, `flyTo`, `fitBounds`, ...) update it in place. Props, tones, events and layout are in the [Maps guide](./maps.md).
|
|
2028
|
+
|
|
2029
|
+
```typescript
|
|
2030
|
+
const fleet = ui.map({ fill: true, markers: vehicles(), onMarkerClick: (m) => select(m.id) });
|
|
2031
|
+
fleet.fitBounds('markers', { padding: 48 });
|
|
2032
|
+
```
|
|
2033
|
+
|
|
1979
2034
|
---
|
|
1980
2035
|
|
|
1981
2036
|
## `Element` methods (`@close-by/clay-core`)
|
|
@@ -2143,6 +2198,33 @@ import { button, input /* … */ } from '@close-by/clay-components';
|
|
|
2143
2198
|
|
|
2144
2199
|
---
|
|
2145
2200
|
|
|
2201
|
+
## Sound
|
|
2202
|
+
|
|
2203
|
+
Clay can answer interactions with short interface sounds, synthesized in the browser with Web Audio (no audio files). They're **off by default**. Turn them on for every visitor with `createServer({ sound: true })`, or per session with `ui.sound.set(true)`. The client remembers the last choice.
|
|
2204
|
+
|
|
2205
|
+
Once sound is on, a mute button appears next to the Clay connection chip in the bottom-left corner, so each user can silence it. A user's mute choice is remembered for their browser. Apps that never turn sound on don't show the button.
|
|
2206
|
+
|
|
2207
|
+
When enabled, a page-wide listener picks the cue from the element, so components need no wiring:
|
|
2208
|
+
|
|
2209
|
+
| Interaction | Cue |
|
|
2210
|
+
|---|---|
|
|
2211
|
+
| Button, link, `role="button"` | `tap` (`soft` for `ghost` / `link` variants, `destructive` for the destructive variant) |
|
|
2212
|
+
| Tab, menu item, option, radio | `select` (pitch rises with position) |
|
|
2213
|
+
| Switch, checkbox | `toggleOn` / `toggleOff` |
|
|
2214
|
+
| Disabled control (when it still receives the press) | `blocked` |
|
|
2215
|
+
| Dialog / sheet / alert dialog opens or closes | `open` / `close` |
|
|
2216
|
+
| Slider value changes | `tick` |
|
|
2217
|
+
| OTP digit typed | `key` |
|
|
2218
|
+
| `ui.notify` toast | `success` / `error` / `warning` / `notification` by type |
|
|
2219
|
+
| `ui.clipboard` / code block copy | `copy` |
|
|
2220
|
+
|
|
2221
|
+
Set `data-sound="<cue>"` on an element to override its cue, or `data-sound="none"` to silence it. Use `ui.sound.play(name)` to play a cue from server code, for example when a long job finishes. Cues: `tap`, `soft`, `select`, `destructive`, `blocked`, `toggleOn`, `toggleOff`, `open`, `close`, `tick`, `key`, `copy`, `success`, `error`, `warning`, `notification`.
|
|
2222
|
+
|
|
2223
|
+
```ts
|
|
2224
|
+
// A settings switch that lets each user turn sounds on or off
|
|
2225
|
+
ui.switch({ label: 'Interface sounds', onChange: (on) => ui.sound.set(on) });
|
|
2226
|
+
```
|
|
2227
|
+
|
|
2146
2228
|
## DuckDB (`@close-by/clay-duckdb`)
|
|
2147
2229
|
|
|
2148
2230
|
See [DuckDB](./duckdb.md) for the multi-database wrapper (`connect`, `attach`, CRUD).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Demo deployment on Pi
|
|
2
|
+
|
|
3
|
+
The demo runs at https://clay.close-by.org/ on Pi as the `bad-ui-demo` systemd service. It runs Bun as the `pi` user from `/home/pi/bad-ui-demo` on port 4000. The existing Flock Cloudflare tunnel forwards `clay.close-by.org` to `http://localhost:4000`; the Cloudflare DNS record is a proxied CNAME to that tunnel.
|
|
4
|
+
|
|
5
|
+
## Update the demo
|
|
6
|
+
|
|
7
|
+
From the repository root on a machine with SSH access to the `pi` host, copy the working tree and rebuild on Pi:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
tar --exclude-vcs --exclude=node_modules --exclude=dist-pack \
|
|
11
|
+
--exclude=.env --exclude='.env.*' --exclude=.badui-user-data \
|
|
12
|
+
--exclude=.clay-user-data --exclude=.clay --exclude=.tmp \
|
|
13
|
+
-czf /tmp/clay-demo-deploy.tgz .
|
|
14
|
+
scp /tmp/clay-demo-deploy.tgz pi:/tmp/clay-demo-deploy.tgz
|
|
15
|
+
ssh pi 'tar -xzf /tmp/clay-demo-deploy.tgz -C ~/bad-ui-demo && \
|
|
16
|
+
cd ~/bad-ui-demo && export PATH="$HOME/.local/bin:$PATH" && \
|
|
17
|
+
bun install --frozen-lockfile && bun run build:client && \
|
|
18
|
+
sudo systemctl restart bad-ui-demo'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This copies the current working tree, including uncommitted changes. Extraction does not delete files removed from the source tree; remove obsolete deployed files deliberately when needed. The service keeps running the previous process until the restart, but the copy and build happen in its working directory.
|
|
22
|
+
|
|
23
|
+
Check the service and public endpoint:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
ssh pi 'systemctl status bad-ui-demo --no-pager; journalctl -u bad-ui-demo -n 50 --no-pager'
|
|
27
|
+
curl -I https://clay.close-by.org/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Service and tunnel setup
|
|
31
|
+
|
|
32
|
+
The service is enabled with `sudo systemctl enable --now bad-ui-demo`. Its unit is `/etc/systemd/system/bad-ui-demo.service`:
|
|
33
|
+
|
|
34
|
+
```ini
|
|
35
|
+
[Unit]
|
|
36
|
+
Description=Clay demo
|
|
37
|
+
After=network.target
|
|
38
|
+
|
|
39
|
+
[Service]
|
|
40
|
+
User=pi
|
|
41
|
+
WorkingDirectory=/home/pi/bad-ui-demo
|
|
42
|
+
Environment=PORT=4000
|
|
43
|
+
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
44
|
+
EnvironmentFile=/home/pi/.config/bad-ui-demo/service.env
|
|
45
|
+
ExecStart=/home/pi/.local/bin/bun apps/demo/src/main.ts
|
|
46
|
+
Restart=on-failure
|
|
47
|
+
RestartSec=2
|
|
48
|
+
|
|
49
|
+
[Install]
|
|
50
|
+
WantedBy=multi-user.target
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The environment file contains `CLAY_AUTH_SECRET`; keep it private and stable across restarts. To create it on a fresh deployment, use `umask 077`, create `/home/pi/.config/bad-ui-demo`, and write `CLAY_AUTH_SECRET=` followed by a random secret (for example, from `openssl rand -hex 32`). Changing it invalidates existing signed sessions. After changing a unit, run `sudo systemctl daemon-reload` and restart the service.
|
|
54
|
+
|
|
55
|
+
The tunnel configuration is `/etc/cloudflared/flock.yml`, managed by `flock-tunnel.service`. Its Clay ingress entry must precede the final catch-all:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
- hostname: clay.close-by.org
|
|
59
|
+
service: http://localhost:4000
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Validate changes with `sudo cloudflared tunnel --config /etc/cloudflared/flock.yml ingress validate`, then restart `flock-tunnel`. This is shared with Flock, so preserve its existing ingress entries. The DNS CNAME must point to the existing tunnel's `<tunnel-id>.cfargotunnel.com` and be proxied; the tunnel credentials on Pi alone do not grant permission to edit Cloudflare DNS.
|
|
63
|
+
|
|
64
|
+
This is a public demo with example authentication and data, not a hardened production application. See [Production & security](./production.md) before deploying real data or applications.
|
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** → **Maps** (gallery, delivery tracker, store locator, globe, uptime monitor) → **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 |
|
|
@@ -38,6 +44,11 @@ After `bun run build:client && bun run demo` (or `bun run demo:cli`), open http:
|
|
|
38
44
|
| `/examples/gantt` | `GanttDemo.ts` | `ui.gantt` owned rows/dates/markers/deps, cross-row drag, header double-click markers, `itemMove` / `markerAdd` settle |
|
|
39
45
|
| `/examples/flow` | `FlowDemo.ts` | Four `ui.flow` demos: ETL with in-node controls + labeled edges + dagre auto-layout, multi-handle approval branches, fan-in/out + `addNode` dynamic stages, group/`parentId` nesting (flow-owned edges/positions; no outer diagram `ui.auto`) |
|
|
40
46
|
| `/examples/data-clients` | `DataClientsDemo.ts` | DuckDB / Kibana / ClickHouse integration story (mock-friendly) |
|
|
47
|
+
| `/examples/maps` | `MapsGallery.ts` | `ui.map` gallery: markers and popups, routes, arcs, choropleth, clusters, compact analytics map |
|
|
48
|
+
| `/examples/delivery-tracker` | `DeliveryTracker.ts` | `ui.map` route progress and courier marker: `updateRoute` / `upsertMarker` on a timer, `fill` layout |
|
|
49
|
+
| `/examples/store-locator` | `StoreLocator.ts` | `ui.map` filtered markers, `flyTo` / `fitBounds('markers')`, popups; list and map select each other by key |
|
|
50
|
+
| `/examples/globe` | `CommunityGlobe.ts` | `ui.map` globe projection, arcs, `setMarkers` / `setArcs` on a 30 s timer |
|
|
51
|
+
| `/examples/uptime` | `UptimeMonitor.ts` | `ui.map` status markers on a globe, refreshed every 2 s with `setMarkers` |
|
|
41
52
|
| `/examples/kitchen-sink` | `KitchenSink.ts` | ShadCN catalog preview (client `KitchenSink`) |
|
|
42
53
|
|
|
43
54
|
Each page file exports optional `pageMeta` (`label`, `icon`, `order`, optional `nav: false`, optional `roles`) for `ui.navFromPages()`.
|
package/docs/maps.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Maps
|
|
2
|
+
|
|
3
|
+
`ui.map(props)` is a live map with markers, routes, arcs, GeoJSON layers, clusters, heatmaps and a popup. It renders with [MapLibre GL](https://maplibre.org/) through a vendored copy of [mapcn](https://mapcn.vercel.app) (MIT). You create the map on the server, change it with setters, and handle clicks on the server.
|
|
4
|
+
|
|
5
|
+
- **Lazy.** The client loads the map code only when a page renders a map (`React.lazy` in the element renderer). Other pages do not download MapLibre.
|
|
6
|
+
- **Worker bundled.** The MapLibre worker ships with the client build. No CDN script is loaded.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
import { ui } from '@close-by/clay';
|
|
12
|
+
|
|
13
|
+
ui.page('/fleet', () => {
|
|
14
|
+
const fleet = ui.map({
|
|
15
|
+
center: [-9.14, 38.72],
|
|
16
|
+
zoom: 11,
|
|
17
|
+
height: '28rem',
|
|
18
|
+
markers: [
|
|
19
|
+
{ id: 'hq', lngLat: [-9.142, 38.7108], label: 'HQ', popup: { title: 'Head office' } },
|
|
20
|
+
{ id: 'truck-1', lngLat: [-9.1393, 38.7097], tone: 'chart-2', icon: 'truck', pulse: true },
|
|
21
|
+
],
|
|
22
|
+
onMarkerClick: (marker) => ui.notify(`Selected ${marker.id}`),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
fleet.fitBounds('markers', { padding: 48 });
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Coordinates are always `[longitude, latitude]`.
|
|
30
|
+
|
|
31
|
+
## Theming
|
|
32
|
+
|
|
33
|
+
- **Light and dark.** The basemap follows the page theme. Markers, routes, arcs and fills take their colours from the active palette.
|
|
34
|
+
- **Tones.** Colour props accept a tone name or a CSS colour such as `#2563eb`:
|
|
35
|
+
|
|
36
|
+
| Tone | Palette token |
|
|
37
|
+
|------|---------------|
|
|
38
|
+
| `primary` | `--primary` (default for markers, routes and fills) |
|
|
39
|
+
| `chart-1` … `chart-5` | `--chart-1` … `--chart-5` |
|
|
40
|
+
| `destructive` | `--destructive` |
|
|
41
|
+
| `muted` | `--muted-foreground` |
|
|
42
|
+
| `foreground` | `--foreground` |
|
|
43
|
+
|
|
44
|
+
- **Live palette changes.** The map watches the root element's `class`, `data-palette` and `style`, and the system colour scheme. A palette change re-resolves the colours in place. The map is not remounted, so the camera and open popups stay put.
|
|
45
|
+
- **`tint`** (default `true`) lightly tints the basemap toward the palette: background, water, and parks or green areas.
|
|
46
|
+
- **`basemap`** (default `'auto'`) pins the basemap to `'light'` or `'dark'`, whatever the page theme.
|
|
47
|
+
- **`styles`** replaces the basemap style URLs: `{ light?: string; dark?: string }`. See [Tiles](#tiles).
|
|
48
|
+
|
|
49
|
+
## Props
|
|
50
|
+
|
|
51
|
+
| Prop | Type | Default | Notes |
|
|
52
|
+
|------|------|---------|-------|
|
|
53
|
+
| `center` | `LngLat` | `[0, 20]` | Initial centre, read at mount. See [Camera](#camera) |
|
|
54
|
+
| `zoom` | `number` | `1.5` | Initial zoom |
|
|
55
|
+
| `pitch` / `bearing` | `number` | `0` | Initial pitch and bearing |
|
|
56
|
+
| `projection` | `'mercator' \| 'globe'` | `'mercator'` | |
|
|
57
|
+
| `height` | `string` | `'24rem'` | CSS length. Ignored with `fill` |
|
|
58
|
+
| `fill` | `boolean` | `false` | Fill the parent. See [Layout](#layout) |
|
|
59
|
+
| `interactive` | `boolean` | `true` | `false` disables pan, zoom and click |
|
|
60
|
+
| `basemap` | `'auto' \| 'light' \| 'dark'` | `'auto'` | |
|
|
61
|
+
| `styles` | `{ light?, dark? }` | CARTO styles | Custom MapLibre style URLs |
|
|
62
|
+
| `tint` | `boolean` | `true` | Palette tint on the basemap |
|
|
63
|
+
| `controls` | `MapControls \| false` | `{ zoom: true, position: 'bottom-right' }` | Object values merge over the default. `false` hides all controls |
|
|
64
|
+
| `markers` | `MapMarkerDatum[]` | `[]` | |
|
|
65
|
+
| `routes` | `MapRouteDatum[]` | `[]` | |
|
|
66
|
+
| `arcs` | `MapArcGroup[]` | `[]` | |
|
|
67
|
+
| `geojson` | `MapGeoJSONLayer[]` | `[]` | |
|
|
68
|
+
| `clusters` | `MapClusterGroup[]` | `[]` | |
|
|
69
|
+
| `heatmaps` | `MapHeatmapLayer[]` | `[]` | |
|
|
70
|
+
| `popup` | `{ lngLat, title?, description? } \| null` | closed | Standalone popup |
|
|
71
|
+
|
|
72
|
+
`MapControls` is `{ zoom?, compass?, locate?, fullscreen?, position? }`. `compass`, `locate` and `fullscreen` are off by default. `position` is `top-left`, `top-right`, `bottom-left` or `bottom-right`.
|
|
73
|
+
|
|
74
|
+
## Data layers
|
|
75
|
+
|
|
76
|
+
**Markers.** `id` (required, unique) and `lngLat` are required. Optional: `tone`, `label` (always visible), `tooltip` (on hover), `popup` (on click: `title`, `description`, `meta: [{ label, value }]`), `icon` (Lucide kebab-case name, drawn in a 28 px pin), `size` (`sm` / `md` / `lg` dots of 10 / 14 / 20 px, used when there is no icon), `pulse` (animated ring), `draggable`.
|
|
77
|
+
|
|
78
|
+
**Routes.** `id` and `coordinates: [lng, lat][]` are required. Optional: `tone`, `width` (px), `opacity`, `dashed`, `progress` (0..1: draws a progress overlay and a marker there), `progressIcon` (default `navigation`), `progressMarker` (default `true`; set `false` when you track the vehicle with your own marker), `progressTone` (colour of the travelled part, default `primary`), `active` (emphasise the selected route), `showEndpoints` (start ring and end dot). Change one route with `updateRoute` (see [Live data](#live-data--in-place-updates)).
|
|
79
|
+
|
|
80
|
+
**Arcs.** Grouped. A group has `id`, `tone`, `width`, `curvature` and `data: { id, from, to, label? }[]`. Arc ids are unique within their group.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
ui.map({
|
|
84
|
+
projection: 'globe',
|
|
85
|
+
arcs: [{ id: 'lanes', tone: 'chart-1', data: [{ id: 'lis-mad', from: [-9.14, 38.72], to: [-3.7, 40.42], label: 'Lisbon to Madrid' }] }],
|
|
86
|
+
onArcClick: (arc, group) => ui.notify(`${arc.label ?? arc.id} (${group.id})`),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**GeoJSON.** `{ id, data, fillTone?, lineTone?, fillOpacity?, choropleth?, interactive?, promoteId? }`. `data` is a FeatureCollection, a Feature, or a URL string. A choropleth is `{ property, stops: [value, tone][] }`, with at least two stops. Without a choropleth the layer uses `fillTone`. The default fill opacity is `0.6` for a choropleth and `0.25` otherwise. The server does not validate GeoJSON.
|
|
91
|
+
|
|
92
|
+
**Clusters.** Grouped points: `{ id, points: [{ id, lngLat, properties? }], tone?, clusterRadius?, clusterMaxZoom? }`. Cluster bubbles use the group tone, `chart-2` and `chart-3`.
|
|
93
|
+
|
|
94
|
+
**Heatmaps.** `{ id, points: [{ lngLat, weight? }], ramp?, radius?, intensity?, opacity?, maxWeight?, showPointsAtZoom? }`. Weights default to `1` and must be finite and at least `0`. `maxWeight` is the weight that reaches full intensity; it defaults to the largest weight. The `ramp` runs from low to high density and defaults to `['chart-3', 'chart-4', 'primary', 'destructive']`, so it follows the palette and light or dark mode. `radius` (default `20` px at zoom 9) scales with zoom. Set `showPointsAtZoom` to fade the heatmap into individual circles when zoomed in. Replace the data with `setHeatmaps(layers)`; the client updates the source in place.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
const heat = ui.map({ projection: 'globe', heatmaps: [{ id: 'quakes', points, maxWeight: 7, showPointsAtZoom: 6 }] });
|
|
98
|
+
heat.setHeatmaps([{ id: 'quakes', points: points.filter((p) => (p.weight ?? 0) >= 5) }]);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Standalone popup.** `showPopup([lng, lat], { title?, description? })` opens one, and `closePopup()` closes it. The `popup` prop does the same declaratively. Only marker popups take `meta` rows.
|
|
102
|
+
|
|
103
|
+
## Camera
|
|
104
|
+
|
|
105
|
+
| Method | Behaviour |
|
|
106
|
+
|--------|-----------|
|
|
107
|
+
| `flyTo(center, zoom?, { duration? })` | Animates. Keeps the current zoom when `zoom` is omitted |
|
|
108
|
+
| `fitBounds([sw, ne], { padding? })` | Fits the given bounds |
|
|
109
|
+
| `fitBounds('markers', { padding? })` | Fits all current markers. Does nothing when there are none |
|
|
110
|
+
| `jumpTo(center, zoom?)` | Moves without animation |
|
|
111
|
+
| `setProjection('mercator' \| 'globe')` | Sends a new `projection` |
|
|
112
|
+
|
|
113
|
+
Each camera call runs once on the client. The server attaches a nonce, so repeating a call moves the map again.
|
|
114
|
+
|
|
115
|
+
The client reads `center`, `zoom`, `pitch`, `bearing` and `interactive` once, at mount. Changing those props later does not move an existing map. Use the camera methods instead.
|
|
116
|
+
|
|
117
|
+
## Events
|
|
118
|
+
|
|
119
|
+
Handlers are registered only when you pass them. Each handler runs on the server, and receives:
|
|
120
|
+
|
|
121
|
+
| Handler | Fires when | Argument |
|
|
122
|
+
|---------|------------|----------|
|
|
123
|
+
| `onMarkerClick(marker)` | A marker is clicked | The current marker datum |
|
|
124
|
+
| `onMarkerDragEnd(marker, lngLat)` | A draggable marker is dropped | The updated marker and its new `lngLat`. The stored position is updated first |
|
|
125
|
+
| `onRouteClick(route)` | A route is clicked | The current route datum |
|
|
126
|
+
| `onArcClick(arc, group)` | An arc is clicked | The arc and its group |
|
|
127
|
+
| `onFeatureClick(feature)` | A GeoJSON feature is clicked | `{ layerId, featureId?, properties }` |
|
|
128
|
+
| `onClusterPointClick(point, group)` | A clustered point is clicked | The point and its group |
|
|
129
|
+
| `onMapClick(lngLat)` | The map is clicked | `[lng, lat]` |
|
|
130
|
+
| `onViewportChange(viewport)` | The camera stops moving (`moveend`) | `{ center, zoom, bearing, pitch }` |
|
|
131
|
+
| `onLocate(lngLat)` | The locate control is used (needs `controls.locate`) | `[lng, lat]` |
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
`markerDragEnd` is always recorded on the server, even without `onMarkerDragEnd`, so a later `fitBounds('markers')` uses the dropped position.
|
|
135
|
+
|
|
136
|
+
## Live data & in-place updates
|
|
137
|
+
|
|
138
|
+
The map owns its marker, route, arc, cluster and popup state. Each setter sends only the prop it changes, so the client patches the map instead of rebuilding it. Markers are keyed by `id`: when a marker's `lngLat` changes, it moves, and its DOM node stays.
|
|
139
|
+
|
|
140
|
+
| Method | What it does |
|
|
141
|
+
|--------|--------------|
|
|
142
|
+
| `setMarkers(list)` | Replaces all markers |
|
|
143
|
+
| `upsertMarker(marker)` | Inserts, or replaces the marker with the same id |
|
|
144
|
+
| `removeMarker(id)` | Removes a marker. No-op if absent |
|
|
145
|
+
| `setRoutes(list)` / `updateRoute(id, patch)` | Replaces all routes, or merges `patch` into one. The id cannot change |
|
|
146
|
+
| `setArcs(groups)` / `setGeoJSON(layers)` / `setClusters(groups)` / `setHeatmaps(layers)` | Replace that layer type |
|
|
147
|
+
|
|
148
|
+
A courier moving along a route:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
ui.timer(0.25, () => {
|
|
152
|
+
progress = Math.min(1, progress + 0.006);
|
|
153
|
+
map.updateRoute('delivery-route', { progress });
|
|
154
|
+
map.upsertMarker({ id: 'courier', lngLat: pointAt(progress), tone: 'chart-2', icon: 'truck' });
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Validation** runs when you create the map or call a setter, before anything is sent. It throws on:
|
|
159
|
+
|
|
160
|
+
- a missing or empty id, or a duplicate id within its list (markers, routes, arc groups, arcs in a group, cluster groups, points in a group). The message is `duplicate id "…"`.
|
|
161
|
+
- a coordinate that is not `[lng, lat]` with two finite numbers. This covers markers, cluster points, route coordinates, arc endpoints, popups, `flyTo` and `jumpTo` centres, and `fitBounds` corners.
|
|
162
|
+
|
|
163
|
+
## Layout
|
|
164
|
+
|
|
165
|
+
`height` gives a fixed-size map in normal flow:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
ui.card({ title: 'Stores', className: 'p-0 overflow-hidden' }, () => {
|
|
169
|
+
ui.map({ height: '26rem', center: [-6, 40], zoom: 5 });
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`fill: true` makes the map fill its parent (`h-full min-h-0 flex-1`), so the parent needs a definite height. On a page that fills the window, put `fillViewport` on the outer column and `grow` on the panel that holds the map:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
ui.column({ fillViewport: true, gap: 4 }, () => {
|
|
177
|
+
ui.row({ grow: true, wrap: false, gap: 4 }, () => {
|
|
178
|
+
ui.card({ title: 'Stores', scroll: true, className: 'w-80 shrink-0' }, () => { /* list */ });
|
|
179
|
+
ui.column({ grow: true }, () => {
|
|
180
|
+
ui.map({ fill: true, center: [-6, 40], zoom: 5 });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [API reference: Filling the page](./api.md#filling-the-page).
|
|
187
|
+
|
|
188
|
+
## Tiles
|
|
189
|
+
|
|
190
|
+
By default the basemap uses CARTO's hosted styles: Positron for light and Dark Matter for dark, served from `basemaps.cartocdn.com`. Those styles are built on OpenStreetMap data. CARTO's free tier and terms apply, and OpenStreetMap data requires attribution.
|
|
191
|
+
|
|
192
|
+
Before production, check CARTO's current terms and attribution rules, or pass your own styles. Any MapLibre style URL works:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
ui.map({ styles: { light: 'https://your-host.example/light.json', dark: 'https://your-host.example/dark.json' } });
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
[OpenFreeMap](https://openfreemap.org) publishes free style URLs. Check its terms before you rely on it.
|
|
199
|
+
|
|
200
|
+
## Demos
|
|
201
|
+
|
|
202
|
+
All five are under the **Maps** group in the sidebar.
|
|
203
|
+
|
|
204
|
+
- [`/examples/maps`](../apps/demo/src/examples/MapsGallery.ts): markers and popups, routes, arcs, a choropleth, clusters, a seismic heatmap on a globe, and a compact analytics map.
|
|
205
|
+
- [`/examples/delivery-tracker`](../apps/demo/src/examples/DeliveryTracker.ts): a courier moving along a route, with `updateRoute` and `upsertMarker` on a timer.
|
|
206
|
+
- [`/examples/store-locator`](../apps/demo/src/examples/StoreLocator.ts): a filterable store list where list rows and markers select each other.
|
|
207
|
+
- [`/examples/globe`](../apps/demo/src/examples/CommunityGlobe.ts): a globe with arcs and markers that refresh every 30 seconds.
|
|
208
|
+
- [`/examples/uptime`](../apps/demo/src/examples/UptimeMonitor.ts): a status globe of edge locations, refreshed every 2 seconds.
|
|
209
|
+
|
|
210
|
+
## See also
|
|
211
|
+
|
|
212
|
+
- [Updating in place](./updating-in-place.md): the general rules for patching instead of rebuilding
|
|
213
|
+
- [API reference](./api.md#uimapprops): `ui.map` in the Layout section
|
|
214
|
+
- [Examples](./examples.md): the demo catalog
|
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.19",
|
|
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.19",
|
|
41
|
+
"@close-by/clay-core": "0.2.19",
|
|
42
|
+
"@close-by/clay-components": "0.2.19",
|
|
43
|
+
"@close-by/clay-server": "0.2.19",
|
|
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,
|
|
@@ -112,6 +113,9 @@ import {
|
|
|
112
113
|
list as listFactory,
|
|
113
114
|
imageCrop as imageCropFactory,
|
|
114
115
|
gantt as ganttFactory,
|
|
116
|
+
map as mapFactory,
|
|
117
|
+
type MapElement,
|
|
118
|
+
type MapProps,
|
|
115
119
|
chart as chartNamespace,
|
|
116
120
|
table as tableFactory,
|
|
117
121
|
flow as flowFactory,
|
|
@@ -242,6 +246,8 @@ import {
|
|
|
242
246
|
type LineChartStyleProps,
|
|
243
247
|
type SparklineProps,
|
|
244
248
|
type CardProps,
|
|
249
|
+
type GridProps,
|
|
250
|
+
type LayoutProps,
|
|
245
251
|
type DialogProps,
|
|
246
252
|
type DialogStackProps,
|
|
247
253
|
type DialogStackStepOptions,
|
|
@@ -343,6 +349,14 @@ import {
|
|
|
343
349
|
type GanttDependency,
|
|
344
350
|
type GanttRange,
|
|
345
351
|
type GanttItemMovePayload,
|
|
352
|
+
type MapMarkerDatum,
|
|
353
|
+
type MapRouteDatum,
|
|
354
|
+
type MapArcGroup,
|
|
355
|
+
type MapGeoJSONLayer,
|
|
356
|
+
type MapClusterGroup,
|
|
357
|
+
type MapHeatmapLayer,
|
|
358
|
+
type LngLat,
|
|
359
|
+
type MapTone,
|
|
346
360
|
type FlowProps,
|
|
347
361
|
type FlowNodeProps,
|
|
348
362
|
type FlowNodeKind,
|
|
@@ -452,6 +466,7 @@ import {
|
|
|
452
466
|
timer as timerCore,
|
|
453
467
|
storage as storageCore,
|
|
454
468
|
theme as themeCore,
|
|
469
|
+
sound as soundCore,
|
|
455
470
|
reactive as reactiveCore,
|
|
456
471
|
state as stateCore,
|
|
457
472
|
subscribe as subscribeCore,
|
|
@@ -471,6 +486,7 @@ import {
|
|
|
471
486
|
TimerHandle,
|
|
472
487
|
type TimerOptions,
|
|
473
488
|
type ThemeMode,
|
|
489
|
+
type ThemePalette,
|
|
474
490
|
type DraftStorage,
|
|
475
491
|
type DraftOptions,
|
|
476
492
|
type UrlStateOptions,
|
|
@@ -512,6 +528,8 @@ export {
|
|
|
512
528
|
|
|
513
529
|
|
|
514
530
|
export type {
|
|
531
|
+
GridProps,
|
|
532
|
+
LayoutProps,
|
|
515
533
|
ButtonProps,
|
|
516
534
|
IconButtonProps,
|
|
517
535
|
ButtonVariant,
|
|
@@ -636,6 +654,15 @@ export type {
|
|
|
636
654
|
GanttDependency,
|
|
637
655
|
GanttRange,
|
|
638
656
|
GanttItemMovePayload,
|
|
657
|
+
MapProps,
|
|
658
|
+
MapMarkerDatum,
|
|
659
|
+
MapRouteDatum,
|
|
660
|
+
MapArcGroup,
|
|
661
|
+
MapGeoJSONLayer,
|
|
662
|
+
MapClusterGroup,
|
|
663
|
+
MapHeatmapLayer,
|
|
664
|
+
LngLat,
|
|
665
|
+
MapTone,
|
|
639
666
|
FlowProps,
|
|
640
667
|
FlowNodeProps,
|
|
641
668
|
FlowNodeKind,
|
|
@@ -698,6 +725,7 @@ export type {
|
|
|
698
725
|
NotifyType,
|
|
699
726
|
ToastPosition,
|
|
700
727
|
ThemeMode,
|
|
728
|
+
ThemePalette,
|
|
701
729
|
PageMeta,
|
|
702
730
|
NavFromPagesOptions,
|
|
703
731
|
PageOptions,
|
|
@@ -1173,6 +1201,15 @@ export function gantt(props?: GanttProps): GanttElement {
|
|
|
1173
1201
|
return ganttFactory(props);
|
|
1174
1202
|
}
|
|
1175
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* Live map (MapLibre, styled to the Clay theme) with markers, routes, arcs,
|
|
1206
|
+
* GeoJSON layers, clusters and a popup. Owns its marker / route / popup /
|
|
1207
|
+
* camera state; setters update in place. See {@link MapProps}.
|
|
1208
|
+
*/
|
|
1209
|
+
export function map(props?: MapProps): MapElement {
|
|
1210
|
+
return mapFactory(props);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1176
1213
|
/**
|
|
1177
1214
|
* Interactive flow diagram (`@xyflow/react`). Owns edges + positions;
|
|
1178
1215
|
* node bodies are Clay trees; drag by card chrome (controls are nodrag);
|
|
@@ -1255,33 +1292,43 @@ export function sparkline(props: SparklineProps): Element {
|
|
|
1255
1292
|
}
|
|
1256
1293
|
|
|
1257
1294
|
/** Horizontal flex row (`fn` or props-first). Prefer `ui.row`. */
|
|
1258
|
-
export function row(fn: () => void, props?:
|
|
1259
|
-
export function row(props:
|
|
1295
|
+
export function row(fn: () => void, props?: LayoutProps): Element;
|
|
1296
|
+
export function row(props: LayoutProps, fn: () => void): Element;
|
|
1260
1297
|
export function row(a: any, b?: any): Element {
|
|
1261
1298
|
return rowFactory(a, b);
|
|
1262
1299
|
}
|
|
1263
1300
|
|
|
1264
1301
|
/** Vertical flex column (`fn` or props-first). Prefer `ui.column`. */
|
|
1265
|
-
export function column(fn: () => void, props?:
|
|
1266
|
-
export function column(props:
|
|
1302
|
+
export function column(fn: () => void, props?: LayoutProps): Element;
|
|
1303
|
+
export function column(props: LayoutProps, fn: () => void): Element;
|
|
1267
1304
|
export function column(a: any, b?: any): Element {
|
|
1268
1305
|
return columnFactory(a, b);
|
|
1269
1306
|
}
|
|
1270
1307
|
|
|
1271
1308
|
/** Layout container (`fn` or props-first). Prefer `ui.container`. */
|
|
1272
|
-
export function container(fn: () => void, props?:
|
|
1273
|
-
export function container(props:
|
|
1309
|
+
export function container(fn: () => void, props?: LayoutProps): Element;
|
|
1310
|
+
export function container(props: LayoutProps, fn: () => void): Element;
|
|
1274
1311
|
export function container(a: any, b?: any): Element {
|
|
1275
1312
|
return containerFactory(a, b);
|
|
1276
1313
|
}
|
|
1277
1314
|
|
|
1278
1315
|
/** Hero section (`fn` or props-first). Prefer `ui.hero`. */
|
|
1279
|
-
export function hero(fn: () => void, props?:
|
|
1280
|
-
export function hero(props:
|
|
1316
|
+
export function hero(fn: () => void, props?: LayoutProps): Element;
|
|
1317
|
+
export function hero(props: LayoutProps, fn: () => void): Element;
|
|
1281
1318
|
export function hero(a: any, b?: any): Element {
|
|
1282
1319
|
return heroFactory(a, b);
|
|
1283
1320
|
}
|
|
1284
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Responsive grid that fills the width (`fn` or props-first). Prefer `ui.grid`.
|
|
1324
|
+
* `minColWidth` auto-fits columns (default 16rem); `cols` fixes the count. See {@link GridProps}.
|
|
1325
|
+
*/
|
|
1326
|
+
export function grid(fn: () => void, props?: GridProps): Element;
|
|
1327
|
+
export function grid(props: GridProps, fn: () => void): Element;
|
|
1328
|
+
export function grid(a: any, b?: any): Element {
|
|
1329
|
+
return gridFactory(a, b);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1285
1332
|
/** Card with optional header/footer (`fn` or props-first). Prefer `ui.card`. See {@link CardProps}. */
|
|
1286
1333
|
export function card(fn: (card: Element) => void, props?: CardProps): Element;
|
|
1287
1334
|
export function card(props: CardProps, fn: (card: Element) => void): Element;
|
|
@@ -1660,6 +1707,9 @@ export const storage = storageCore;
|
|
|
1660
1707
|
/** Server-driven appearance (`light` | `dark` | `system`). */
|
|
1661
1708
|
export const theme = themeCore;
|
|
1662
1709
|
|
|
1710
|
+
/** Interface sound cues: `ui.sound.set(on)`, `ui.sound.volume(0..1)`, `ui.sound.play(name)`. */
|
|
1711
|
+
export const sound = soundCore;
|
|
1712
|
+
|
|
1663
1713
|
/** Proxy that notifies subscribers on property change. Prefer this over importing from `@close-by/clay-core`. */
|
|
1664
1714
|
export const reactive = reactiveCore;
|
|
1665
1715
|
|
|
@@ -1907,6 +1957,7 @@ export const ui = {
|
|
|
1907
1957
|
list,
|
|
1908
1958
|
imageCrop,
|
|
1909
1959
|
gantt,
|
|
1960
|
+
map,
|
|
1910
1961
|
flow,
|
|
1911
1962
|
stat,
|
|
1912
1963
|
dataTable,
|
|
@@ -1925,6 +1976,7 @@ export const ui = {
|
|
|
1925
1976
|
row,
|
|
1926
1977
|
column,
|
|
1927
1978
|
container,
|
|
1979
|
+
grid,
|
|
1928
1980
|
hero,
|
|
1929
1981
|
card,
|
|
1930
1982
|
app,
|
|
@@ -1973,6 +2025,7 @@ export const ui = {
|
|
|
1973
2025
|
timer,
|
|
1974
2026
|
storage,
|
|
1975
2027
|
theme,
|
|
2028
|
+
sound,
|
|
1976
2029
|
reactive,
|
|
1977
2030
|
state,
|
|
1978
2031
|
urlState,
|