import { mutation, query, timed, hotkeys, dnd, detailPanel, localStore, clipboard, clickOutside, dropzone, a11y } from "@k2b/stdlib/solid";All exports require SolidJS. Primitives that register lifecycle cleanup must be called inside a
reactive owner (component or createRoot); owner-independent APIs are noted explicitly.
Async mutation controller with reactive signals, lifecycle hooks, abort, and retry. It does not require a reactive owner.
import { mutation } from "@k2b/stdlib/solid";
const { data, error, loading, mutate, abort, retry } = mutation.create({
mutation: async (vars: { name: string }, { abortSignal }) => {
const res = await fetch("/api/items", {
signal: abortSignal,
method: "POST",
body: JSON.stringify(vars),
});
return res.json();
},
onBefore: (vars) => ({ optimisticId: crypto.randomUUID() }),
onSuccess: (data) => console.log("Created:", data),
onError: (err) => console.error("Failed:", err),
onFinally: () => console.log("Done"),
});
mutate({ name: "New Item" });
abort(); // cancel in-flight request
retry(); // retry with same args (skips onBefore)Signals: data(), error(), loading() are all reactive.
Owner-local canonical reads with source changes, optional initial data, refresh, invalidation, abort, last-good data, and infinite pagination. Queries do not share a global cache or deduplicate requests across owners.
import { query } from "@k2b/stdlib/solid";
const workspace = query.create({
source: () => requestUrl(),
initial: {
source: props.requestUrl,
data: props.data,
},
enabled: () => !dialogOpen(),
load: async (url, { abortSignal, cause }) => {
const response = await fetch(url, { signal: abortSignal });
if (!response.ok) throw new Error("Could not load workspace");
return response.json();
},
});Matching initial data suppresses the first request. Without initial, the current source
loads automatically after the owner mounts on the client. Server rendering never starts a
load or subscription; SSR data is optional and is passed through initial when available.
Source changes load automatically and keep last-good data renderable until the new source
commits. Use isSameSource when source identity needs semantic comparison instead of
Object.is.
loading() means no data is available yet; refreshing() means a request is running while
last-good data remains renderable. invalidate(meta) marks the query stale and returns a
Promise that resolves only after a covering snapshot commits. Covered invalidations reject
when their load fails, the source changes, or the owner is disposed.
refresh() and loadMore() resolve when their attempt settles; load errors are exposed
through error(). Only invalidate() rejects because its Promise represents successful
coverage for cursor acknowledgement or similar adapter bookkeeping.
An optional subscribe callback is set up once for the owner and may call invalidate.
Transport setup, message parsing, authorization, cursor interpretation, and retry policy stay
in the adapter. The returned cleanup runs exactly once on owner disposal.
const conversations = query.createInfinite({
source: () => ({ mailboxId: props.mailboxId, folderId: folderId() }),
initial: {
source: props.initialSource,
pages: [props.initialPage],
},
loadPage: (source, { cursor, abortSignal }) =>
api.loadConversations({ ...source, cursor, signal: abortSignal }),
getNextCursor: (page) => page.nextCursor,
isSameSource: (left, right) =>
left.mailboxId === right.mailboxId && left.folderId === right.folderId,
});
const items = () => conversations.pages().flatMap((page) => page.items);loadMore() coalesces parallel calls and appends one page. Canonical refreshes and
invalidations rebuild the currently loaded page count from page one with newly returned
cursors, then commit the complete chain atomically. stdlib does not flatten or deduplicate
page items and does not observe the DOM for infinite scroll. A source change loads only the
first page of the new source while keeping the old chain renderable until that page commits.
Pausing through enabled aborts an active loadMore() without changing the last-good pages.
Reactive debounce and interval with automatic cleanup on component unmount.
import { timed } from "@k2b/stdlib/solid";
const { debouncedFn, trigger, cancel, isPending } = timed.debounce(
(text: string) => saveSearch(text),
500,
);
debouncedFn("hello"); // debounced
trigger("immediate"); // execute now
cancel(); // cancel pending
isPending(); // booleanconst { start, stop, execute, isRunning } = timed.interval(
() => fetchUpdates(),
5000,
{ autoStart: true, executeImmediately: true },
);
stop();
start();
execute(); // run callback once without affecting interval
isRunning(); // booleanGlobal keyboard shortcut registry. The mod alias resolves to Cmd on Mac, Ctrl elsewhere.
import { hotkeys } from "@k2b/stdlib/solid";
const { entries, dispose } = hotkeys.create({
"mod+s": { label: "Save", run: () => save() },
"mod+shift+z": { label: "Redo", run: () => redo() },
"mod+k": { label: "Search", run: () => openSearch(), desc: "Open search" },
"escape": { label: "Close", run: () => close(), inInput: true },
});
// entries() returns metadata for rendering a help overlay
// [{ keys: "mod+s", keysPretty: [{ key: "Cmd", ariaLabel: "Command" }, ...], label: "Save" }]Hotkeys are registered on mount and unregistered on cleanup. Duplicate combos are ignored with a warning.
Drag-and-drop with pointer and keyboard support. Uses SolidJS directives.
import { dnd } from "@k2b/stdlib/solid";
const { draggable, droppable, isDragging, activeId, overId } = dnd.create({
onDrop: ({ active, over, intent }) => {
if (over) reorder(active.id, over.id);
},
onDragStart: ({ active }) => console.log("Dragging:", active.id),
announcements: {
dragStart: (active) => `Picked up ${active.id}`,
drop: (active, over) => `Dropped ${active.id} on ${over?.id}`,
},
});
// JSX
<div use:draggable={{ id: "item-1", meta: item1 }}>Drag me</div>
<div use:droppable={{ id: "zone-a", meta: zoneA }}>Drop here</div>Supports activation distance, touch delay, custom collision detection, intent building, handle selectors, and ARIA live announcements.
The controller and its directives clean themselves up with their Solid owner; destroy() is also safe to call manually and permanently disables the controller. Keyboard events from nested buttons, links, and form controls do not start a drag unless they match handleSelector.
Touch scrolling is preserved by default. For unrestricted touch dragging, apply touch-action: none to a dedicated handle; use the draggable's touchAction: "none" option only when disabling pan/zoom for the whole element is intentional. The touch delay continues asynchronously after movement, so a moved pointer can activate when the delay elapses without requiring another pointermove.
Hybrid SSR + client-side detail panel pattern. Updates URL params without page reloads and supports browser back/forward.
import { detailPanel } from "@k2b/stdlib/solid";
// In the detail panel component
const { item, itemKey } = detailPanel.createPanel({
paramName: "user",
eventName: "user-detail-select",
initialItem: props.initialUser,
initialKey: props.initialUserId,
items: props.users,
getItemKey: (user) => user.id,
});
// In the list component
const { selectedKey, select, deselect } = detailPanel.createList({
paramName: "user",
eventName: "user-detail-select",
initialKey: props.selectedUserId,
});
<div onClick={() => select(user, user.id)}>
{user.name}
</div>Reactive SolidJS store with automatic localStorage persistence and cross-tab sync via BroadcastChannel.
For simple non-reactive cookie storage, see cookies from @k2b/stdlib/browser.
import { localStore } from "@k2b/stdlib/solid";
// Single record
const [user, setUser] = localStore.create("user", { name: "", email: "" });
setUser("name", "John"); // persisted + synced across tabs
// Query multiple keys
const [pads, reload] = localStore.query((key) => key.startsWith("pad:"));
// Direct operations
localStore.modify("user", (prev) => ({ ...prev, name: "Jane" }));
localStore.remove("user");
localStore.exists("user"); // boolean
localStore.read("user"); // T | nullReactive clipboard helpers with auto-resetting copy-feedback state.
import { clipboard } from "@k2b/stdlib/solid";
const { copy, wasCopied } = clipboard.create(2000);
<button onClick={() => copy("Hello!")}>
{wasCopied() ? "Copied!" : "Copy"}
</button>wasCopied() resets to false after the timeout (default 2000ms).
clipboard.create(timeout?) keeps the existing text-only API and catches Clipboard API errors.
Use clipboard.createWriter() when the consumer needs to write another value shape or
specialized clipboard content:
const resourceCopy = clipboard.createWriter({
write: writeCustomClipboardContent,
copiedFor: 1800,
});
const success = await resourceCopy.copy(value);
resourceCopy.wasCopied(); // true only after the latest successful write
resourceCopy.error(); // Error from the latest write, or nullcopy() returns true on success and false on failure. A new attempt clears old
feedback and errors immediately. Only the latest concurrent attempt may update reactive
state, and the Solid owner clears pending reset timers on disposal.
Click-outside detection using a ref callback. Uses mousedown to detect before the element's own click handlers fire.
import { clickOutside } from "@k2b/stdlib/solid";
const ref = clickOutside.create(() => setOpen(false));
<div ref={ref}>
Dropdown content
</div>Headless file drop zone with MIME type validation and nested-element-safe drag tracking.
import { dropzone } from "@k2b/stdlib/solid";
const { isDragging, invalidDrag, handlers } = dropzone.create({
onDrop: (files) => uploadFiles(files),
accept: "image/*",
});
<div
{...handlers}
classList={{
"border-blue-500": isDragging(),
"border-red-500": invalidDrag(),
}}
>
Drop images here
</div>Accessible event handler spreads for non-button interactive elements.
import { a11y } from "@k2b/stdlib/solid";
<div role="button" tabindex="0" {...a11y.clickOrEnter(handleAction)}>
Click or press Enter
</div>Returns { onClick, onKeyDown } handlers that fire on click, Enter, and Space.