@wcstack/fullscreen
v2.2.0
Published
Declarative Fullscreen API component for Web Components. Framework-agnostic requestFullscreen()/exitFullscreen() control via wc-bindable-protocol.
Maintainers
Readme
@wcstack/fullscreen
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
@wcstack/fullscreen is a headless Fullscreen API component for the wcstack ecosystem.
It is not a visual UI widget.
It is a control node: unlike most wcstack IO nodes (which act on themselves), <wcs-fullscreen> drives requestFullscreen() / exitFullscreen() on a referenced element — the same way @wcstack/intersection observes a referenced element rather than itself.
With @wcstack/state, <wcs-fullscreen> can be bound directly through path contracts:
- input surface:
target(which element to operate on — see below) - output state surface:
active,error,errorInfo - commands:
requestFullscreen(),exitFullscreen()
@wcstack/fullscreen follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
FullscreenCore) drives the Fullscreen API and tracksdocument'sfullscreenchangeevent - Shell (
<wcs-fullscreen target="...">) resolvestargetto a DOM element and connects Core state to DOM lifecycle - Binding Contract (
static wcBindable) declares the observableactive/error/errorInfoproperties and therequestFullscreen/exitFullscreencommands
Why this exists — you operate on the target, not on the tag itself
Element.requestFullscreen() is a method on the element you want to fullscreen — an image, a video, a card UI — not on <wcs-fullscreen> itself. So this tag is a non-visual control element (its display is set per target-resolution mode — see the table below) that points at another element via its target attribute, exactly like <wcs-intersect>:
| target | operates on | display | typical use |
| ---------------- | ------------------------ | ------------ | -------------------------- |
| omitted | first element child | contents | wrap a gallery image/video |
| "#hero" / selector | the matched element | none | point at a distant node |
| "self" | the element itself | block | fullscreen the wrapper itself |
Install
npm install @wcstack/fullscreenQuick Start
1. Fullscreen an image on button click
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fullscreen/auto"></script>
<wcs-state>
<script type="module">
export default {
$commandTokens: ["goFullscreen"],
};
</script>
</wcs-state>
<wcs-fullscreen target="#hero" data-wcs="command.requestFullscreen: $command.goFullscreen"></wcs-fullscreen>
<img id="hero" src="/photo.jpg">
<button data-wcs="onclick: $command.goFullscreen">Fullscreen</button>The button never touches <wcs-fullscreen> directly: its click emits the goFullscreen command token, and <wcs-fullscreen> subscribes to that token via command.requestFullscreen: $command.goFullscreen (the command-token protocol — the element with the command method is the subscriber, not the emitter).
2. Wrap a video and show an exit button while active
<wcs-state>
<script type="module">
export default {
$commandTokens: ["exitFs"],
isFullscreen: false,
};
</script>
</wcs-state>
<wcs-fullscreen data-wcs="active: isFullscreen; command.exitFullscreen: $command.exitFs">
<video src="/movie.mp4" controls></video>
</wcs-fullscreen>
<button data-wcs="hidden: isFullscreen|not; onclick: $command.exitFs">Exit fullscreen</button>Every bound state path must be declared up front — isFullscreen: false here; binding an undeclared path throws at initialization. Negation in a data-wcs path is done with the |not filter (isFullscreen|not), not a leading ! — paths do not support prefix operators.
Observable Properties (outputs)
| Property | Event | Description |
| --------- | ---------------------- | ------------ |
| active | wcs-fullscreen:change | true while document.fullscreenElement is this instance's resolved target; false otherwise. |
| error | wcs-fullscreen:error | The most recent failure: a rejected promise (e.g. a TypeError for a gesture-less call), { message: "Fullscreen API is not supported." } when the platform API is missing, { message: "Fullscreen target could not be resolved." } when target did not resolve to an element, or null if the last attempt succeeded / nothing has failed yet. |
| errorInfo | wcs-fullscreen:error-info-changed | Serializable failure taxonomy (WcsIoErrorInfo: stable code / phase / recoverable) derived from error, or null. Additive — the error value shape is unchanged. |
Commands
| Command | Async | Description |
| --------------------- | ------ | ------------ |
| requestFullscreen() | yes | Resolve target and call requestFullscreen() on it. |
| exitFullscreen() | yes | Call document.exitFullscreen(). Silent no-op if nothing is currently fullscreen. |
Attributes / Inputs
| Attribute | Description |
| ---------- | ------------ |
| target | Same 3-mode resolution as @wcstack/intersection's target: "self", a CSS selector, or omitted (first child). |
CSS styling with :state()
<wcs-fullscreen> reflects one boolean output state onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| active | wcs-fullscreen:change fires with detail.active === true (cleared when active: false) |
error / errorInfo are observable (data-wcs bindable via their own events —
see the Observable Properties table above), but they are not reflected onto
:state(): a failure object is not a boolean state, so there is nothing for the
boolean :state() reflection to represent.
wcs-fullscreen:state(active) ~ .exit-hint { display: block; }
wcs-fullscreen:state(active) ~ .exit-hint { display: none; } /* default */Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-fullscreen> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-fullscreen:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["active"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes onto adata-wcs-state-activeattribute on the element, so the Elements panel highlights it as it toggles:<wcs-fullscreen target="#hero" debug-states></wcs-fullscreen>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attribute exists purely to make state changes visible while debugging with
DevTools open; it is not a supported styling hook.
Notes & limitations
- User gesture requirement.
requestFullscreen()only succeeds when called synchronously from within a real user gesture (e.g. a click handler). This node cannot manufacture a gesture — if you invokerequestFullscreenvia the command-token protocol (command.requestFullscreen: $command.<token>on<wcs-fullscreen>, emitted by a button'sonclick: $command.<token>), make sure the triggering event itself is a genuine user gesture. Calling it from inside asetTimeoutor deep in a promise chain will reject with aTypeError(per the WHATWG Fullscreen spec's transient-activation check — notNotAllowedError) regardless of how it was invoked — this is a browser-level constraint, not something wcstack can work around. - Vendor prefixes. Some older Safari versions only implement
webkitRequestFullscreen/webkitExitFullscreen/webkitFullscreenElement/webkitfullscreenchange. The Core probes the standard name first and falls back to the legacy name at call time (never cached), so both are supported transparently. - Multiple instances.
document.fullscreenElementis a single, document-wide value. If you have several<wcs-fullscreen>instances pointed at different targets, only the instance whosetargetmatchesdocument.fullscreenElementreportsactive: true— the others correctly reportfalse. Each instance tracks its own resolved target internally; it does not simply mirror "is anything fullscreen". Note the asymmetry:exitFullscreen()is not scoped per instance — it calls the document-globaldocument.exitFullscreen(), so invoking it on any instance exits whatever element is currently fullscreen, even one put there by another instance'starget(its silent no-op check is likewise document-wide: "is anything fullscreen", not "is my target fullscreen"). This mirrors the platform API itself. exitFullscreen()is a safe no-op. Calling it when nothing is fullscreen (or when the API is unsupported) resolves without error — it is treated as an idempotent "make sure we're not fullscreen" command, not a failable precondition check.error/errorInfoare observable (bindable). Both are declared instatic wcBindable.propertieswith their own events (wcs-fullscreen:error/wcs-fullscreen:error-info-changed), so a binding system can observe a request/exit failure reactively (data-wcs="error: fsError; errorInfo: fsErrorInfo") — or you can still readelement.error/element.errorInfoimperatively after a command's promise settles.errorInfois the additive serializable failure taxonomy derived fromerror: a stableWcsIoErrorInfo(code/phase/recoverable) without changing theerrorvalue shape. A missing API ("…is not supported.") →capability-missing(phaseprobe); an unresolvedtarget→invalid-argument(phasestart); a gesture-less rejection (TypeError/NotAllowedError) →not-allowed(phaseexecute,recoverable: true— a retry from within a genuine user gesture may succeed); anything else →fullscreen-error(phaseexecute).errorInfotransitions exactly whenerrordoes (cleared tonullon success); theWcsIoErrorInfotype and theWCS_FULLSCREEN_ERROR_CODEconstants are exported._gengeneration guard. In-flightrequestFullscreen()/exitFullscreen()calls that settle afterdispose()(or after a superseding call) do not write to torn-down state.- SSR (
@wcstack/server). Declaresstatic hasConnectedCallbackPromise = trueand exposesconnectedCallbackPromise, though since subscribing tofullscreenchangeis synchronous this promise always settles immediately.
Headless usage (FullscreenCore)
The Core has no DOM dependency beyond document and the target Element you pass it explicitly — it never resolves selectors itself:
import { FullscreenCore } from "@wcstack/fullscreen";
const core = new FullscreenCore();
core.addEventListener("wcs-fullscreen:change", (e) => {
console.log((e as CustomEvent).detail); // { active: true | false }
});
await core.observe(); // subscribe to document's fullscreenchange
await core.requestFullscreen(myElement); // must be called from within a user gesture
console.log(core.active); // true once fullscreenchange confirms it
await core.exitFullscreen();
core.dispose(); // detach the fullscreenchange listenerThe structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
Accessibility
WCAG 2.1.2 No Keyboard Trap / 2.4.3 Focus Order: Esc always exits fullscreen (browser-guaranteed — do not repurpose it), but also offer a visible in-app exit wired to the exitFullscreen command; not every user knows the convention. Entering fullscreen changes what is on screen without a navigation: make sure focus lands somewhere sensible inside the fullscreen element (focus its first interactive control, or the element itself with tabindex="-1"), and check where focus returns when fullscreen ends — it should go back to the control that opened it.
License
MIT
