Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bright-users-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@playhtml/common": minor
"playhtml": minor
"@playhtml/react": minor
---

Add the element `live`, `users`, `setLive`, and `update` APIs so one renderer can combine shared state with current per-user values and identity. Existing awareness and `updateElement` names remain available as deprecated compatibility aliases.
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/advanced/dynamic-elements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ document.body.appendChild(note);

playhtml.register(note, {
defaultData: { text: "" },
updateElement: ({ element, data }) => {
update: ({ element, data }) => {
element.textContent = data.text;
},
});
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/advanced/merging-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ setData((draft) => {

## Reactive callbacks

The riskiest conflict is a callback that reads shared data, writes shared data, and re-runs when that same data changes. React effects, subscriptions, and vanilla `updateElement` callbacks can all do this.
The riskiest conflict is a callback that reads shared data, writes shared data, and re-runs when that same data changes. React effects, subscriptions, and vanilla `update` callbacks can all do this.

```tsx
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/advanced/mirror-playground.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This page hosts the common edge cases, one section each. Open it in two browser
- direct child additions, removals, and reorders on the mirrored element itself;
- form state inside the mirrored element when inputs fire input/change events;
- contenteditable changes when the browser reports them through input events;
- hover and focus awareness through `data-playhtml-hover` and `data-playhtml-focus`.
- live hover and focus state through `data-playhtml-hover` and `data-playhtml-focus`.

`can-mirror` does not update:

Expand Down
12 changes: 6 additions & 6 deletions apps/docs/src/content/docs/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ import { CanHoverElement } from "@playhtml/react";
</TabItem>
</Tabs>

If you want the hover effect to reflect *who* is hovering (e.g. tint the element with each viewer's cursor color) rather than a plain on/off, read the hover roster off element awareness. Awareness is presence scoped to one element, so it clears when readers leave and does not persist:
If you want the hover effect to reflect *who* is hovering (for example, tint the element with each viewer's color) rather than a plain on/off, render the element's `users`. Live user data clears when readers leave and does not persist:

```tsx
import { CanPlayElement } from "@playhtml/react";
Expand All @@ -321,12 +321,12 @@ import { TagType } from "playhtml";
tagInfo={[TagType.CanHover]}
id="hover-pad"
defaultData={{}}
myDefaultAwareness={"#3b82f6"}
live={{ hovering: false }}
>
{({ awareness }) => (
{({ users }) => (
<div
style={{
background: `linear-gradient(45deg, ${awareness.join(", ")})`,
background: `linear-gradient(45deg, ${users.map(({ user }) => user.color).join(", ")})`,
}}
>
hover me
Expand Down Expand Up @@ -409,7 +409,7 @@ Full treatment with live demos lives on **[Custom elements → can-mirror](/docs

## can-play

Build your own capability: you define a shared `data` shape and how the element renders from it (an imperative `updateElement`, or the newer reactive `view`). You control how the data drives the element and which parts persist. Use it for custom counters, guestbooks, chat, games, reactions, per-user state, and event broadcasts — anything the built-ins don't cover.
Build your own capability: you define a shared `data` shape and how the element renders from it (an imperative `update`, or the newer reactive `view`). You control how the data drives the element and which parts persist. Use it for custom counters, guestbooks, chat, games, reactions, per-user state, and event broadcasts — anything the built-ins don't cover.

Full guide with live demos: **[Custom elements](/docs/custom-elements/)**. Property reference: **[Element API](/docs/reference/element-api/)**.

Expand All @@ -423,7 +423,7 @@ These capabilities are the building blocks. The best way to see them in concert
<a class="ph-composed-card" href="https://playhtml.fun/experiments/4/" target="_blank" rel="noopener">
<span class="ph-composed-card__kicker">EXPERIMENT · 04</span>
<span class="ph-composed-card__title">Every color</span>
<span class="ph-composed-card__body">A page where every visitor adds one color. can-play + element awareness + an ever-growing shared palette.</span>
<span class="ph-composed-card__body">A page where every visitor adds one color. can-play + element users + an ever-growing shared palette.</span>
</a>
<a class="ph-composed-card" href="https://playhtml.fun/fridge" target="_blank" rel="noopener">
<span class="ph-composed-card__kicker">COMMUNITY · FRIDGE</span>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Once you can reach for the right one, everything else is just attribute names.

- **Element data** (`defaultData` / [`can-play`](/docs/capabilities/)): persistent state scoped to a single DOM element. A toggle's on/off, a draggable's position, a shared count. Survives reload. See [data essentials](/docs/data/data-essentials/) for shape, updates, and cleanup.
- **Page data** (`playhtml.createPageData`): persistent state keyed by a name, not tied to any element. A page-level counter, a shared prompt, an open vote. See [page-level data](/docs/data/page-data/).
- **Presence** (`playhtml.presence`, cursors, element awareness): ephemeral per-user state: "who's online", "who's typing", "where's my cursor". Element APIs call this same kind of state _awareness_ when it is scoped to one element. Disappears when users disconnect. See [presence](/docs/data/presence/) and [cursors](/docs/data/presence/cursors/).
- **Users** (`playhtml.users`, cursors, element `live` data): identity plus ephemeral per-user state: "who's online", "who's typing", "where's my cursor". Element callbacks join each user's identity with their element-specific value in `users`. Disappears when users disconnect. See [presence](/docs/data/presence/) and [cursors](/docs/data/presence/cursors/).
- **Events** (`playhtml.dispatchPlayEvent`): one-off broadcasts with no persisted state. Confetti, chimes, notifications. See [events](/docs/data/events/).

Not sure which one you want? The [decision table on data essentials](/docs/data/data-essentials/#when-to-use-which-primitive) lays out the tradeoffs side by side.
53 changes: 27 additions & 26 deletions apps/docs/src/content/docs/custom-elements.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Custom elements"
description: "Build custom collaborative elements with can-mirror, register, updateElement, view, or React shared state."
description: "Build custom collaborative elements with can-mirror, register, update, view, or React shared state."
sidebar:
order: 5
---
Expand Down Expand Up @@ -116,12 +116,12 @@ If a mirrored child also has its own playhtml capability, keep giving it a stabl

### can-play

Register an element with its starting data, event handlers, and renderer in one initializer object. Pass the element itself when you already have it, or pass its id before it exists. Both `playhtml.register(element, initializer)` and `playhtml.register(id, initializer)` work before or after `playhtml.init()`. playhtml syncs the data and calls `updateElement` whenever it changes.
Register an element with its starting data, event handlers, and renderer in one initializer object. Pass the element itself when you already have it, or pass its id before it exists. Both `playhtml.register(element, initializer)` and `playhtml.register(id, initializer)` work before or after `playhtml.init()`. playhtml calls `update` whenever shared data or a user's live value changes.

The [Element API reference](/docs/reference/element-api/#initializer) lists every initializer field and callback context.

:::note[Existing property-based elements]
Direct assignments such as `element.defaultData = …` and `element.updateElement = …` remain supported for compatibility. Use `register` for new vanilla elements.
Direct assignments such as `element.defaultData = …` and `element.updateElement = …` remain supported for compatibility. Use `register` with `update` for new vanilla elements.
:::

Most capabilities can share the same element. Each keeps its own data and behavior, so a registered element can also carry `can-move`. Watch out for style conflicts: two capabilities can still fight if they write the same CSS property. For example, `can-move` and `can-spin` both update `transform`, so the last update wins instead of combining translate and rotate.
Expand All @@ -141,7 +141,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi
data.count += 1;
});
},
updateElement: ({ element, data }) => {
update: ({ element, data }) => {
element.textContent = String(data.count);
},
});
Expand Down Expand Up @@ -169,7 +169,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi

#### State to style

`updateElement` runs on every change, so the simplest pattern is "read `data`, set a class or style."
`update` runs on every change, so the simplest pattern is "read `data`, set a class or style."

```html
<button id="switch">off</button>
Expand All @@ -184,7 +184,7 @@ Most capabilities can share the same element. Each keeps its own data and behavi
data.on = !data.on;
});
},
updateElement: ({ element, data }) => {
update: ({ element, data }) => {
element.classList.toggle("on", data.on);
element.textContent = data.on ? "on" : "off";
},
Expand All @@ -204,9 +204,9 @@ Most capabilities can share the same element. Each keeps its own data and behavi
<InteractiveToggleDemo client:only="react" />
</div>

#### Presence and per-user state
#### Users and live per-user state

`myDefaultAwareness` seeds a per-user ephemeral value; `setMyAwareness` broadcasts it; `updateElementAwareness` renders everyone's. Awareness does **not** persist — it's "who's here right now."
`live` is your value for this element. It does not persist. `setLive` changes it, and every user's current value appears in `users` beside their identity. The same `update` function renders shared `data` and live user changes.

```html
<div id="online"></div>
Expand All @@ -215,13 +215,14 @@ Most capabilities can share the same element. Each keeps its own data and behavi
import { playhtml } from "playhtml";

playhtml.register("online", {
myDefaultAwareness: "#2563eb",
updateElementAwareness: ({ element, awareness }) => {
live: { status: "here" },
update: ({ element, users }) => {
element.replaceChildren(
...awareness.map((color) => {
...users.map(({ user }) => {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = color;
dot.style.background = user.color;
dot.title = user.name ?? "Anonymous";
return dot;
}),
);
Expand Down Expand Up @@ -250,7 +251,7 @@ Put `resetShortcut` in the initializer so shift-clicking (or ctrl/alt/meta) rese
playhtml.register("counter", {
defaultData: { count: 0 },
resetShortcut: "shiftKey",
updateElement: ({ element, data }) => {
update: ({ element, data }) => {
element.textContent = String(data.count);
},
});
Expand All @@ -261,19 +262,19 @@ playhtml.register("counter", {
`register` can run before the element exists and binds it when it appears. For dynamically added built-in or defined capabilities, call `playhtml.setupPlayElement(element)` after insertion. Use [`selector-id`](/docs/advanced/dynamic-elements/) for many-of-a-kind elements.

:::note[Lists and data-driven UIs get verbose here]
With `updateElement` you hand-build and diff the DOM yourself, which is painful for lists, chat, and anything computed from a collection. That's exactly what the [reactive view API](#reactive-view-api-experimental) below is for.
With `update` you hand-build and diff the DOM yourself, which is painful for lists, chat, and anything computed from a collection. That's exactly what the [reactive view API](#reactive-view-api-experimental) below is for.
:::

### Reactive view API (experimental)

<Aside type="caution" title="Experimental">
The `view` renderer and lit-html helpers are experimental. `register`,
`define`, handles, and the imperative `updateElement` renderer are supported.
`define`, handles, and the imperative `update` renderer are supported.
Trying `view`? Tell us how it goes in
[#95](https://github.com/spencerc99/playhtml/issues/95).
</Aside>

Instead of hand-writing DOM updates in `updateElement`, you can register a **`view`**: a pure function from state to a [lit-html](https://lit.dev/docs/libraries/standalone-templates/) template. playhtml patches only what changed when the data updates. It's the vanilla equivalent of what React's `withSharedState` already gives you.
Instead of hand-writing DOM updates in `update`, you can register a **`view`**: a pure function from state to a [lit-html](https://lit.dev/docs/libraries/standalone-templates/) template. playhtml patches only what changed when the data updates. It's the vanilla equivalent of what React's `withSharedState` already gives you.

```ts
import { playhtml, html, svg, repeat, classMap, styleMap, nothing } from "playhtml";
Expand Down Expand Up @@ -302,7 +303,7 @@ The element is an empty mount point; everything you see is rendered from state.
</script>
```

`register` returns a **handle** (`getElement`, `getData`, `setData`, `setLocalData`, `setMyAwareness`, `requestUpdate`, `unregister`) for reads/writes from outside the view.
`register` returns a **handle** (`getElement`, `getData`, `setData`, `setLocalData`, `setLive`, `requestUpdate`, `unregister`) for reads/writes from outside the view.

#### Local UI state

Expand All @@ -323,7 +324,7 @@ playhtml.register("panel", {

#### Lists

This is where the view API earns its keep — `repeat(items, keyFn, template)` is lit-html's keyed list, versus hand-diffing DOM in `updateElement`. **Key by a stable unique id** (`crypto.randomUUID()`), never an index or timestamp.
This is where the view API earns its keep — `repeat(items, keyFn, template)` is lit-html's keyed list, versus hand-diffing DOM in `update`. **Key by a stable unique id** (`crypto.randomUUID()`), never an index or timestamp.

```js
const guestbook = playhtml.register("guestbook", {
Expand Down Expand Up @@ -443,14 +444,14 @@ playhtml.register("chats", {

#### Rules and gotchas (view API)

- **Renders must be pure.** Don't call `setData` / `setLocalData` / `setMyAwareness` while rendering — it's an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from `@event` handlers or `onMount`.
- **Renders must be pure.** Don't call `setData` / `setLocalData` / `setLive` while rendering — it's an infinite re-render loop. playhtml rejects such writes with a console error. Drive writes from `@event` handlers or `onMount`.
- **Key lists by a stable id**, never index or timestamp.
- **Security:** interpolated text and attribute values are auto-escaped (and `unsafeHTML` is deliberately not exported). Two sharp edges remain about a binding's *content*: `style` from a user-controlled string is CSS-injectable (prefer `styleMap`), and `href`/`src` from user data isn't URL-sanitized (validate the scheme).
- **Changing the shape of already-live data** needs a migration or a new field name — see [Data essentials](/docs/data/data-essentials/).

## React

In React there's a single, declarative path: **`withSharedState`**. It wraps a component, owns a piece of shared `data`, and hands the component `data` + `setData`. You render JSX from `data`; playhtml syncs it and re-renders on every change, local or remote. There's no `updateElement` to hand-write and no separate view API — JSX _is_ the declarative view.
In React there's a single, declarative path: **`withSharedState`**. It wraps a component and hands it shared `data`, element `live` data, and `users`. JSX re-renders on every data or user change. There's no imperative `update` to hand-write and no separate view API — JSX _is_ the declarative view.

### A click counter

Expand Down Expand Up @@ -537,17 +538,17 @@ export const Guestbook = withSharedState(

[▶ Live demo ↑](#play-guestbook)

### Presence and per-user state
### Users and live per-user state

`myDefaultAwareness` seeds a per-user ephemeral value; `setMyAwareness` broadcasts it; the `awareness` prop renders everyone's. Awareness does **not** persist — it's "who's here right now."
`live` seeds your ephemeral value; `setLive` broadcasts it; `users` renders every current user with their identity and live value.

```tsx
export const Online = withSharedState(
{ defaultData: {}, myDefaultAwareness: "#2563eb" },
({ awareness }) => (
{ defaultData: {}, live: { ready: false } },
({ users }) => (
<div className="row">
{awareness.map((color, i) => (
<span key={i} className="dot" style={{ background: color }} />
{users.map(({ user }) => (
<span key={user.pid} className="dot" style={{ background: user.color }} />
))}
</div>
),
Expand Down
Loading
Loading