; every other case defaults to the inline
.
const element = tag ?? (skeleton && !children ? "div" : "span");
return jsx(element, out);
}
// ── ─────────────────────────────────────────────────────────────────
/** Props for {@link Pager}: the `paginator`, preserved `params`, and label/style overrides. */
/**
* What `` needs from a paginator. `Model.paginate()`'s result and the in-memory
* {@link Paginator} both carry this shape, so either renders the same pager.
*/
export interface PagerLike {
page: number;
lastPage: number;
onFirstPage: boolean;
hasMorePages: boolean;
elements(each?: number): (number | "...")[];
}
export interface PagerProps {
paginator: PagerLike;
/** Extra query params to keep in the page links (e.g. a search term). */
params?: Record;
/** Prefetch each page on hover. */
hover?: boolean;
/** Labels for the prev/next controls. */
prevLabel?: string;
nextLabel?: string;
class?: string;
linkClass?: string;
activeClass?: string;
[key: string]: unknown;
}
/**
* Renders a Prev / numbered / Next pager from a `Paginator` (the result of
* `Model.paginate()` or the in-memory `paginate()` helper). Links are SPA `navigate` anchors to
* `?page=N`, so they pair with `@url page` with no per-link wiring; renders nothing for
* a single page.
*
* @remarks
* Pass `params` to preserve other query state (search term, per-page, …). `hover`
* prefetches each page; `prevLabel`/`nextLabel` and the class props override the
* defaults. This is the links UI for a paginator.
*
* @example
* ```tsx
*
* ```
*
* @category Navigation & data
*/
export function Pager(props: PagerProps): HtmlNode {
const {
paginator: p,
params,
hover,
prevLabel = "‹ Prev",
nextLabel = "Next ›",
class: cls,
linkClass,
activeClass,
children: _ignore,
...rest
} = props as PagerProps & { children?: unknown };
if (!p || p.lastPage <= 1) return { html: "" };
const href = (page: number) => {
const sp = new URLSearchParams();
sp.set("page", String(page));
if (params)
for (const [k, v] of Object.entries(params))
if (v !== null && v !== undefined && v !== "") sp.set(k, String(v));
return "?" + sp.toString();
};
const base = "px-3 py-1.5 rounded-lg text-sm transition " + (linkClass ?? "");
const activeCls = activeClass ?? "bg-indigo-600 text-white font-semibold";
const idleCls = "bg-gray-800 hover:bg-gray-700 text-gray-300";
const link = (
page: number,
label: unknown,
opts: { active?: boolean; disabled?: boolean } = {},
) =>
opts.disabled
? jsx("span", {
"aria-disabled": "true",
class: base + "bg-gray-900 text-gray-700 cursor-not-allowed",
children: label,
})
: jsx("a", {
href: href(page),
navigate: true,
...(hover ? { navigateHover: true } : {}),
...(opts.active ? { "aria-current": "page" } : {}),
class: base + (opts.active ? activeCls : idleCls),
children: label,
});
const numbers = p
.elements()
.map((el, i) =>
el === "..."
? jsx("span", { key: `gap-${i}`, class: "px-2 text-gray-600", children: "…" })
: link(el as number, String(el), { active: el === p.page }),
);
return jsx("nav", {
...rest,
role: "navigation",
"aria-label": "Pagination",
class: "flex items-center justify-center gap-1 flex-wrap " + (cls ?? ""),
children: [
link(p.page - 1, prevLabel, { disabled: p.onFirstPage }),
...numbers,
link(p.page + 1, nextLabel, { disabled: !p.hasMorePages }),
],
});
}
// ── ──────────────────────────────────────────────────────────────
/** Props for {@link FileUpload}: the `bind` target plus `multiple`, `accept` and `label`. */
export interface FileUploadProps {
/** Bound @expose property — receives the TemporaryUploadedFile (or array). */
bind: unknown;
multiple?: boolean;
accept?: string;
label?: string;
class?: string;
/** Rendered below the dropzone — e.g. a preview/filename of the current value. */
children?: unknown;
[key: string]: unknown;
}
/**
* A dropzone bound to an `@expose` property. Selecting a file POSTs it to
* `/__flow/upload` over HTTP, shows live progress, and `$set`s a signed reference the
* server resolves into a `TemporaryUploadedFile`. Pairs with the `FileUploads` mixin.
*
* @remarks
* Bind `bind` to the receiving property; `multiple` uploads an array, `accept` filters
* the file picker, and `children` render below the dropzone (e.g. a preview of the
* current value).
*
* @example
* ```tsx
*
*
* ```
*
* @category Forms & inputs
*/
export function FileUpload(props: FileUploadProps): HtmlNode {
const { bind, multiple, accept, label = "Choose a file…", class: cls, children, ...rest } = props;
const name = _resolveReactiveName(bind);
const inputAttrs: Record = { type: "file", class: "sr-only" };
if (name) inputAttrs["flow:model"] = name; // file inputs upload over HTTP, then $set the ref
if (multiple) inputAttrs["multiple"] = true;
if (accept) inputAttrs["accept"] = accept;
const dropzone = jsx("label", {
class:
"flex flex-col items-center justify-center gap-1 cursor-pointer rounded-xl border-2 border-dashed " +
"border-gray-700 hover:border-gray-600 p-6 text-center text-sm text-gray-400 transition-colors",
children: [
jsx("input", inputAttrs),
jsx("span", { "x-show": "!uploading", children: label }),
jsx("span", { "x-show": "uploading", "x-text": "'Uploading… ' + progress + '%'" }),
],
});
const bar = jsx("div", {
"x-show": "uploading",
"x-cloak": true,
class: "mt-2 h-1.5 w-full rounded bg-gray-800 overflow-hidden",
children: jsx("div", {
class: "h-full bg-indigo-500 transition-all",
":style": "'width: ' + progress + '%'",
}),
});
const err = jsx("p", {
"x-show": "error",
"x-cloak": true,
"x-text": "error",
class: "mt-2 text-xs text-red-400",
});
return jsx("div", {
...rest,
"x-data": name
? `flowFileUpload({ name: ${jsLiteral(name)} })`
: "{ uploading: false, progress: 0, error: '' }",
class: "flow-fileupload " + (cls ?? ""),
children: [dropzone, bar, err, children],
});
}
// ── ─────────────────────────────────────────────────────────────────
/** Props for {@link Tooltip}: the tip text (`content`/`text`) and its `placement`. */
export interface TooltipProps {
content?: unknown;
/** Alias for `content`. */
text?: unknown;
placement?: "top" | "bottom";
class?: string;
tooltipClass?: string;
children?: unknown;
[key: string]: unknown;
}
/**
* Shows a small tip on hover/focus of its children, with `aria-describedby` wired to
* the tooltip. Client-only (Alpine), positioned with CSS relative to the trigger.
*
* @remarks
* Supply the tip via `content` (or its alias `text`); `placement` is `"top"` (default)
* or `"bottom"`. The trigger is whatever you pass as `children`.
*
* @example
* ```tsx
* 📋
* ```
*
* @category Overlays
*/
export function Tooltip(props: TooltipProps): HtmlNode {
const { content, text, placement = "top", class: cls, tooltipClass, children, ...rest } = props;
const tip = content ?? text;
const pos = placement === "bottom" ? "top-full mt-1" : "bottom-full mb-1";
return jsx("span", {
...rest,
"x-data": "{ open: false }",
"x-id": "['flow-tooltip']",
"x-on:mouseenter": "open = true",
"x-on:mouseleave": "open = false",
"x-on:focusin": "open = true",
"x-on:focusout": "open = false",
class: "relative inline-block " + (cls ?? ""),
children: [
jsx("span", { ":aria-describedby": "$id('flow-tooltip')", tabindex: 0, children }),
jsx("span", {
role: "tooltip",
":id": "$id('flow-tooltip')",
"x-show": "open",
"x-cloak": true,
"x-transition": true,
class:
"absolute left-1/2 -translate-x-1/2 z-50 whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-xs text-white shadow-lg " +
pos +
(tooltipClass ? " " + tooltipClass : ""),
children: tip,
}),
],
});
}
// ── ───────────────────────────────────────────────────────────────────
/** Props for {@link Alert}: the colour/role `variant`, `dismissible` flag and optional `title`. */
export interface AlertProps {
variant?: "info" | "success" | "warning" | "error";
dismissible?: boolean;
title?: unknown;
class?: string;
children?: unknown;
[key: string]: unknown;
}
const _ALERT_VARIANT: Record = {
info: "bg-blue-500/10 border-blue-500/40 text-blue-200",
success: "bg-emerald-500/10 border-emerald-500/40 text-emerald-200",
warning: "bg-amber-500/10 border-amber-500/40 text-amber-200",
error: "bg-red-500/10 border-red-500/40 text-red-200",
};
/**
* An inline, statically-styled alert box. `variant` (default `"info"`) sets the colour
* and ARIA role — `error`/`warning` announce assertively as `role="alert"`, others as
* `role="status"`.
*
* @remarks
* `title` renders a bold heading above the body; `dismissible` adds a client-only ×
* that hides the alert with no server round-trip.
*
* @example
* ```tsx
* Saved!
* ```
*
* @category Feedback
*/
export function Alert(props: AlertProps): HtmlNode {
const { variant = "info", dismissible, title, class: cls, children, ...rest } = props;
const role = variant === "error" || variant === "warning" ? "alert" : "status";
const body: unknown[] = [];
if (title !== undefined && title !== null) {
body.push(jsx("div", { class: "font-semibold mb-0.5", children: title }));
}
body.push(jsx("div", { class: "min-w-0 flex-1", children }));
if (dismissible) {
body.push(
jsx("button", {
type: "button",
"aria-label": "Dismiss",
"x-on:click": "shown = false",
class: "shrink-0 text-current/70 hover:text-current text-lg leading-none",
children: "×",
}),
);
}
return jsx("div", {
...rest,
role,
"x-data": "{ shown: true }",
"x-show": "shown",
class:
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm " +
(_ALERT_VARIANT[variant] ?? _ALERT_VARIANT["info"]) +
(cls ? " " + cls : ""),
children: body,
});
}
// ── ───────────────────────────────────────────────────────────────────
/** A {@link Table} column definition: its `key`, `label`, `sortable` flag and optional cell `render`. */
export interface TableColumn> {
key: string;
label: unknown;
sortable?: boolean;
/** Custom cell renderer; defaults to `row[key]`. */
render?: (row: T) => unknown;
/** Extra classes for this column's cells. */
class?: string;
}
/** Props for {@link Table}: the `columns` and `rows`, current `sortBy`/`sortDir`, and styling. */
export interface TableProps> {
columns: TableColumn[];
rows: T[];
sortBy?: string;
sortDir?: "asc" | "desc";
/** Extra query params to keep in sort links (e.g. a search term). */
params?: Record;
/** Field used as the row key (defaults to the first column's key). */
rowKey?: string;
hover?: boolean;
class?: string;
theadClass?: string;
[key: string]: unknown;
}
/**
* A data table with URL-driven sortable headers. Clicking a sortable header navigates
* to `?sortBy=key&sortDir=asc|desc` (toggling direction) — pair with `@url sortBy` /
* `@url sortDir` and sort the rows server-side in `render()`.
*
* @remarks
* Cells default to `row[col.key]`; give a column a `render` for custom content. `params`
* preserves other query state (e.g. a search term), `hover` highlights rows, and `rowKey`
* (default the first column's key) identifies rows for morph-stable reordering.
*
* @example
* ```tsx
*
* ```
*
* @category Navigation & data
*/
export function Table>(props: TableProps): HtmlNode {
const {
columns,
rows,
sortBy,
sortDir,
params,
rowKey,
hover,
class: cls,
theadClass,
children: _ignore,
...rest
} = props as TableProps & { children?: unknown };
const keyField = rowKey ?? columns[0]?.key ?? "id";
const sortHref = (key: string) => {
const nextDir = key === sortBy && sortDir === "asc" ? "desc" : "asc";
const sp = new URLSearchParams();
sp.set("sortBy", key);
sp.set("sortDir", nextDir);
if (params)
for (const [k, v] of Object.entries(params))
if (v !== null && v !== undefined && v !== "") sp.set(k, String(v));
return "?" + sp.toString();
};
const headCells = columns.map((col) => {
if (!col.sortable) {
return jsx("th", {
scope: "col",
class: "px-3 py-2 text-left font-semibold text-gray-300",
children: col.label,
});
}
const active = col.key === sortBy;
const indicator = active ? (sortDir === "asc" ? " ▲" : " ▼") : "";
return jsx("th", {
scope: "col",
"aria-sort": active ? (sortDir === "asc" ? "ascending" : "descending") : "none",
class: "px-3 py-2 text-left font-semibold text-gray-300",
children: jsx("a", {
href: sortHref(col.key),
navigate: true,
...(hover ? { navigateHover: true } : {}),
class: "inline-flex items-center gap-0.5 hover:text-white",
children: [col.label, indicator],
}),
});
});
const bodyRows = rows.map((row) =>
jsx("tr", {
"flow:key": String(row[keyField] ?? ""), // morph keys rows by identity (sort/filter reorders)
class: "border-t border-gray-800 " + (hover ? "hover:bg-gray-800/40" : ""),
children: columns.map((col) =>
jsx("td", {
class: "px-3 py-2 text-gray-200 " + (col.class ?? ""),
children: col.render ? col.render(row) : (row[col.key] as unknown),
}),
),
}),
);
return jsx("table", {
...rest,
class: "w-full text-sm border-collapse " + (cls ?? ""),
children: [
jsx("thead", { class: theadClass ?? "", children: jsx("tr", { children: headCells }) }),
jsx("tbody", { children: bodyRows }),
],
});
}
// ── ──────────────────────────────────────────────────────────────────
/** Props for {@link Drawer}: the `show` binding, the `side` it slides from, and title/close options. */
export interface DrawerProps {
show?: unknown;
/** Explicit prop name override — use when auto-resolve is ambiguous. */
name?: string;
onClose?: unknown;
title?: unknown;
closable?: boolean;
side?: "left" | "right" | "top" | "bottom";
/** Extra classes for the panel. */
class?: string;
children?: unknown;
[key: string]: unknown;
}
const _DRAWER_SIDE: Record = {
right: { pos: "inset-y-0 right-0 h-full w-80 max-w-full", closed: "translate-x-full" },
left: { pos: "inset-y-0 left-0 h-full w-80 max-w-full", closed: "-translate-x-full" },
top: { pos: "inset-x-0 top-0 w-full max-h-full", closed: "-translate-y-full" },
bottom: { pos: "inset-x-0 bottom-0 w-full max-h-full", closed: "translate-y-full" },
};
/**
* A slide-over panel — the edge-anchored sibling of {@link Modal}. Bind `show` to an
* `@expose` boolean; backdrop click, the × button and Escape all close it locally with
* no server round-trip, and focus is trapped while open.
*
* @remarks
* Slides in from `side` (default `"right"`; also `"left"`, `"top"`, `"bottom"`). `title`
* fills the header, `closable` (default `true`) renders the × button, and `onClose`
* overrides the default close action. The prop name is auto-resolved; override with `name`.
*
* @example
* ```tsx
*
*
*
* ```
*
* @category Overlays
*/
export function Drawer(props: DrawerProps): HtmlNode {
const {
show,
name: nameProp,
onClose,
title,
closable = true,
side = "right",
class: panelClass,
children,
...rest
} = props;
const name = nameProp ?? _injectedBindKey(props, "show") ?? _resolveReactiveName(show);
const open = Boolean(show);
const s = _DRAWER_SIDE[side] ?? _DRAWER_SIDE["right"]!;
const shownExpr = name ? `$flow.${name}` : open ? "true" : "false";
const inAxis = side === "left" || side === "right" ? "translate-x-0" : "translate-y-0";
const closeProps: Record = onClose
? { onClick: onClose }
: name
? { "flow:click": `$flow.${name} = false` }
: {};
const backdrop = jsx("div", {
"x-show": shownExpr,
"x-cloak": true,
"x-transition:enter": "transition-opacity ease-out duration-300",
"x-transition:enter-start": "opacity-0",
"x-transition:enter-end": "opacity-100",
"x-transition:leave": "transition-opacity ease-in duration-200",
"x-transition:leave-start": "opacity-100",
"x-transition:leave-end": "opacity-0",
class: "fixed inset-0 z-40 bg-black/50",
...closeProps,
});
const titleId = name ? `flow-drawer-title-${name}` : undefined;
const header =
title || closable
? jsx("div", {
class: "flex items-center justify-between gap-4 mb-3",
children: [
title
? jsx("h2", { id: titleId, class: "text-lg font-semibold", children: title })
: "",
closable
? jsx("button", {
type: "button",
"aria-label": "Close",
class: "text-gray-400 hover:text-white text-2xl leading-none",
children: "×",
...closeProps,
})
: "",
],
})
: "";
const panelOut: Record = {
role: "dialog",
"aria-modal": "true",
"x-show": shownExpr,
"x-cloak": true,
"x-transition:enter": "transform transition ease-out duration-300",
"x-transition:enter-start": s.closed,
"x-transition:enter-end": inAxis,
"x-transition:leave": "transform transition ease-in duration-200",
"x-transition:leave-start": inAxis,
"x-transition:leave-end": s.closed,
class:
"fixed z-50 bg-gray-900 border border-gray-800 p-5 shadow-xl text-white overflow-auto " +
s.pos +
" " +
(panelClass ?? ""),
children: [header, children],
};
if (name) {
panelOut["data-flow-modal"] = name; // Escape-to-close (shared bridge handler)
panelOut["x-trap.noscroll"] = shownExpr; // focus trap + scroll lock, as Modal
if (title) panelOut["aria-labelledby"] = titleId;
}
// `contents` wrapper creates no box, so the fixed backdrop/panel aren't blocked
// and nothing intercepts clicks when closed (both are display:none via x-show).
return jsx("div", { ...rest, class: "contents", children: [backdrop, jsx("div", panelOut)] });
}
// ── ───────────────────────────────────────────────────────────
/** Props for {@link ErrorBoundary}: the protected `children` and what to show instead on failure. */
export interface ErrorBoundaryProps {
/** Markup to render when a child fails. A function receives the thrown error. */
fallback?: HtmlNode | ((error: unknown) => HtmlNode);
/** Called server-side when a child fails, for logging or reporting. */
onError?: (error: unknown) => void;
/** Wrapper element. Defaults to `"div"`. */
tag?: string;
class?: string;
children?: unknown;
[key: string]: unknown;
}
/**
* Contains a failure in a nested ` ` so it costs that component
* rather than the page.
*
* @remarks
* Without a boundary, a child component that throws while mounting or rendering
* takes the whole response with it — one broken widget blanks the dashboard.
* Wrapped, the child is replaced by `fallback` and everything around it renders.
*
* What it covers is child *components*. Inline JSX in the same `render()` is evaluated before
* this component is ever called, so a throw there cannot be intercepted here —
* move the risky work into a child component, which is where it belongs anyway.
* Boundaries nest, and the innermost one wins.
*
* The failure is reported through `onError` and logged; it is contained, not
* hidden.
*
* @example
* ```tsx
* Sales data unavailable.}>
*
*
* ```
*
* @category Feedback
*/
export function ErrorBoundary(props: ErrorBoundaryProps): HtmlNode {
const { fallback, onError, tag, class: cls, children, ...rest } = props;
// Children are already rendered, and their child-component thunks already
// queued but not yet run, so the tokens sitting in this string are exactly the
// pending children this boundary is responsible for.
const inner = _renderChild(children);
_protectThunks(inner, (error) => {
try {
onError?.(error);
} catch {
// A reporting hook that throws must not replace the original failure with
// a less useful one.
}
console.error("[Flow] A component inside failed to render:", error);
if (typeof fallback === "function") return fallback(error).html;
if (fallback) return fallback.html;
return (
'Something went wrong loading this section.
'
);
});
const out: Record = { ...rest, children: { html: inner } };
out["data-flow-boundary"] = true;
if (cls) out["class"] = cls;
return jsx(tag ?? "div", out);
}
// ── / ────────────────────────────────────────
/** Props for {@link SectionContent}: the `name` to publish under, and the markup. */
export interface SectionContentProps {
/** Section name. Must match a {@link SectionOutlet}. */
name: string;
children?: unknown;
}
/**
* Publish markup to a named section, rendered wherever the matching
* {@link SectionOutlet} sits — typically in the layout.
*
* @remarks
* This renders nothing where it appears. It is how a page reaches a region it
* does not own: a page-specific toolbar button, a breadcrumb trail, a title. The
* alternative is threading content through every component in between as props,
* or the layout knowing about every page that might contribute.
*
* Two components may publish to one name; their content accumulates in render
* order rather than one replacing the other.
*
* Sections resolve once per document render. A WebSocket patch re-renders a
* component, not the layout, so content published during one does not reach an
* outlet outside the component being patched.
*
* @example
* ```tsx
*
* Publish
*
* ```
*
* @category Layout
*/
export function SectionContent(props: SectionContentProps): HtmlNode {
publishSection(props.name, _renderChild(props.children));
return { html: "" };
}
/** Props for {@link SectionOutlet}: the `name` to render, plus optional default content. */
export interface SectionOutletProps {
/** Section name. Must match a {@link SectionContent}. */
name: string;
/** Rendered when nothing was published to this section. */
children?: unknown;
}
/**
* Render whatever was published to a named section.
*
* @remarks
* Place it in a layout to give pages a hole to fill. Children are the default,
* used when no page published anything.
*
* Order does not matter: the outlet reserves its place and is filled after the
* page *and* the layout have rendered. That is what makes the usual arrangement
* work — outlet in the layout, content in the page it wraps — where reading a
* store at render time would always be too early.
*
* @example
* ```tsx
*
* ```
*
* @category Layout
*/
export function SectionOutlet(props: SectionOutletProps): HtmlNode {
return { html: reserveSection(props.name, _renderChild(props.children)) };
}
// ── ──────────────────────────────────────────────────────────────
/** Props for {@link Virtualize}: the current window of rows and how to ask for another. */
export interface VirtualizeProps {
/** The rows to render — the current window, not the whole collection. */
items: readonly T[];
/** Height of one row in pixels. Every row must be this tall. */
itemHeight: number;
/** Total rows in the full collection. Defaults to `items.length`. */
total?: number;
/** Index of `items[0]` within the full collection. Defaults to 0. */
start?: number;
/** Height of the scroll viewport in pixels. Defaults to 400. */
height?: number;
/**
* Server action requesting a different window, called as `(start, count)`.
* Omit when the collection is small enough to render whole.
*/
onWindow?: unknown;
/** Rows rendered beyond the viewport on each side, to hide fetch latency. Defaults to 6. */
overscan?: number;
class?: string;
/** The row template: `(item, index) => …
`, where `index` is absolute. */
children: (item: T, index: number) => HtmlNode;
}
/**
* A scrolling window over a collection too large to put in the DOM.
*
* @remarks
* Only the visible rows exist as elements; spacers above and below hold the
* scrollbar at the size the full collection implies. As the viewport moves, the
* component asks the server for the window it now needs via `onWindow` — a
* server-authoritative arrangement, which means the collection never has to
* reach the client in full.
*
* Rows must be a known, uniform `itemHeight`. That is what lets a scroll offset
* be turned into an index without measuring anything.
*
* Distinct from {@link InfiniteScroll}, which appends and grows the DOM without
* bound. Reach for this when *keeping* every rendered row is the problem.
*
* @example
* ```tsx
*
* {(row) => {row.name}
}
*
* ```
*
* @category Navigation & data
*/
export function Virtualize(props: VirtualizeProps): HtmlNode {
const {
items,
itemHeight,
total,
start = 0,
height = 400,
onWindow,
overscan = 6,
class: cls,
children,
} = props;
const rows = items ?? [];
const count = total ?? rows.length;
const visible = Math.max(1, Math.ceil(height / itemHeight));
let body = "";
for (let i = 0; i < rows.length; i++) {
const node = children(rows[i]!, start + i);
body += node && typeof node === "object" && "html" in node ? node.html : "";
}
// Spacers rather than absolutely-positioned rows: the browser keeps the
// scrollbar honest without every row needing a computed `top`, and the row
// template stays ordinary markup the caller can style however they like.
const above = start * itemHeight;
const below = Math.max(0, (count - start - rows.length) * itemHeight);
const state =
`{ h: ${itemHeight}, v: ${visible}, o: ${overscan}, at: ${start}, ` +
`win() { const s = Math.max(0, Math.floor($el.scrollTop / this.h) - this.o); ` +
`const c = this.v + this.o * 2; ` +
// Only ask when the window actually moved: scroll fires far more often than
// the set of needed rows changes, and every request is a round trip.
`if (s === this.at) return; this.at = s; ${_virtualizeRequest(onWindow)} } }`;
return jsx("div", {
...(cls ? { class: cls } : {}),
"x-data": state,
"x-on:scroll.passive.throttle.100ms": "win()",
style: `height:${height}px;overflow-y:auto`,
"data-flow-virtualize": true,
children: {
html:
`
` +
body +
`
`,
},
});
}
/**
* The client expression that asks for a new window.
*
* A method reference arrives as a function whose `name` is the server action to
* call. Anything else means no provider was supplied, in which case scrolling
* just moves over rows that are already present.
*/
function _virtualizeRequest(onWindow: unknown): string {
if (typeof onWindow !== "function") return "";
const method = (onWindow as { name?: string }).name;
if (!method) return "";
return `$flow.${method}(s, c);`;
}