Skip to content

Latest commit

 

History

History
329 lines (249 loc) · 10.6 KB

File metadata and controls

329 lines (249 loc) · 10.6 KB

Solid Modules

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.

mutation

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.

query

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.

Infinite queries

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.

timed

Reactive debounce and interval with automatic cleanup on component unmount.

Debounce

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();              // boolean

Interval

const { start, stop, execute, isRunning } = timed.interval(
  () => fetchUpdates(),
  5000,
  { autoStart: true, executeImmediately: true },
);

stop();
start();
execute();    // run callback once without affecting interval
isRunning();  // boolean

hotkeys

Global 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.

dnd

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.

detailPanel

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>

localStore

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 | null

clipboard

Reactive 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 null

copy() 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.

clickOutside

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>

dropzone

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>

a11y

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.