ranui
Version:
A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.
210 lines (209 loc) • 10.2 kB
TypeScript
import type { EventManager } from './events';
import { type Getter } from './signal';
export interface Ref<T extends HTMLElement = HTMLElement> {
current: T | null;
}
export declare const createRef: <T extends HTMLElement = HTMLElement>() => Ref<T>;
/** A single directly-appendable child: element, text, nested builder, or nothing.
* Internal helper — the public, general type is {@link Child}. */
type StaticChild = HTMLElement | string | ElementBuilder<any> | undefined | null;
/**
* The one type every `children()` / `replaceChildren()` argument accepts —
* fully composable (recursive): a node, text, nested builder, `null`/`undefined`,
* a (nested) array, a {@link For}/{@link Index} handle for a keyed list, or a
* getter `() => Child` marking a reactive region. A getter (or `Show`/`Switch`
* branch) may itself return any `Child`, including a `For`/`Index` or another
* getter — control-flow nests freely. On SSR getters / `For` / `Index` render once.
*/
export type Child = StaticChild | ForHandle | IndexHandle | Child[] | (() => Child);
declare const FOR_BRAND = "__ranFor";
/** Options for {@link For}. */
export interface ForOptions<T> {
/** Reactive source array. Read inside an effect, so the list updates on change. */
each: () => readonly T[] | null | undefined;
/** Stable identity per item — **must be unique** within the list. Reused across
* updates to match old nodes to new items (that is what preserves DOM state). */
key: (item: T, index: number) => string | number;
/** Render one item to a single node. `index` is a **getter** (reactive): it
* reflects the item's current position even after the list reorders. Runs once
* per item (not on every list change) — drive per-item updates with signals. */
render: (item: T, index: Getter<number>) => StaticChild;
}
/** Opaque handle returned by {@link For}; pass it straight to `children()`. */
export type ForHandle = {
readonly [FOR_BRAND]: true;
};
/**
* Keyed list for `children()`. Unlike a plain getter child (which rebuilds the
* whole region on every change), `For` matches items by `key` and **reuses their
* DOM nodes** — only added/removed/moved items touch the DOM, so focus, scroll,
* input values and in-flight transitions inside surviving rows are preserved.
*
* Ul().children(
* For({
* each: () => rows(),
* key: (r) => r.id,
* render: (r, i) => Li().text(() => `${i()}. ${r.title}`),
* }),
* );
*
* On SSR the list is rendered once as a static snapshot. Must be built inside a
* `createRoot` so per-item scopes are disposed with the page.
*/
export declare function For<T>(options: ForOptions<T>): ForHandle;
/** Options for {@link Show}. */
export interface ShowOptions<T> {
/** Condition source. Truthy → `children`, falsy → `fallback`. */
when: () => T;
/** Built when `when` is truthy. May return any {@link Child} — a `For`/`Index`
* list, a nested `Show`, etc. Receives an accessor to the narrowed value —
* read it inside a binding (`.text(() => v())`) to update without rebuilding. */
children: (value: () => NonNullable<T>) => Child;
/** Built when `when` is falsy. Omitted → nothing is rendered. */
fallback?: () => Child;
}
/**
* Fine-grained conditional child. Unlike a raw getter child (which re-runs on
* **every** change it reads), `Show` rebuilds the branch **only when the
* truthiness of `when` flips** — the condition is memoized. Content inside a
* branch updates through its own bindings, not by re-running `Show`. This is the
* SwiftUI/Solid model: build once, toggle only when the branch actually changes.
*
* Show({
* when: () => user(), // reads a signal
* children: (u) => Div().text(() => u().name),
* fallback: () => Div().text('Signed out'),
* })
*
* `Show` returns a getter, so it is accepted anywhere `children()` takes a child.
* Must be created inside a `createRoot` (it owns a memo + the branch effect).
*/
export declare function Show<T>(options: ShowOptions<T>): () => Child;
/** One branch of a {@link Switch}; build with {@link Match}. */
export interface MatchClause<T> {
when: () => T;
children: (value: () => NonNullable<T>) => Child;
}
/** Declare one `Switch` branch (identity helper — gives per-clause type inference). */
export declare function Match<T>(clause: MatchClause<T>): MatchClause<T>;
/** Options for {@link Switch}. */
export interface SwitchOptions {
/** Branches, tried in order; the first with a truthy `when` renders. Clauses are
* heterogeneous (each `Match<T>` carries its own `T`), hence `any` here. */
children: MatchClause<any>[];
/** Rendered when no branch matches. Omitted → nothing. */
fallback?: () => Child;
}
/**
* Fine-grained multi-branch conditional (the n-way `Show`). Renders the first
* `Match` whose `when` is truthy, else `fallback`. Only the **index of the
* winning branch** is memoized, so it rebuilds only when the active branch
* changes — not on every change a `when` reads. Evaluation short-circuits at the
* first match (later branches aren't subscribed while an earlier one wins).
*
* Switch({
* fallback: () => Span().text('idle'),
* children: [
* Match({ when: () => status() === 'loading', children: () => Spinner() }),
* Match({ when: () => error(), children: (e) => ErrorView(e) }),
* ],
* })
*/
export declare function Switch(options: SwitchOptions): () => Child;
declare const INDEX_BRAND = "__ranIndex";
/** Options for {@link Index}. */
export interface IndexOptions<T> {
/** Reactive source array. */
each: () => readonly T[] | null | undefined;
/** Render the slot at a position. `item` is a **getter** (a signal): when the
* value at this index changes, it updates in place — the node is not rebuilt.
* `index` is a fixed number (the position never moves). */
render: (item: () => T, index: number) => StaticChild;
}
/** Opaque handle returned by {@link Index}; pass it straight to `children()`. */
export type IndexHandle = {
readonly [INDEX_BRAND]: true;
};
/**
* Position-keyed list. The node at index `i` is **reused** across updates and its
* `item` signal is updated in place — nodes never move. Use it when position is
* the identity (primitive arrays, fixed-length rows). Use {@link For} instead
* when items have a stable id and can reorder. SSR renders once.
*
* Ul().children(
* Index({ each: () => nums(), render: (n, i) => Li().text(() => `${i}: ${n()}`) }),
* );
*/
export declare function Index<T>(options: IndexOptions<T>): IndexHandle;
export declare class ElementBuilder<T extends HTMLElement = HTMLElement> {
private el;
constructor(tag: string);
id(value: string): this;
/**
* Apply a value now, or bind it reactively when a getter is passed.
* A getter creates an effect owned by the current reactive scope (createRoot),
* so the binding is disposed automatically when that scope is torn down.
*/
private bind;
class(name: string | Getter<string>): this;
addClass(...names: string[]): this;
removeClass(...names: string[]): this;
attr(name: string, value: string | Getter<string>): this;
attrs(values: Record<string, string | number | boolean | null | undefined>): this;
boolAttr(name: string, value: boolean | Getter<boolean>, enabledValue?: string): this;
part(value: string | Getter<string>): this;
data(key: string, value: string | Getter<string>): this;
style(keyOrMap: string | Record<string, string>, value?: string | Getter<string>): this;
cssVar(name: string, value: string | Getter<string>): this;
aria(key: string, value: string | Getter<string>): this;
role(value: string | Getter<string>): this;
tabIndex(value: number): this;
label(value: string | Getter<string>): this;
labelledBy(id: string | Getter<string>): this;
describedBy(id: string | Getter<string>): this;
ariaHidden(hidden?: boolean): this;
/**
* Permanent build-time listener — tied to the element's lifetime.
* Use for internal shadow DOM elements created in the constructor.
*/
on<K extends keyof HTMLElementEventMap>(type: K, listener: (this: T, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): this;
/**
* Lifecycle-managed listener — registered into an EventManager.
* Use in connectedCallback when building elements that need cleanup on disconnect.
* Call manager.abort() in disconnectedCallback to remove all listeners at once.
*/
listen<K extends keyof HTMLElementEventMap>(manager: EventManager, type: K, handler: (this: T, ev: HTMLElementEventMap[K]) => any, options?: Omit<AddEventListenerOptions, 'signal'>): this;
/**
* Delegated listener — the built element acts as the parent container.
* Fires handler only when the event originates from a descendant matching selector.
* Registered into an EventManager so it is cleaned up with manager.abort().
*
* Div().class('list')
* .children(...)
* .delegate(scope, '.item', 'click', (ev, item) => handleItem(item))
* .build();
*/
delegate<K extends keyof HTMLElementEventMap>(manager: EventManager, selector: string, type: K, handler: (ev: HTMLElementEventMap[K], target: Element) => void, options?: Omit<AddEventListenerOptions, 'signal'>): this;
children(...items: Child[]): this;
replaceChildren(...items: Child[]): this;
text(value: string | Getter<string>): this;
ref(holder: Ref<T>): this;
shadow(options?: ShadowRootInit): ShadowBuilder<T>;
build(): T;
serialize(): string;
}
export declare class ShadowBuilder<T extends HTMLElement = HTMLElement> {
private root;
private hostEl;
private options;
constructor(host: T, root: ShadowRoot, options: ShadowRootInit);
children(...items: Child[]): this;
adoptSheet(...sheets: CSSStyleSheet[]): this;
css(cssText: string): this;
done(): {
host: T;
shadow: ShadowRoot;
};
serialize(): string;
}
export {};