tabulator-awareness-module
v1.0.1
Published
Tabulator module that add collaborative awareness (realtime remote user focus state), compatible with Hocuspocus/Yjs or other custom providers.
Maintainers
Readme
tabulator-awareness-module
A Tabulator 6.x module that adds collaborative awareness to a table: it shows which user is focused on which cell in real time.
Written in TypeScript, published as a plain JavaScript package (ESM + CJS).
Installation
npm install tabulator-awareness-module tabulator-tablestabulator-tables (^6.0.0) is a peer dependency, so you control which Tabulator version you use.
How it works
The module never depends on yjs, y-protocols, or @hocuspocus/provider at runtime. Instead it defines a minimal structural interface, AwarenessLike, matching the subset of the y-protocols/awareness API it actually needs (getStates, setLocalState, setLocalStateField, on, off, clientID). Any Yjs-compatible provider - Hocuspocus, y-websocket, y-webrtc, or a provider you wrote yourself - already exposes an .awareness object matching this shape.
You choose how to supply it with a small discriminated config:
type AwarenessProviderConfig =
| { type: "hocuspocus"; provider: { awareness: AwarenessLike } }
| { type: "custom"; awareness: AwarenessLike };Both branches resolve to the same AwarenessLike internally; the "hocuspocus" variant is just clearer at the call site when you already hold a HocuspocusProvider instance.
Usage with Hocuspocus
import { Tabulator } from "tabulator-tables";
import { AwarenessModule } from "tabulator-awareness-module";
import { HocuspocusProvider } from "@hocuspocus/provider";
Tabulator.registerModule(AwarenessModule);
const provider = new HocuspocusProvider({
url: "wss://your-hocuspocus-server",
name: "my-document",
});
const table = new Tabulator("#table", {
index: "id", // the field used as each row's stable identity
data: myData,
columns: myColumns,
awareness: {
user: {
id: currentUser.id,
name: currentUser.name,
fullName: currentUser.fullName,
color: currentUser.color,
},
provider: { type: "hocuspocus", provider },
},
});Usage with a custom provider
Any object exposing a y-protocols-compatible awareness works, for example a raw y-websocket or y-webrtc provider, or your own implementation:
import { Tabulator } from "tabulator-tables";
import { AwarenessModule } from "tabulator-awareness-module";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";
Tabulator.registerModule(AwarenessModule);
const doc = new Y.Doc();
const provider = new WebsocketProvider("wss://your-server", "my-document", doc);
const table = new Tabulator("#table", {
index: "id",
data: myData,
columns: myColumns,
awareness: {
user: { id: currentUser.id, fullName: currentUser.fullName, name: currentUser.name },
provider: { type: "custom", awareness: provider.awareness },
},
});Options
| Option | Type | Required | Description |
|-----------------------| --- |----------|-------------------------------------------------------------------------------------------------------------------------------------------|
| user.id | string | yes | Stable identifier for the local user. Used to derive a deterministic color when user.color is omitted. |
| user.name | string | no | Short label rendered inside the user's badge (e.g. "JD"). |
| user.fullName | string | no | Full name displayed in place of the short name when hovering the cell. |
| user.color | string | no | CSS color for this user's badge and cell border. Auto-generated from user.id when omitted. |
| provider | AwarenessProviderConfig | yes | Either { type: "hocuspocus", provider } or { type: "custom", awareness }. |
| clearStateOnDestroy | boolean | no | Clears the local awareness state when the table is destroyed. Defaults to true. |
| heartbeatIntervalMs | number | no | How often (ms) the local client republishes a heartbeat. Defaults to 20000. |
| staleTimeoutMs | number | no | How long (ms) a peer's heartbeat may go without an update before its focus is hidden. Defaults to 3 * heartbeatIntervalMs. |
| debug | boolean | no | Logs local focus changes, heartbeats and remote state renders to the console, prefixed with [tabulator-awareness]. Defaults to false. |
| injectDefaultStyles | boolean | no | Injects the default stylesheet (AWARENESS_STYLE) into <head> on init. Set to false if you bundle your own CSS instead. Defaults to true. |
Rows must have a stable identity: the module reads it via Tabulator's own index option (defaults to "id"), through row.getIndex().
Focus lifecycle and stale peer detection
Local focus is tracked purely from the editing lifecycle: cellEditing sets it, and cellEdited or cellEditCancelled clears it. A user is only shown as "on" a cell while actively editing it.
This leaves one gap: a user who closes the tab, loses their connection, or otherwise leaves without triggering cellEdited/cellEditCancelled would keep showing as focused on that cell forever. To handle this, the module maintains a heartbeat:
- On an interval (
heartbeatIntervalMs), each client republishes aheartbeatAttimestamp on its own local awareness state. - On the same interval, each client re-evaluates every remote peer's
heartbeatAt. Any peer whose heartbeat is older thanstaleTimeoutMshas its focus hidden, even if the underlying awareness entry is still technically present.
This check runs entirely on the client, for two reasons: this library only runs in the browser (it has no access to your Hocuspocus/y-websocket server process), and the "custom" provider path accepts any object satisfying AwarenessLike, which may or may not implement its own peer-timeout logic. Note that if you use a real Yjs backend (Hocuspocus, y-websocket, y-webrtc), y-protocols/awareness already prunes fully disconnected clients from getStates() on its own after about 30 seconds; this module's heartbeat is a faster, provider-agnostic safety net on top of that, and the only mechanism available at all for a "custom" provider that doesn't implement such pruning itself.
Querying a cell's state
The module exposes a table function, getCellAwareness(rowId, field), returning the list of remote users (if any) currently focused on a given cell:
const focuses = table.getCellAwareness(rowId, field);
// [{ clientId: 42, user: { id: "u2", fullName: "User Two", name: "UT" } }]An empty array means nobody else is on that cell. This is the hook to use for blocking a concurrent edit, typically from a column's editable callback:
const table = new Tabulator("#table", {
index: "id",
data: myData,
columns: [
{
field: "name",
editable: (cell) => table.getCellAwareness(cell.getRow().getIndex(), cell.getField()).length === 0,
},
],
awareness: {
user: { id: currentUser.id, fullName: currentUser.fullName, name: currentUser.name },
provider: { type: "custom", awareness: provider.awareness },
},
});With this in place, Tabulator itself refuses to enter edit mode on a cell someone else is already editing, instead of letting the user start typing and only failing later.
Styling
A small default stylesheet is injected once into <head> on module initialization (cell border + stacked user badges). It's also exported as a raw string if you'd rather bundle it yourself:
import { AWARENESS_STYLE } from "tabulator-awareness-module";Set injectDefaultStyles: false to skip the automatic injection entirely, for example if you're including AWARENESS_STYLE in your own bundled stylesheet, or shipping a fully custom look:
awareness: {
user: { id: currentUser.id, fullName: currentUser.fullName, name: currentUser.name },
provider: { type: "custom", awareness: provider.awareness },
injectDefaultStyles: false,
}The relevant class names are tabulator-awareness-cell, tabulator-awareness-badges and tabulator-awareness-badge, and the border color is driven by the --tabulator-awareness-color CSS custom property, so you can override the look with your own CSS.
Each badge shows the user's short name (tabulator-awareness-badge-name) by default. Hovering the cell swaps it for the full name (tabulator-awareness-badge-fullname) via a plain CSS rule (.tabulator-awareness-cell:hover).
Current limitations
Since focus is tied to editing, a user simply viewing or selecting a cell without editing it is not shown as focused. This is intentional: awareness reflects who is editing what, not who last clicked where.
License
MIT
