/** * Native Flow components — the built-in library of PascalCase function * components used inside a `Component`'s `render()`. Each one compiles to * ordinary HTML elements plus Flow/Alpine directives, so you use them like * any JSX element: * * ```tsx * Posts * Dashboard * * ``` */ import { jsx, _resolveReactiveName, _resolveBindName, _injectedBindKey, _renderChild, _protectThunks, } from "./jsx-runtime.ts"; import type { HtmlNode } from "./jsx-runtime.ts"; import { publishSection, reserveSection } from "./sections.ts"; import { jsLiteral } from "./utils.ts"; // ── ──────────────────────────────────────────────────────────────── /** Props for {@link Link}: the target `href` plus SPA-navigation and active-state flags. */ export interface LinkProps { href: string; hover?: boolean; /** * Prefetch on pointer-down instead of on hover. * * The right choice for a row in a long list: hover prefetch fires for every * link the pointer crosses on its way somewhere else, which on a dense table * is a fetch per row nobody chose. Pointer-down fires once, on the one link * the reader has committed to, and still lands ahead of the click. */ down?: boolean; current?: boolean; exact?: boolean; preserveScroll?: boolean; [key: string]: unknown; } /** * An `` wired for Flow SPA navigation, with automatic URL-based active state * that you can override or refine per link. * * @remarks * `hover` prefetches the target on hover and `down` on pointer-down (the one to reach * for inside a long list); `current` forces (`true`) or suppresses * (`false`) the active state instead of deriving it from the URL; `exact` restricts * the automatic active state to an exact path match. Any other props pass through to * the ``. * * Following a link lands at the top of the new page, as a full navigation would — * or at the fragment, if the href names one. `preserveScroll` leaves the viewport * where it is instead, which is what you want for a control the user is looking at * partway down a page (a filter, a sort header, a tab strip) rather than a link * that takes them somewhere else. * * @example * ```tsx * Posts * Dashboard * Title * ``` * * @category Navigation & data */ export function Link(props: LinkProps & { children?: unknown }): HtmlNode { const { href, hover, down, current, exact, preserveScroll, children, ...rest } = props; const out: Record = { ...rest, href, navigate: true, children }; if (hover) out["navigateHover"] = true; if (down) out["navigateDown"] = true; if (preserveScroll) out["navigatePreserveScroll"] = true; // `current` overrides the automatic URL-based active state: // false → never active (flow:current.ignore) // true → always active, and stays active across SPA navigations (flow:current.force). // `data-current` is also set here so the link renders active on first paint, // before the client runtime runs. if (current === false) out["flow:current.ignore"] = true; if (current === true) { out["flow:current.force"] = true; out["data-current"] = ""; } // `exact` restricts the automatic active state to an exact path match, so an index // link (e.g. /section) isn't marked current on its own sub-pages (/section/child). if (exact) out["flow:current.exact"] = true; return jsx("a", out); } // ── ──────────────────────────────────────────────────────────────── /** * Marks its children as document `` content — they are hoisted into the * page head (and reconciled across SPA navigations) rather than rendered inline. * * @example * ```tsx * * Dashboard * * * ``` * * @category Document head */ export function Head(props: { children?: unknown }): HtmlNode { return jsx("template", { "data-flow-head": true, children: props.children }); } // ── ─────────────────────────────────────────────────────────────── /** * Preserves its subtree untouched across SPA navigations and morphs — the element * with the given `name` is carried over instead of re-rendered, so media keeps * playing and DOM state survives. * * @remarks * The children are also marked `flow:ignore`, so Flow never patches inside them. * * @example * ```tsx * * * ``` * * @category Navigation & data */ export function Persist(props: { name: string; children?: unknown }): HtmlNode { return jsx("div", { "data-flow-persist": props.name, "flow:ignore": true, children: props.children, }); } // ── ─────────────────────────────────────────────────────────────────── /** * Shorthand for setting the document `<title>` — wraps its children in a `<title>` * inside a {@link Head}. * * @example * ```tsx * <Title>Dashboard * ``` * * @category Document head */ export function Title(props: { children?: unknown }): HtmlNode { return Head({ children: jsx("title", { children: props.children }) }); } // ── ─────────────────────────────────────────────────────────────────── /** Props for {@link Modal}: the `show` binding plus title, close behaviour and panel styling. */ export interface ModalProps { show?: unknown; /** Explicit prop name override — use when auto-resolve is ambiguous (multiple @expose props share the same value). */ name?: string; onClose?: unknown; title?: unknown; closable?: boolean; class?: string; children?: unknown; [key: string]: unknown; } /** * A centered, backdrop-dimmed dialog whose visibility is driven by a bound `@expose` * boolean; backdrop click and the × button close it locally (no server round-trip) * and focus is trapped while open. * * @remarks * Bind `show` to a reactive boolean — its name is auto-resolved (override with `name`). * `closable` (default `true`) renders the × button, `title` fills the header and wires * `aria-labelledby`, and `onClose` replaces the default `$flow. = false` close action. * * @example * ```tsx * *

This can't be undone.

*
* ``` * * @category Overlays */ export function Modal(props: ModalProps): HtmlNode { const { show, name: nameProp, onClose, title, closable = true, class: panelClass, children, ...rest } = props; const name = nameProp ?? _injectedBindKey(props, "show") ?? _resolveReactiveName(show); const open = Boolean(show); const closeProps: Record = onClose ? { onClick: onClose } : name ? { "flow:click": `$flow.${name} = false` } : {}; const backdrop = jsx("div", { class: "absolute inset-0 bg-black/50", ...closeProps, }); const titleId = name ? `flow-modal-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 panel = jsx("div", { class: "relative z-10 w-full max-w-lg rounded-xl bg-gray-900 border border-gray-800 p-5 shadow-xl text-white " + (panelClass ?? ""), "flow:transition": true, children: [header, children], }); const overlay: Record = { ...rest, role: "dialog", "aria-modal": "true", class: "fixed inset-0 z-50 flex items-center justify-center p-4", children: [backdrop, panel], }; if (name) { overlay["flow:show"] = name; overlay["data-flow-modal"] = name; // `.noscroll` locks the body while the dialog is open. Without it focus is // trapped correctly and the page behind still scrolls under a finger or a // wheel, which on a phone reads as the dialog having broken the page. overlay["x-trap.noscroll"] = `$flow.${name}`; // focus trap + scroll lock (@alpinejs/focus) if (title) overlay["aria-labelledby"] = titleId; } if (!open) overlay["style"] = { display: "none" }; return jsx("div", overlay); } // ── ─────────────────────────────────────────────────────────────────── /** Props for {@link Flash}: the default toast `position` and auto-dismiss `duration`. */ export interface FlashProps { position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center"; duration?: number; [key: string]: unknown; } // Container positioning is INLINE (not Tailwind classes): the markup lives // in the flow package, which an app's Tailwind `@source` paths don't scan — so any // utility classes here get purged and the toast collapses to the page corner. Inline // styles make the component self-contained and correct in every app. const _FLASH_POS: Record> = { "top-right": { top: "1rem", right: "1rem", alignItems: "flex-end" }, "top-left": { top: "1rem", left: "1rem", alignItems: "flex-start" }, "bottom-right": { bottom: "1rem", right: "1rem", alignItems: "flex-end" }, "bottom-left": { bottom: "1rem", left: "1rem", alignItems: "flex-start" }, "top-center": { top: "1rem", left: "50%", transform: "translateX(-50%)", alignItems: "center" }, "bottom-center": { bottom: "1rem", left: "50%", transform: "translateX(-50%)", alignItems: "center", }, }; /** * The toast host — a single fixed container plus the client runtime that renders * flash messages emitted by the server (via `flow:flash` window events) as styled, * self-dismissing toasts. Place it once, near the root of your layout. * * @remarks * `position` (default `"bottom-right"`) and `duration` in ms (default `4000`) are the * defaults for toasts that don't specify their own; individual toasts can override * position, duration, icon, actions and more via the emitted payload. * * @example * ```tsx * * ``` * * @category Feedback */ export function Flash(props: FlashProps = {}): HtmlNode { const position = props.position ?? "bottom-right"; const duration = props.duration ?? 4000; const pos = _FLASH_POS[position] ?? _FLASH_POS["bottom-right"]; const script = `(function(){ if (window.__flowFlash) return; window.__flowFlash = 1; var root = document.getElementById('flow-flash'); if (!root) return; var defDur = Number(root.getAttribute('data-duration')) || 4000; var defPos = root.getAttribute('data-position') || 'bottom-right'; var FONT = 'system-ui,-apple-system,Segoe UI,Roboto,sans-serif'; var COLORS = { success:'#16a34a', error:'#dc2626', danger:'#dc2626', warning:'#d97706', info:'#2563eb' }; // Inline position styles, mirrored from the server _FLASH_POS map, so per-toast // positions get a fixed container even though Tailwind classes here are purged. var POS = { 'top-right':'top:1rem;right:1rem;align-items:flex-end', 'top-left':'top:1rem;left:1rem;align-items:flex-start', 'bottom-right':'bottom:1rem;right:1rem;align-items:flex-end', 'bottom-left':'bottom:1rem;left:1rem;align-items:flex-start', 'top-center':'top:1rem;left:50%;transform:translateX(-50%);align-items:center', 'bottom-center':'bottom:1rem;left:50%;transform:translateX(-50%);align-items:center' }; var ICONS = { success: '', error: '', warning: '', info: '' }; ICONS.danger = ICONS.error; var X = ''; function esc(s){ return String(s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } // One fixed container per position. The default position reuses the server-rendered // #flow-flash element; others are created lazily and appended to . var containers = {}; function container(pos){ if (pos === defPos) return root; if (containers[pos]) return containers[pos]; var c = document.createElement('div'); c.style.cssText = 'position:fixed;z-index:50;display:flex;flex-direction:column;gap:8px;pointer-events:none;'+(POS[pos]||POS[defPos]||''); document.body.appendChild(c); containers[pos] = c; return c; } // Theme is resolved per-toast so it tracks the live OS/app theme: honour an explicit // .dark / .light class on first, else fall back to prefers-color-scheme. function theme(){ var el = document.documentElement; var dark = el.classList.contains('dark') || (!el.classList.contains('light') && window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches); return dark ? { bg:'#1f2937', ring:'rgba(255,255,255,.1)', title:'#f9fafb', body:'#9ca3af', close:'#6b7280', btnBg:'#374151', btnText:'#e5e7eb' } : { bg:'#ffffff', ring:'rgba(0,0,0,.05)', title:'#111827', body:'#6b7280', close:'#9ca3af', btnBg:'#f3f4f6', btnText:'#374151' }; } function toast(opt){ var level = String(opt.level || 'info'); var color = COLORS[level] || '#334155'; var c = theme(); var dur = (opt.duration != null) ? Number(opt.duration) : defDur; var sticky = !(dur > 0); var dismissible = (opt.dismissible != null) ? !!opt.dismissible : true; if (sticky) dismissible = true; // never trap the user with an unclosable toast var pos = opt.position || defPos; var host = container(pos); // Icon: explicit string = custom (emoji/text); false = hidden; else status SVG. var showIcon = opt.icon !== false; var iconHtml = ''; if (showIcon) { iconHtml = (typeof opt.icon === 'string' && opt.icon) ? esc(opt.icon) : (ICONS[level] || ICONS.info); } // Title/body: explicit title wins (message becomes the body); otherwise the // first line of the message is the title and the rest is the body. var msg = String(opt.message || ''), title, body; if (opt.title != null && opt.title !== '') { title = String(opt.title); body = msg; } else { var parts = msg.split('\\n'); title = parts.shift() || ''; body = parts.join(' ').trim(); } var t = document.createElement('div'); t.setAttribute('role', level === 'error' || level === 'warning' ? 'alert' : 'status'); t.style.cssText = 'position:relative;overflow:hidden;pointer-events:auto;display:flex;gap:12px;align-items:flex-start;'+ 'width:24rem;max-width:calc(100vw - 2rem);background:'+c.bg+';border-radius:12px;padding:16px;'+ 'box-shadow:0 10px 25px rgba(0,0,0,.15),0 0 0 1px '+c.ring+';'+ 'opacity:0;transform:translateY(8px);transition:opacity .2s ease, transform .2s ease;'; // Action buttons — inline-styled from a constrained set (color/variant/uppercase) // so they always render, even though runtime toasts can't use Tailwind classes. function btnStyle(a){ var accent = a.color ? (COLORS[a.color] || a.color) : ''; var variant = a.variant || 'soft'; var base = 'font:600 13px '+FONT+';padding:6px 12px;border-radius:8px;border:0;cursor:pointer;'+ (a.uppercase ? 'text-transform:uppercase;letter-spacing:.04em;font-size:12px;' : ''); if (variant === 'solid') return base+'background:'+(accent||'#4f46e5')+';color:#fff;'; if (variant === 'ghost') return base+'background:none;padding-left:2px;padding-right:2px;color:'+(accent||c.btnText)+';'; return base+'background:'+c.btnBg+';color:'+(accent||c.btnText)+';'; } var actions = Array.isArray(opt.actions) ? opt.actions : []; var btns = ''; for (var i=0;i'+esc(a.label)+''; } t.innerHTML = (showIcon ? ''+iconHtml+'' : '')+ '
'+ '

'+esc(title)+'

'+ (body ? '

'+esc(body)+'

' : '')+ (btns ? '
'+btns+'
' : '')+ '
'+ (dismissible ? '' : ''); // Countdown progress bar (only meaningful while auto-dismissing). if (opt.progressBar && !sticky) { var bar = document.createElement('div'); bar.style.cssText = 'position:absolute;left:0;bottom:0;height:3px;width:100%;background:'+color+';opacity:.5;transition:width '+dur+'ms linear;'; t.appendChild(bar); requestAnimationFrame(function(){ bar.style.width = '0%'; }); } // Invoke a @expose method on the emitting component via the bridge (window event). function invoke(spec){ if (!spec || !spec.method) return; window.dispatchEvent(new CustomEvent('flow:invoke', { detail: { component: opt.component, method: spec.method, args: spec.args || [] }})); } host.appendChild(t); requestAnimationFrame(function(){ t.style.opacity='1'; t.style.transform='none'; }); var done = false; function dismiss(){ if(done) return; done=true; invoke(opt.onClose); t.style.opacity='0'; t.style.transform='translateY(8px)'; setTimeout(function(){ t.remove(); }, 200); } var closeBtn = t.querySelector('[data-flow-close]'); if (closeBtn) closeBtn.addEventListener('click', dismiss); Array.prototype.forEach.call(t.querySelectorAll('[data-flow-action]'), function(btn){ var idx = Number(btn.getAttribute('data-flow-action')); btn.addEventListener('click', function(){ invoke(actions[idx]); dismiss(); }); }); if (!sticky) setTimeout(dismiss, dur); } window.addEventListener('flow:flash', function(e){ toast((e && e.detail) || {}); }); })();`; return jsx("div", { id: "flow-flash", "data-duration": duration, "data-position": position, style: { position: "fixed", zIndex: "50", display: "flex", flexDirection: "column", gap: "8px", pointerEvents: "none", ...pos, }, children: jsx("script", { dangerouslySetInnerHTML: { __html: script } }), }); } // ── ────────────────────────────────────────────────────────────────── /** Props for {@link Errors}: an optional `only` filter limiting which fields' errors show. */ export interface ErrorsProps { only?: string | string[]; class?: string; [key: string]: unknown; } /** * A reactive validation-error summary — a `role="alert"` list that stays hidden until * the component has errors, then shows the current messages. * * @remarks * `only` limits the summary to one or more named fields (a string or array of field * names); omit it to show every error. * * @example * ```tsx * * * ``` * * @category Forms & inputs */ export function Errors(props: ErrorsProps = {}): HtmlNode { const { only, class: cls, children: _ignore, ...rest } = props as ErrorsProps & { children?: unknown }; const out: Record = { ...rest, "flow:errors": true, role: "alert", class: cls ?? "text-sm text-red-400 space-y-1 list-disc pl-5", style: { display: "none" }, }; if (only) out["flow:errors.only"] = Array.isArray(only) ? only.join(",") : only; return jsx("ul", out); } // ── ────────────────────────────────────────────────────────────── /** Props for {@link ErrorMessage}: the target field via `for`. */ export interface ErrorProps { /** The field — `this.` (compiled) or `this.errors.` (runtime). */ for?: unknown; class?: string; [key: string]: unknown; } /** * A single field's first validation message as a reactive, self-hiding `` * (hidden when the field has no error). Point it at a field with `for`. * * @remarks * In a compiled page the Flow compiler rewrites this to a ``; * `for={this.email}` (the bound property) resolves to that field's errors. The runtime * body here is the fallback and also accepts the `this.errors.` accessor. * * @example * ```tsx * * ``` * * @category Forms & inputs */ export function ErrorMessage(props: ErrorProps): HtmlNode { const { for: forField, class: cls, children: _ignore, ...rest } = props as ErrorProps & { children?: unknown }; // `error` consumes an ErrorField sentinel (carrying __field) → emits flow:error + flow:show. // When `for={this.email}` passes the raw bound VALUE, recover the field name from the // render's exposed-key capture / value match and wrap it in a sentinel so binding works. let target = forField; if ( target !== undefined && target !== null && !(typeof target === "object" && (target as { __isErrorField?: boolean }).__isErrorField) ) { try { const field = _resolveBindName(target, "for", undefined, true); target = { __isErrorField: true, __field: field, __value: "" }; } catch { // Couldn't resolve to a field — leave as-is (renders unbound rather than crashing). } } return jsx("span", { ...rest, error: target, class: cls ?? "text-red-400 text-xs", }); } // ── ──────────────────────────────────────────────────────────────── /** Props for {@link Dropdown}: the trigger (`label` or custom `trigger`), `align`, and styling. */ export interface DropdownProps { label?: unknown; trigger?: unknown; align?: "left" | "right"; class?: string; panelClass?: string; children?: unknown; [key: string]: unknown; } /** * A click-to-open menu (Alpine-driven, client-only) with full keyboard support — * arrow keys, Home/End, Escape and click-outside — and correct menu ARIA roles. * * @remarks * Renders a default button labelled by `label` (or `"Menu"`), or your own `trigger` * node when provided. `align` (default `"left"`) sets which edge the panel opens from; * `children` are the menu items. * * @example * ```tsx * * Profile * Sign out * * ``` * * @category Overlays */ export function Dropdown(props: DropdownProps): HtmlNode { const { label, trigger, align = "left", class: cls, panelClass, children, ...rest } = props; // Trigger carries the menu ARIA + keyboard entry (flowMenu runtime: Down/Up/Enter // open and focus the first/last item). const triggerCommon = { "x-on:click": "toggle()", "x-on:keydown": "onButtonKey($event)", "aria-haspopup": "menu", ":aria-expanded": "open", }; const triggerNode = trigger ? jsx("span", { ...triggerCommon, role: "button", tabindex: 0, class: "inline-flex cursor-pointer", children: trigger, }) : jsx("button", { ...triggerCommon, type: "button", class: "inline-flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 text-gray-200", children: [ label ?? "Menu", jsx("span", { "aria-hidden": "true", class: "text-xs", children: "▾" }), ], }); // Panel is the menu: arrow/Home/End/Escape handled by the runtime; click-outside // closes without stealing focus back to the trigger. const panel = jsx("div", { role: "menu", "x-show": "open", "x-cloak": true, "x-transition": true, "x-on:keydown": "onKey($event)", "x-on:click.outside": "close(false)", "x-on:keydown.escape.window": "open && close(false)", class: "absolute mt-2 min-w-44 rounded-lg bg-gray-900 border border-gray-800 shadow-xl p-1 z-50 " + (align === "right" ? "right-0" : "left-0") + (panelClass ? " " + panelClass : ""), children, }); return jsx("div", { ...rest, "x-data": "flowMenu()", class: "relative inline-block" + (cls ? " " + cls : ""), children: [triggerNode, panel], }); } // ── ──────────────────────────────────────────────────────────────────── /** A single {@link Tabs} entry: its `label`, panel `content`, and optional stable `name`. */ export interface TabItem { label: unknown; content: unknown; name?: string; } /** Props for {@link Tabs}: the array of tab `items` to render. */ export interface TabsProps { items: TabItem[]; class?: string; [key: string]: unknown; } /** * A tabbed panel set built from an `items` array, with a `role="tablist"`, roving * arrow-key navigation and correct tab/tabpanel ARIA wiring. Selection is client-only * (Alpine); the first item is active initially. * * @remarks * Each item supplies a `label`, its panel `content`, and an optional `name` used as the * stable id (defaults to the index). * * @example * ```tsx * }, * { label: "Billing", content: }, * ]} /> * ``` * * @category Overlays */ export function Tabs(props: TabsProps): HtmlNode { const { items, class: cls, ...rest } = props; const names = items.map((it, i) => it.name ?? String(i)); const first = names[0] ?? "0"; const bar = jsx("div", { role: "tablist", "x-on:keydown": "onKey($event)", // roving arrow-key navigation (flowTabs) class: "flex gap-1 border-b border-gray-800 mb-3", children: items.map((it, i) => jsx("button", { type: "button", role: "tab", id: `flow-tab-${names[i]}`, "aria-controls": `flow-tabpanel-${names[i]}`, ":aria-selected": `tab === ${jsLiteral(names[i])}`, ":tabindex": `tab === ${jsLiteral(names[i])} ? 0 : -1`, "x-on:click": `tab = ${jsLiteral(names[i])}`, ":class": `tab === ${jsLiteral(names[i])} ? 'border-indigo-500 text-white' : 'border-transparent text-gray-400 hover:text-gray-200'`, class: "px-3 py-1.5 text-sm -mb-px border-b-2", children: it.label, }), ), }); const panels = items.map((it, i) => jsx("div", { role: "tabpanel", id: `flow-tabpanel-${names[i]}`, "aria-labelledby": `flow-tab-${names[i]}`, tabindex: 0, "x-show": `tab === ${jsLiteral(names[i])}`, "x-cloak": true, children: it.content, }), ); return jsx("div", { ...rest, "x-data": `flowTabs({ tab: ${jsLiteral(first)} })`, class: cls ?? "", children: [bar, ...panels], }); } // ── ────────────────────────────────────────────────────────── /** Props for {@link InfiniteScroll}: the `onMore` action, an optional `show` guard, and slot content. */ export interface InfiniteScrollProps { /** Server action to run when the sentinel enters the viewport (method ref). */ onMore: unknown; /** * Whether to render the sentinel at all. Pass a boolean expression — e.g. * `show={this.visible < this.all.length}` — to stop loading at the end without * an outer ternary. Re-evaluated on every render. Defaults to true. */ show?: boolean; class?: string; children?: unknown; [key: string]: unknown; } /** * A viewport sentinel that runs a server action when it scrolls into view — the * building block for load-more / infinite lists. * * @remarks * `onMore` is the method to invoke when the sentinel intersects; pass `show={false}` * (typically a boolean expression like `this.visible < this.all.length`) to stop * loading at the end. `children` replace the default "Loading more…" text. * * @example * ```tsx * * ``` * * @category Navigation & data */ export function InfiniteScroll(props: InfiniteScrollProps): HtmlNode { const { onMore, show, class: cls, children, ...rest } = props; if (show === false) return { html: "" }; // past the end — render nothing return jsx("div", { ...rest, onIntersect: onMore, class: cls ?? "py-4 text-center text-sm text-gray-500", children: children ?? "Loading more…", }); } // ── ──────────────────────────────────────────────────────────────── /** Props for {@link Skeleton}: bar `width`/`height`/`rounded`, or `lines` for a multi-line block. */ export interface SkeletonProps { /** Bar width (CSS length). Defaults to 100%. */ width?: string; /** Bar height (CSS length). Defaults to 1rem (0.75rem in multi-line mode). */ height?: string; /** Corner radius: `true` → pill, a string → that radius, omitted → 0.375rem. */ rounded?: boolean | string; /** Render N stacked line bars (the last is shortened for realism). */ lines?: number; class?: string; [key: string]: unknown; } /** * A pulsing placeholder block for content that hasn't loaded yet — pure markup plus * the bundled `.flow-skeleton` animation (no app CSS needed). Use it inside a lazy * child's `placeholder()` or a {@link Loading} region. * * @remarks * A single bar by default; pass `lines` for a stacked multi-line block (the last bar * is shortened for realism). `width`, `height` and `rounded` tune the shape. * * @example * ```tsx * * * * ``` * * @category Feedback */ export function Skeleton(props: SkeletonProps): HtmlNode { const { width, height, rounded, lines, class: cls, ...rest } = props; const radius = rounded === true ? "9999px" : typeof rounded === "string" ? rounded : "0.375rem"; const bar = (w: string, h: string): HtmlNode => jsx("div", { class: "flow-skeleton", style: `width:${w};height:${h};border-radius:${radius};`, }); if (lines && lines > 1) { const h = height ?? "0.75rem"; const bars = Array.from({ length: lines }, (_, i) => bar(i === lines - 1 ? "60%" : (width ?? "100%"), h), ); return jsx("div", { ...rest, class: cls ? `flow-skeleton-group ${cls}` : "flow-skeleton-group", style: "display:flex;flex-direction:column;gap:0.5rem;", children: bars, }); } const single = bar(width ?? "100%", height ?? "1rem"); if (!cls && Object.keys(rest).length === 0) return single; return jsx("div", { ...rest, ...(cls ? { class: cls } : {}), children: single }); } // ── reactive list ───────────────────────────────────────────────────────── /** Props for {@link For}: the `each` array, an optional `keyBy` field, and the item template `children`. */ export interface ForProps { /** The array to render — an `@expose`/`@locked` prop; its name drives the reactive `x-for`. */ each: readonly T[]; /** The item field used as Alpine's stable `:key` (e.g. `keyBy="id"`). */ keyBy?: string; /** The item template: `(item, index) =>
  • …
  • `. */ children: (item: T, index: number) => HtmlNode; } /** * Reactive list. The AOT compiler turns `{(item) => …}` * into an Alpine `