Field presence engine
Audience: users.
Field presence answers “who else is on this field right now” — a form input, a table cell, a record attribute. It’s a field-oriented view of the awareness channel: the local peer declares which field it’s active on, and every peer sees which remote peers are on which field. Purpose-built for collaborative forms, tables, and admin records.
Access
Section titled “Access”const fieldPresence = room.useFieldPresence();Interface
Section titled “Interface”interface FieldPresenceEngine { setActiveField(fieldId: string | null): void; // declare the local peer's field (null to clear) getFieldPeers(fieldId: string): Peer[]; // remote peers on a field, with live presence getActiveFields(): FieldPresenceState[]; // every field with a remote peer, ordered by id subscribe(callback: (fields: FieldPresenceState[]) => void): Unsubscribe;}
interface FieldPresenceState { fieldId: string; // an app-defined id, e.g. 'user.email' or 'row-42:status' peers: Peer[]; // the remote peers on the field}setActiveField(fieldId)declares the field the local peer is editing — call it on focus, andsetActiveField(null)on blur.getFieldPeers(fieldId)andgetActiveFields()return only remote peers (you are never in your own list), resolved with live presence so you can render their name and color.subscribe(cb)fires immediately with the current fields, then on every change.
Semantics
Section titled “Semantics”- Rides awareness — field presence is stored on a reserved awareness key, so it converges over the same channel as cursors, typing, and focus. No relay change is needed.
- Ephemeral — like all presence, it reflects only connected peers and clears when a peer leaves or goes idle. It is not persisted.
- One field per peer — a peer is on at most one field at a time (the last
setActiveFieldwins), matching how focus works.
Example
Section titled “Example”Pair it with record locks for safe collaborative editing — presence shows who is looking, a lock enforces who may write:
const fieldPresence = room.useFieldPresence();const locks = room.useLocks();
input.addEventListener('focus', () => { fieldPresence.setActiveField('user.email'); void locks.acquire('user.email');});input.addEventListener('blur', () => { fieldPresence.setActiveField(null); locks.release('user.email');});
fieldPresence.subscribe((fields) => renderFieldAvatars(fields));Adapter usage
Section titled “Adapter usage”import { useFieldPresence } from '@roomful/react';
function Field({ id, label }: { id: string; label: string }): JSX.Element { const { setActiveField, getFieldPeers } = useFieldPresence(); const peers = getFieldPeers(id); return ( <label> {label} <input onBlur={() => setActiveField(null)} onFocus={() => setActiveField(id)} /> {peers.map((peer) => ( <span key={peer.id} title={peer.name ?? peer.id}> ● </span> ))} </label> );}useFieldPresence() returns { fields, setActiveField, getFieldPeers }; fields is the reactive
list of active fields, and getFieldPeers(id) reads that snapshot.
<script setup lang="ts">import { useFieldPresence } from '@roomful/vue';
const { setActiveField, getFieldPeers } = useFieldPresence();</script>
<template> <label> Email <input @blur="setActiveField(null)" @focus="setActiveField('user.email')" /> <span v-for="peer in getFieldPeers('user.email')" :key="peer.id" :title="peer.name ?? peer.id"> ● </span> </label></template>useFieldPresence() returns { fields, setActiveField, getFieldPeers }; fields is a readonly ref,
and getFieldPeers(id) reads the reactive snapshot.
Svelte
Section titled “Svelte”The roomful(...) adapter exposes fieldPresence as a readable store of FieldPresenceState[] with
setActiveField and getFieldPeers attached:
<script lang="ts"> import { roomful } from '@roomful/svelte';
const room = roomful('my-room'); const { fieldPresence } = room;</script>
<label> Email <input on:blur={() => fieldPresence.setActiveField(null)} on:focus={() => fieldPresence.setActiveField('user.email')} /> {#each fieldPresence.getFieldPeers('user.email') as peer (peer.id)} <span title={peer.name ?? peer.id}>●</span> {/each}</label>import { useFieldPresence } from '@roomful/solid';
function Field(props: { id: string }) { const { setActiveField, getFieldPeers } = useFieldPresence(); return ( <label> <input onBlur={() => setActiveField(null)} onFocus={() => setActiveField(props.id)} /> <For each={getFieldPeers(props.id)}> {(peer) => <span title={peer.name ?? peer.id}>●</span>} </For> </label> );}useFieldPresence() returns { fields, setActiveField, getFieldPeers }; fields is an accessor and
getFieldPeers(id) reads the reactive snapshot.
Angular
Section titled “Angular”injectFieldPresence() must run in an injection context and returns { fields, setActiveField, getFieldPeers }, where fields is a Signal:
import { Component } from '@angular/core';import { injectFieldPresence } from '@roomful/angular';
@Component({ selector: 'app-email-field', template: ` <input (blur)="fp.setActiveField(null)" (focus)="fp.setActiveField('user.email')" /> @for (peer of fp.getFieldPeers('user.email'); track peer.id) { <span [title]="peer.name ?? peer.id">●</span> } `,})export class EmailFieldComponent { protected readonly fp = injectFieldPresence();}