Occupancy Banner
Object-page info banner that other people are editing the record. Never locks fields.
Occupancy Banner is object chrome for “someone else is on this record.” By default it lists other editors only. Pass includeViewers for ServiceNow-style viewers. It is an info Banner (Primer card, not a pink Alert). Fields stay editable. Copy is awareness only — save conflicts live on Stale Save Dialog and Stale Version Banner. Policy: Concurrent Record Edit.
When To Use
- Show that other sessions are editing the same object (default), or viewing when you opt in with
includeViewers. - Place it under the page header, above the form. Stack order when all three are present: Stale Version Banner → Occupancy Banner → Form Error Summary.
- Do not turn occupancy into field
readOnly, a toast, or a notification-center item. - Skip the banner when the host has no occupancy channel. Stale Save Dialog still applies if
versionis present. - Heartbeat, TTL, and push channels belong to the host, not this component. The kit does not open
EventSource.
Features
| Area | Behavior |
|---|---|
| Signal | Awareness only — never changes Formily pattern; never mentions save conflicts |
| Default filter | Other editors only (kind: "edit"); viewers hidden unless includeViewers |
| Filter | Drops currentUserId; same actor keeps edit over view |
| Empty | Renders nothing when nobody else matches the filter |
| A11y | role="status" |
| Chrome | Primer info Banner (bg-card + border-border), not Alert |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/occupancy-banner.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/occupancy-banner.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/occupancy-banner.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/occupancy-banner.jsonWith a namespace: npx shadcn@latest add @f-ui/occupancy-banner.
registryDependencies: utils, fui-i18n. Runtime: lucide-react.
Usage
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
<OccupancyBanner
currentUserId={session.userId}
occupants={occupants}
/>Examples
Someone Is Editing
The current user is filtered out. Ada is editing; the field below stays writable. Conflict copy stays off this Banner.
Ada Chen is editing this record.
"use client";
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
export function OccupancyBannerDemo() {
return (
<OccupancyBanner
currentUserId="you"
occupants={[
{ id: "ada", kind: "edit", displayName: "Ada Chen" },
{ id: "you", kind: "edit", displayName: "You" },
]}
/>
);
}Viewers Only
With includeViewers, two people viewing — milder copy, still not a lock. Without that prop, viewers alone render nothing.
2 people are viewing this record.
"use client";
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
export function OccupancyBannerViewingDemo() {
return (
<OccupancyBanner
includeViewers
occupants={[
{ id: "ben", kind: "view", displayName: "Ben Park" },
{ id: "cara", kind: "view", displayName: "Cara Liu" },
]}
/>
);
}Two Sessions (Fake Backend)
Ada saves first; Ben’s next save opens Stale Save Dialog. Occupancy uses an 8s TTL.
Ada Chen
Loaded version 1
Ben Park
Loaded version 1
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/f-ui/button/button";
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
import type { Occupant } from "@/components/f-ui/occupancy-banner/occupancy-message";
import { isStaleVersionProblem } from "@/components/f-ui/stale-save-dialog/is-stale-version-problem";
import { StaleSaveDialog } from "@/components/f-ui/stale-save-dialog/stale-save-dialog";
import { StaleVersionBanner } from "@/components/f-ui/stale-save-dialog/stale-version-banner";
import { Textarea } from "@/components/ui/textarea";
type Ticket = {
notes: string;
version: string;
modifiedBy?: { displayName: string };
};
type Presence = Occupant & { expiresAt: number };
const TTL_MS = 8000;
function bumpVersion(version: string): string {
const n = Number.parseInt(version, 10);
return String(Number.isFinite(n) ? n + 1 : 1);
}
function saveTicket(ticket: Ticket, clientVersion: string, notes: string, actor: string): Ticket {
if (clientVersion !== ticket.version) {
throw {
status: 412,
code: "object.stale_version",
modifiedBy: ticket.modifiedBy,
};
}
return {
notes,
version: bumpVersion(ticket.version),
modifiedBy: { displayName: actor },
};
}
function SessionPane({
actorId,
actorName,
ticket,
presence,
onHeartbeat,
onSave,
onReload,
}: {
actorId: string;
actorName: string;
ticket: Ticket;
presence: Presence[];
onHeartbeat: (occupant: Occupant) => void;
onSave: (clientVersion: string, notes: string) => Ticket;
onReload: () => Ticket;
}) {
const [notes, setNotes] = useState(ticket.notes);
const [version, setVersion] = useState(ticket.version);
const [open, setOpen] = useState(false);
const [behind, setBehind] = useState(false);
const [modifiedBy, setModifiedBy] = useState<{ displayName?: string }>();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
onHeartbeat({ id: actorId, kind: "edit", displayName: actorName });
const id = window.setInterval(() => {
onHeartbeat({ id: actorId, kind: "edit", displayName: actorName });
}, 2000);
return () => window.clearInterval(id);
}, [actorId, actorName, onHeartbeat]);
const occupants = useMemo(
() => presence.filter((row) => row.id !== actorId),
[presence, actorId],
);
const handleReload = () => {
const next = onReload();
setNotes(next.notes);
setVersion(next.version);
setOpen(false);
setBehind(false);
};
return (
<div className="flex min-w-0 flex-1 flex-col gap-3 rounded-xl border bg-card p-4">
<p className="text-sm font-medium">{actorName}</p>
{behind && !open ? (
<StaleVersionBanner modifiedBy={modifiedBy} onReload={handleReload} />
) : null}
<OccupancyBanner currentUserId={actorId} occupants={occupants} />
<label className="flex flex-col gap-1.5 text-sm">
Notes
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={4} />
</label>
<p className="text-muted-foreground text-xs">Loaded version {version}</p>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button
type="button"
onClick={() => {
setError(null);
try {
const next = onSave(version, notes);
setVersion(next.version);
setBehind(false);
} catch (err) {
if (isStaleVersionProblem(err)) {
const problem = err as { modifiedBy?: { displayName?: string } };
setModifiedBy(problem.modifiedBy);
setOpen(true);
return;
}
setError("Could not save.");
}
}}
>
Save
</Button>
<StaleSaveDialog
open={open}
onOpenChange={setOpen}
modifiedBy={modifiedBy}
draftText={notes}
changedFields={[{ label: "Notes", local: notes, server: ticket.notes }]}
onReload={handleReload}
onKeepEditing={() => {
setOpen(false);
setBehind(true);
}}
/>
</div>
);
}
export function StaleSaveDialogTwoSessionDemo() {
const [ticket, setTicket] = useState<Ticket>({
notes: "Kickoff notes for the Q3 order.",
version: "1",
});
const ticketRef = useRef(ticket);
ticketRef.current = ticket;
const [presence, setPresence] = useState<Presence[]>([]);
const [, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
const onHeartbeat = useCallback((occupant: Occupant) => {
setPresence((rows) => {
const next = rows.filter((row) => row.id !== occupant.id);
next.push({ ...occupant, expiresAt: Date.now() + TTL_MS });
return next;
});
}, []);
const livePresence = presence.filter((row) => row.expiresAt > Date.now());
return (
<div className="flex flex-col gap-3 sm:flex-row">
<SessionPane
actorId="ada"
actorName="Ada Chen"
ticket={ticket}
presence={livePresence}
onHeartbeat={onHeartbeat}
onSave={(clientVersion, notes) => {
const next = saveTicket(ticketRef.current, clientVersion, notes, "Ada Chen");
setTicket(next);
return next;
}}
onReload={() => ticketRef.current}
/>
<SessionPane
actorId="ben"
actorName="Ben Park"
ticket={ticket}
presence={livePresence}
onHeartbeat={onHeartbeat}
onSave={(clientVersion, notes) => {
const next = saveTicket(ticketRef.current, clientVersion, notes, "Ben Park");
setTicket(next);
return next;
}}
onReload={() => ticketRef.current}
/>
</div>
);
}API Reference
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
occupants | { id: string; kind: "view" | "edit"; displayName?: string }[] | — | Host payload; kit does not fetch |
currentUserId | string | — | Dropped from the list |
includeViewers | boolean | false | When false, only other editors are shown |
className | string | — | Root |
Missing displayName renders as Another user.
Composition
OccupancyBanner
└─ role="status" Banner (Info mark + sentence)The host owns heartbeat and the occupant list. The Banner never wraps inputs.