@robonen/platform
Platform-dependent utilities for browser and multi-runtime JavaScript — focus management, ARIA isolation, animation lifecycle tracking, and environment-safe globals.
Most utility libraries stop at the platform boundary: the moment you need to reach for the DOM, shadow roots, aria-hidden, or globalThis, you are on your own. @robonen/platform fills that gap. It packages the gritty, well-tested primitives that overlays, dialogs, and editors depend on — focus guards, tabbable-edge detection, sibling hiding for screen readers, and CSS animation settling — and ships them SSR-aware and dependency-free. It is the low-level layer that powers @robonen/primitives and Writekit.
Focus, done right
Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge detection via a fast TreeWalker — the bones of any focus trap.
Accessible isolation
hideOthers marks every sibling aria-hidden, ref-counted across layers, preserving aria-live regions. A dependency-free port of aria-hidden.
Animation lifecycle
Detect running animations and transitions, then settle exit animations cleanly with fill-mode flash prevention — so unmounts wait for the CSS to finish.
Multi-runtime safe
A resolved _global and an isClient flag that work across Node, Bun, Deno, and the browser — guards baked in so SSR never throws.
Install
pnpm add @robonen/platformSubpath exports
The package splits along the platform boundary. Browser-only helpers live under /browsers; runtime-agnostic helpers live under /multi.
| Entry | Scope | What you get |
|---|---|---|
@robonen/platform/browsers | DOM | Focus, tabbable edges, hideOthers, animation lifecycle |
@robonen/platform/multi | Any runtime | _global, isClient |
Usage
A typical overlay flow: capture the focused element, hide siblings from assistive tech, drop focus onto the first tabbable target, and tear it all down on close.
import {
getActiveElement,
getTabbableEdges,
focus,
hideOthers,
} from '@robonen/platform/browsers';
function openDialog(dialog: HTMLElement) {
// Remember where focus was, so we can restore it on close.
const previouslyFocused = getActiveElement();
// Hide everything outside the dialog from screen readers (ref-counted).
const undoHide = hideOthers(dialog);
// Move focus to the first tabbable element inside the dialog.
const { first } = getTabbableEdges(dialog);
focus(first, { select: true });
return function close() {
undoHide();
focus(previouslyFocused);
};
} On the cross-runtime side, reach for a safe global and a reliable client check without sprinkling "undefined" guards through your code:
import { _global, isClient } from '@robonen/platform/multi';
// Works in Node, Bun, Deno and the browser — never throws in SSR.
if (isClient) {
_global.addEventListener('resize', onResize);
}hideOthers already no-ops when document is undefined, and /multi is import-safe everywhere. Where to next
Browse the full API reference below, or jump straight to the building blocks:
- focusGuard — add boundary guards for predictable focus wrapping
- getTabbableEdges — find the first and last focusable elements in a container
- hideOthers — isolate a subtree for assistive technology
- onAnimationSettle — run a callback once an animation or transition finishes
browsers · 39
Merges a style patch onto an element's inline `style` and returns a cleanup function that restores the element's entire previous `cssText`. Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is an all-or-nothing revert — handy for scoped effects.
Decodes a cookie value: unwraps an RFC 6265 DQUOTE-wrapped value (the quotes are transport dressing, not payload), then decodes percent-escapes. Malformed escapes (e.g. third-party cookies that never used percent-encoding) are returned as-is instead of throwing.
Dispatches a non-bubbling custom event on an element for animation lifecycle tracking
Percent-encodes the characters a cookie name cannot contain (cookie names are RFC 2616 tokens). Typical names — letters, digits, `-`, `_`, `.` — pass through unchanged. `(` and `)` are escaped as `%28`/`%29`.
Percent-encodes only the characters a cookie value cannot contain per RFC 6265 (controls, whitespace, `"` `,` `;` `\` and `%` itself), leaving everything else readable. Compatible with js-cookie's default write converter.
Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
Focuses an element without scrolling. Optionally calls select on input elements.
Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
Returns the active element of the document (or shadow root)
Returns the current CSS animation name(s) of an element
Looks up a single cookie in a `document.cookie`-style string without building a full map — same first-occurrence and verbatim-raw-value semantics as {@link parseCookieString}, but allocation-free for misses and cheap for hot paths (reactive reads, polling).
Collects all tabbable candidates via TreeWalker (faster than querySelectorAll). This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
Returns the first and last tabbable elements inside a container
Reads the current translation of an element along one axis from its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)` (3D) forms. Returns `null` when the element has no matrix transform.
Marks every sibling of `target` (within `parentNode`, defaulting to `document.body`) as `aria-hidden="true"` so assistive technologies skip them. `aria-live` regions and `<script>` elements are preserved. Returns an undo function that restores the previous state; calls stack (ref-counted) across multiple layers. Port of the `aria-hidden` npm package, kept dependency-free.
Checks whether an element has a running CSS animation or transition
Type guard for a value that is itself an {@link EventTarget}(e.g. `window`, `document`, or an element) — i.e. it can be attached to directly rather than unwrapped from a ref/getter first.
Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
Reports whether an element is fully within the visual viewport, accounting for on-screen keyboards via `window.visualViewport`. A 40px slack is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false` when `visualViewport` is unavailable.
Whether the current device runs iOS/iPadOS (iPhone or iPad).
Whether the current device is an iPad. iPadOS 13+ masquerades as a Mac, so this also treats a touch-capable Mac (`maxTouchPoints > 1`) as an iPad.
Whether the current platform is an iPhone (per `navigator.platform`).
Whether the current platform is macOS (per `navigator.platform`). Note iPadOS reports as a Mac — combine with {@link isIPad} to disambiguate.
Whether the current browser is Firefox on a mobile device (Android Firefox or iOS Firefox / `FxiOS`).
Whether the current browser is Safari (desktop or iOS), excluding Chrome and Android browsers that also include "Safari" in their UA string.
Checks if an element is an input element with a select method
Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.
Parses a `document.cookie`-style string (`'a=1; b=2'`) into a `Map` of decoded names to values. Keeps the **first** occurrence per name — browsers order cookies most-specific-path first, so the first one is the one a server would use. The raw value is passed to `decode` verbatim (including any wrapping quotes) so it matches what the Cookie Store API would report; the default decoder unwraps quotes and percent-escapes.
Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a pixel number. `em`/`rem` use the conventional 16px root size. Returns `NaN` for non-numeric input.
Reads the value and current selection of an `<input>`/`<textarea>` into a plain {@link InputState}. A `null` selection (some input types report it) falls back to a collapsed caret at the end of the value.
Restores the inline styles an element had before the most recent cached {@link setStyle}. With `prop` it restores a single property; otherwise it restores every property that was remembered. A no-op if nothing was cached.
Builds a `document.cookie` assignment string from an **already-encoded** name and value (see {@link encodeCookieName} / {@link encodeCookieValue}) plus {@link CookieAttributes}. `Path` and `SameSite` are always emitted explicitly. Fails loudly on combinations browsers silently drop: `SameSite=None` or `Partitioned` without `Secure`, and `name=value` over 4096 UTF-8 bytes.
Applies a batch of inline styles to an element, remembering the values it overwrote so {@link resetStyle} can restore them later. `--custom` properties are written through `setProperty`. Pass `ignoreCache` to apply the styles without recording the originals (e.g. for transient, per-frame writes during a drag that you intend to clear wholesale).
Determines whether unmounting should be delayed due to a running animation/transition change
Tests `navigator.platform` against a regular expression, guarding for non-browser environments. Returns `undefined` when there is no `navigator` (e.g. during SSR) so callers can distinguish "no" from "unknown".
Writes value and selection back to an `<input>`/`<textarea>`. The value is only assigned when it actually changed (avoids spurious cursor jumps), and the caret is moved **only while the element is focused** so programmatic updates never steal or reposition focus. `setSelectionRange` is guarded because some input types (`number`, `email`, `date`) forbid it.
multi · 7
Every supported card brand. Resolve a number with `findCardBrand`.
Detect a payment-card brand from a number's digits by its IIN/BIN pattern. Returns the brand whose pattern matches the most leading digits (so it narrows down as the user types), or `undefined` if none match. Pure.
Resolve a digit string to its country among a {@link PhoneCountry}list. Matches the **longest dialing code** (codes are prefix-free, so this is unambiguous), then — for countries sharing a code (NANP `+1`, `+7` RU/KZ) — the most specific **area code**, then the lowest **priority** (the primary country) when no area code matches. The default dataset is indexed for O(1) lookup; a custom list falls back to a linear scan.
Convert an ISO 3166-1 alpha-2 country code (e.g. `'RU'`, `'us'`) into its flag emoji by mapping each letter to a Unicode regional indicator symbol. Case-insensitive; returns an empty string for anything that isn't two ASCII letters. Pure and environment-agnostic.
Check if the current environment is the client
Whether `value` is a complete, valid payment-card number: it passes the Luhn checksum (`luhn` from `@robonen/encoding`) AND its digit length matches the detected {@link findCardBrand} brand (or the 12–19 digit ISO/IEC 7812 range when the brand is unknown). For the bare checksum, use `luhn` directly.
Every supported country. Resolve a number with `findPhoneCountry`.