@rightkit/platform-ui
v0.4.0
Published
Brand-neutral platform, shortcut, and window-control primitives for Right Suite apps.
Readme
@rightkit/platform-ui
Brand-neutral platform primitives for Right Suite desktop apps: Tauri-backed platform detection, display-only shortcut formatting, keyboard-chord rendering, platform capability flags, window-control labels and headless window-control adapters. Title bars, colours and navigation stay in each app.
| Item | Value | |---|---| | Where it runs | App webview UI (React and plain TypeScript) | | Rust pair | None | | Version | see INDEX.md | | License | MIT OR Apache-2.0 |
Entry points
| Import | Contents |
|---|---|
| @rightkit/platform-ui | getPlatform, loadPlatform, normalizePlatform, formatChord, platformCapabilities, createWindowControls, createFullscreenControls, createWindowControlLabels, DEFAULT_WINDOW_CONTROL_LABELS, WINDOW_CONTROL_LABELS, bootstrapQaPlatform, resolveQaPlatform, configurePlatform, platformInitializationScript; types Platform, FormatChordOptions, PlatformOptions, WindowControlAdapter, FullscreenControlAdapter, WindowControlLabels, QaPlatformBootstrapInput |
| @rightkit/platform-ui/react | usePlatform, Kbd, KbdProps |
| @rightkit/platform-ui/headless | No native detection and no @tauri-apps/plugin-os import, so it works in any app. formatChord (explicit platform, default unknown), normalizePlatform, platformInitializationScript, floatingPanelStyle, fitToViewport, createToastBus, defaultToastBus, DEFAULT_TOAST_TTL, toast, useFocusTrap, useModalDismiss, useLatest, useDismiss, useExitTransition, useListNavigation, rankCommands, rankItems, matchScore, the list helpers clampIndex, readIndex, moveIndex, selectByPointer, typeaheadMatch, and the framework-free cores attachFocusTrap, attachDismiss, createExitTransition. See Headless behaviour hooks. |
Peer dependencies
| Peer | Range |
|---|---|
| @tauri-apps/plugin-os | >=2 <3, optional (peerDependenciesMeta). Loaded lazily; the browser's platform report is used when it is absent. |
| react | >=18 <21 |
Example
Illustrative. nativeWindowAdapter is the app's own Tauri window adapter.
import { createWindowControls, formatChord, getPlatform } from "@rightkit/platform-ui";
import { Kbd, usePlatform } from "@rightkit/platform-ui/react";
const platform = getPlatform();
const keys = formatChord("mod+shift+p", platform); // display tokens only
const controls = createWindowControls(nativeWindowAdapter); // your Tauri window adapterformatChord only returns display tokens. The app owns the stored shortcut string. Fullscreen is a separate adapter, so an app can implement only minimize, maximize and close.
Native platform loading
getPlatform() is synchronous. It returns the QA override if set, then the native platform once it has loaded, and before that the host-injected platform if one was set, else the browser's report. Every supported desktop webview reports the right OS there, so the first render is correct. loadPlatform() loads @tauri-apps/plugin-os with a dynamic import and resolves the native value, which is cached. usePlatform() calls it for you.
Host-injected platform (synchronous, before any script)
Code that runs at module load, or the first render of a host that cannot wait, can read the native platform synchronously if the host injects it before the app bundle runs. getPlatform() reads window.__RIGHTKIT_PLATFORM__ (a string, normalised by normalizePlatform) when it is present. A Tauri host sets it from an initialization script:
import { platformInitializationScript } from "@rightkit/platform-ui/headless";
// Put this text in the window's initialization script (for example Tauri's `initialization_script`),
// generated at build or startup time from the native platform name.
const script = platformInitializationScript("macos");
// => window.__RIGHTKIT_PLATFORM__ = "macos";platformInitializationScript returns the exact snippet; an unsupported name yields "unknown". Once loadPlatform() resolves, the async native value takes over. If the two disagree, the native value wins and a warning is logged. If plugin-os is absent, the injected value is what loadPlatform() resolves to.
Configuring the platform (configurePlatform)
configurePlatform(options) merges options into the module configuration. Call it before the first platform read. Defaults equal the previous behaviour.
| Option | Default | Effect |
|---|---|---|
| qaQueryParam | "qaPlatform" | Query parameter that carries the QA platform for resolveQaPlatform and bootstrapQaPlatform. |
| qaRequireQaFlag | true | When false, the query parameter alone enables the override (no qa=1). |
| nativeWinsOverQa | false | When true, inside a native host (injected value, plugin-os answered, or __TAURI_INTERNALS__) the QA override is ignored and the native platform is reported. |
| fallbackPlatform | none | Used only when the resolved platform would be unknown. Normalised like any name. |
import { configurePlatform } from "@rightkit/platform-ui";
configurePlatform({ qaQueryParam: "qaPlatform", qaRequireQaFlag: false, nativeWinsOverQa: true, fallbackPlatform: "windows" });Platform names accept the aliases mac and darwin (macOS) and win and win32 or Win32 (Windows) in addition to the canonical macos, ios, windows and linux.
Chord modifier ctrlormeta
ctrlormeta means "Ctrl or Meta" (Ctrl or Cmd/Win). formatChord renders the token as ⌘/⌃ on macOS, Ctrl/Win on Windows and Linux, Touch on iOS and Ctrl/Meta when unknown. Matching is in @rightkit/react-shell.
Bundling note: the root and ./react entries contain a dynamic import("@tauri-apps/plugin-os"). Apps that bundle those entries must install the plugin. An app without the plugin should import ./headless only.
Headless behaviour hooks
For an app whose markup and look are fixed (ViewRight): keep your JSX and classes, delete your local focus, dismiss, exit and list logic, and take that behaviour from @rightkit/platform-ui/headless. The hooks return state and props; they render nothing, import no styles, create no portals and impose no element types or ids. The defaults of useFocusTrap and useModalDismiss reproduce 0.2.1, so adopting either with no options changes nothing that 0.2.1 did.
| Hook / function | Options (default) | Returns |
|---|---|---|
| useFocusTrap(ref, active = true, opts) | autoFocus (true), trap (true), restore (true), restoreTiming ("microtask" | "sync" | ms), restoreWhen ("inactive" | "always"), captureOpener ("render" | "effect"), fromOutside ("both" | "backward" | "none"), skipHidden (true), emptyList ("prevent" | "pass"), focusFallback ("container" | "none") | nothing |
| useModalDismiss(onClose, opts) | escape (false), exitMs (80), preventDefault (true), stopPropagation (true) | { closing, close } |
| useDismiss(onDismiss, opts) | enabled (true), escape (true), escapeTarget ("window" capture, or a ref: bubble on that element), preventDefault (true), stopPropagation (true), imeGuard (true), outside ("mousedown" | "click" | "click-target" | false; default "mousedown"), container, backdrop, ignore, outsideContextMenu (false), onWindowBlur (false), onResize (false), onScroll (false) | nothing; calls onDismiss(reason, event), reason is escape | outside | blur | resize | scroll |
| useExitTransition(opts) | exitMs (80), closeAfter ("immediately" | "settled"), onActionWhileClosing ("drop" | "run"), onSyncThrow ("close" | "propagate"), dismissWhilePending ("ignore" | "exit"), closeOn ("original-settled" | "first-settled"), initialOpen (true), onExitStart, onClosed | { phase: "open" \| "closing" \| "closed", closing, isOpen, pending, open(), close(action?) } |
| useListNavigation(opts) | count, initialIndex (0), wrap (true), homeEnd (true), keepIndexOnItemsChange (false), resetKey, hoverSelects ("always" | "after-pointer-move"), typeahead (off), isDisabled, onActivate, activateKeys (["Enter"]), idPrefix, listId, optionId, role ("listbox" | "menu"), focusOwner ("input" | "list"), scrollIntoView (false), scrollOn ("active" | "active-or-length"), imeGuard (true), emptyIndex ("clamp" | "preserve") | { activeIndex, rawIndex, activeId, setActiveIndex, getInputProps(), getListProps(), getOptionProps(i) } |
| fitToViewport({ x, y, size \| estimatedSize, viewport, margin = 8 }) | pure; size (measured) wins over estimatedSize; one of the two is required, else it throws a TypeError | { left, top, width, height, maxWidth, maxHeight } |
| rankCommands(items, query, limit = 50) | pure; also rankItems, matchScore | ranked copy; empty query keeps input order |
Notes that matter when you configure them:
Focus trap.
captureOpener: "render"reads the opener during the first render, before React's ownautoFocusattribute moves focus at commit;"effect"reads it when the trap activates. By default (restoreWhen: "inactive") a cleanup restores only when the container is gone oractiveis false, whatever the timing, so StrictMode's simulated cleanup leaves focus alone;"always"restores on every cleanup (use it withrestoreTiming: "sync"; with a microtask the restore would land after StrictMode's re-run and take focus from the first control). WithcaptureOpener: "effect"the opener is recorded once per open: never from a node inside the container, never over a value already recorded, and cleared when the trap really closes (0.2.3; before, StrictMode's second effect run recorded the trap's own first control). A ReactautoFocusattribute inside the container has already moved focus when effects run, so use"render"with it.useDismissoutside press. It needscontainer(the panel) orbackdrop. Withbackdrop, only a target that is the backdrop element counts as outside, likee.target === e.currentTarget; otherwise outside means not insidecontainerand not inside anignoreelement."click"needs both the press and the release outside (a drag that starts or ends inside never dismisses);"click-target"dismisses on any click whose target is outside, which for a panel nested in its backdrop includes a drag from the panel to the backdrop, and for a sibling backdrop does not (the click lands on the common ancestor), exactly as a plainonClickon the backdrop would. Listeners are removed whenenabledturns false and on unmount; passenabled: exit.isOpenso a closing layer stops listening.useExitTransition.close(action)withcloseAfter: "immediately"starts the exit, then callsactionsynchronously. With"settled"it callsactionfirst, keepsphase: "open"withpending: trueuntil the returned value settles (fulfilled or rejected), then starts the exit.open()while closing starts a fresh instance; the old timer and any unsettled action cannot close it. Aclose(action)while closing or pending drops its action (onActionWhileClosing: "drop", default)."run"still runs it, so a double Enter or click executes the action twice; it exists only for parity with wrappers that always call their action.closereturns a promise that rejects with the action's error, after the close has still happened. Timers are cleared on unmount.useListNavigation. The index is clamped on read, so a shrinking list never leaves a danglingaria-activedescendant; an empty list gives-1.keepIndexOnItemsChange: truekeeps the stored index whencountchanges (it comes back if the list grows again);resetKey(the query) resets it regardless. Keys during an IME composition are ignored unlessimeGuard: false. WithemptyIndex: "preserve"the stored -1 that ArrowDown leaves on an empty list survives (rawIndex), so results arriving later select nothing until a key moves; the default clamps it to the first item.scrollOn: "active-or-length"also scrolls to the active option when the list length changes.hoverSelects: "after-pointer-move"never lets a resting pointer take the selection back from the keyboard. On an empty listemptyIndex: "preserve"stores what the clamp rule computes (no wrap; the start index does not matter,activeIndexstays -1 until results arrive):| Key on an empty list | Stored raw index from -1 | Stored raw index from 0 | |---|---|---| | ArrowDown, End | -1 | -1 | | ArrowUp, Home | 0 | 0 | | any key, with
wrap: true| -1 | -1 |"clamp"(default) stores -1 for every key.onSyncThrow. With"close"(default) an action that throws synchronously becomes a rejected promise fromclose()and the close still happens. With"propagate"the throw reaches the caller ofclose()synchronously, the transition does not start and state is unchanged (the menu stays open), like a wrapper that calls its action before wrapping it in a promise; the action then runs beforeonExitStarteven for"immediately". Asynchronous rejections close in both modes. Guard the call site (try { void exit.close(fn) } catch { ... }) when you choose"propagate", since React reports an uncaught handler error otherwise.dismissWhilePending. WithcloseAfter: "settled", a plainclose()while an action is pending is dropped ("ignore", default). With"exit"the exit starts at once (phase: "closing",pending: false), the action keeps running, and its settling starts no second exit and no secondonClosed. A rejection afterwards, even after the exit finished, reaches only the promise returned by theclose(action)that started it; state is untouched and nothing reopens. Catch that promise at the call site. Aclose(action)made while pending still followsonActionWhileClosing.closeOn. WithcloseAfter: "settled"andonActionWhileClosing: "run", a secondclose(action)made while the first is pending runs its action. With"original-settled"(default) only the first action's settling starts the exit. With"first-settled"whichever settles first starts it, the same as a wrapper written asvoid Promise.resolve(fn()).finally(close)per click. A wrapper that records rejections in its own diagnostics must not add.catch(() => {})to the promiseclose()returns; let it reach the handler it reaches today.fitToViewportis the same clamp asfloatingPanelStyleand equals it for{ width, height: estimatedHeight }. Measuring (getBoundingClientRectin a layout effect, then calling it again withsize) stays in your component.
Menu (replaces a local scrim + Escape listener + exit timer)
ViewRight's DocumentContextMenu: a full-screen scrim closes on mousedown and on a context-menu gesture, a window-capture Escape listener calls only onClose, floatingPanelStyle with a fixed 176 x 260 estimate. The sidebar and explorer menus close on a click (outside: "click-target"), run each item then close after it settles.
const panel = useRef<HTMLDivElement>(null);
const scrim = useRef<HTMLDivElement>(null);
const exit = useExitTransition({ exitMs: 80, closeAfter: "settled", onActionWhileClosing: "run", onSyncThrow: "propagate", closeOn: "first-settled", dismissWhilePending: "exit", onClosed: onClose });
// document menu: exitMs 0 (it unmounts at once); sidebar menus: onSyncThrow "propagate" keeps the menu open when the action throws, and dismissWhilePending "exit" lets Escape or a backdrop press start the 80 ms exit during a slow action
useDismiss(() => void exit.close(), {
enabled: exit.isOpen, container: panel, backdrop: scrim,
outside: "mousedown", // sidebar menus: "click-target"
outsideContextMenu: true, // scrim onContextMenu -> preventDefault + close
preventDefault: false, stopPropagation: false, imeGuard: false, // what the local Escape listener did
// and on the transition: onActionWhileClosing: "run" (ViewRight's wrapper runs the action on every click, even during the exit)
});
const pos = fitToViewport({ x, y, estimatedSize: { width: 176, height: 260 }, viewport: { width: innerWidth, height: innerHeight } });
// JSX unchanged: scrim div ref={scrim}, menu div ref={panel} style={pos} className={exit.closing ? "vr-pop-exit" : ""}
// and onMouseDown={(e) => e.preventDefault()} stays yours (it keeps the document selection alive).
// run = (fn) => () => void exit.close(fn) // replaces Promise.resolve(fn()).finally(close); no .catch, see closeOn aboveDeletes: the useEffect Escape listener, the scrim onMouseDown / onContextMenu handlers, the useModalDismiss call and run wrapper in the sidebar menus. Stays: portal choice, tabIndex, wrappers, vr-pop* classes, the mousedown preventDefault, the measured-height re-fit if you want one (call fitToViewport again with size).
Modal (replaces local useFocusTrap + useModalDismiss)
ViewRight's ModalShell: Tab trap that restores to the element that was focused when the effect ran, synchronously; no wrap for a forward Tab from outside; a click on the overlay (not on the panel) closes; Escape on the window capture calls stopPropagation only.
const overlay = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(null);
const exit = useExitTransition({ exitMs: 80, onClosed: onClose });
useFocusTrap(panel, true, {
autoFocus, // the shell's own prop
captureOpener: "effect", restoreTiming: "sync", restoreWhen: "always", fromOutside: "backward",
skipHidden: false, emptyList: "pass", focusFallback: "none",
});
useDismiss(() => void exit.close(), {
enabled: exit.isOpen, container: panel, backdrop: overlay,
outside: "click-target", preventDefault: false, imeGuard: false,
});
// JSX unchanged; className uses exit.closing in place of the local `closing`.For alertdialog surfaces that must not dismiss on the backdrop, pass outside: false. autoFocus: false and trap: false cover dialogs that set their own initial focus or stay Tab-native.
Command palette (replaces the local list state, clamping and exit)
ViewRight's CommandPalette: ArrowDown/Up clamp, no Home/End, a stored index that is only clamped on read, every mouse move selects, option ids vr-palette-option-<i>, the list id vr-palette-results, Enter runs the item and starts the exit in the same tick, Escape closes from anywhere inside the dialog.
const exit = useExitTransition({ exitMs: 80, onActionWhileClosing: "run", onClosed: onClose });
const items = rankCommands(allItems, q, 50); // or your own scorer
const nav = useListNavigation({
count: items.length, wrap: false, homeEnd: false, keepIndexOnItemsChange: true,
hoverSelects: "always", scrollIntoView: true, scrollOn: "active-or-length",
imeGuard: false, emptyIndex: "preserve",
idPrefix: "vr-palette", listId: "vr-palette-results", optionId: (i) => `vr-palette-option-${i}`,
onActivate: (i) => void exit.close(items[i].run),
});
// ViewRight has no focus trap or restore on the palette, so there is no useFocusTrap call; an app that wants one adds
// useFocusTrap(dialog, true, { autoFocus: false }) and focuses its input itself.
useDismiss(() => void exit.close(), { enabled: exit.isOpen, escapeTarget: dialog, stopPropagation: false, container: dialog, backdrop: overlay, outside: "click-target" });
// <input value={q} onChange={...} aria-label="Search files and commands" {...nav.getInputProps()} />
// <div {...nav.getListProps()} aria-label="Results"> ... <button {...nav.getOptionProps(i)} onClick={() => exit.close(it.run)} /></div>rankCommands scores substring, keyword, hint and subsequence matches (higher is better, stable, empty query keeps input order). It is not ViewRight's scorer (subsequence only, lower first): adopting it changes the order results appear in. Pass onActivate the run you want; grouping into sections stays in your JSX. An app that wants the shared look and resets on typing sets wrap: true, homeEnd: true, resetKey: q, hoverSelects: "after-pointer-move".
Unreleased helpers
isTauri()(root): Tauri WebView detection, without loadingplugin-os.blockNativeContextMenu(doc?)(root,./headless): suppress the native context menu; returns the uninstall.relativeTimeFrom(ms, now?)(root,./headless): short "N minutes ago" text.useFocusTrap(ref, active, { scope: "document", skipUnrendered: true }): a trap that still holds while focus is on a backdrop, and ignores controls with no layout box.useDismiss(fn, { isInside, onFocusOut: true }):isInsidetreats several elements as one panel;onFocusOutdismisses with reason"focus"when focus leaves the panel and never cancels the Tab.createToastBus({ dedupe: true, max: 3 })andtoast(message, { id: "save" }): dedupe plain toasts, cap the live stack, and replace a toast by key. Callbus.dismiss(id)on every removal so dedupe andmaxstay accurate.
Reduced motion
@rightkit/platform-ui ships no stylesheet and runs no animation, so it has no prefers-reduced-motion handling of its own to opt out of. The visual stylesheet that does is @rightkit/react-shell/shell.css: it honours prefers-reduced-motion: reduce by default and a host sets data-rk-motion="full" on the root to opt out (see that README). Opting out removes an accessibility accommodation and is the host's decision.
Known issues
bootstrapQaPlatformandresolveQaPlatformare QA helpers exported from the runtime entry. Call them only in QA builds. By default an override needsqa=1andqaPlatformin the query string, orRIGHTKIT_QA(1ortrue) andRIGHTKIT_QA_PLATFORMin theenvinput. Production detection uses@tauri-apps/plugin-os.- Peer React is
<21, the same range asdoc-render,i18n,media-uiandpdf-ui(package.json:33).
