@zerotal/flow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -0,0 +1,1465 @@
1
+ /**
2
+ * Native Flow components — the built-in library of PascalCase function
3
+ * components used inside a `Component`'s `render()`. Each one compiles to
4
+ * ordinary HTML elements plus Flow/Alpine directives, so you use them like
5
+ * any JSX element:
6
+ *
7
+ * ```tsx
8
+ * <Link href="/posts" hover>Posts</Link>
9
+ * <Head><title>Dashboard</title></Head>
10
+ * <Persist name="player"><audio src={src} controls /></Persist>
11
+ * ```
12
+ */
13
+
14
+ import { jsx, _resolveReactiveName, _resolveBindName, _injectedBindKey } from "./jsx-runtime.ts";
15
+ import type { HtmlNode } from "./jsx-runtime.ts";
16
+ import { jsLiteral } from "./utils.ts";
17
+
18
+ // ── <Link> ────────────────────────────────────────────────────────────────
19
+ /** Props for {@link Link}: the target `href` plus SPA-navigation and active-state flags. */
20
+ export interface LinkProps {
21
+ href: string;
22
+ hover?: boolean;
23
+ current?: boolean;
24
+ exact?: boolean;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * An `<a>` wired for Flow SPA navigation, with automatic URL-based active state
30
+ * that you can override or refine per link.
31
+ *
32
+ * @remarks
33
+ * `hover` prefetches the target on hover; `current` forces (`true`) or suppresses
34
+ * (`false`) the active state instead of deriving it from the URL; `exact` restricts
35
+ * the automatic active state to an exact path match. Any other props pass through to
36
+ * the `<a>`.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * <Link href="/posts" hover>Posts</Link>
41
+ * <Link href="/dashboard" exact>Dashboard</Link>
42
+ * ```
43
+ *
44
+ * @category Navigation & data
45
+ */
46
+ export function Link(props: LinkProps & { children?: unknown }): HtmlNode {
47
+ const { href, hover, current, exact, children, ...rest } = props;
48
+ const out: Record<string, unknown> = { ...rest, href, navigate: true, children };
49
+ if (hover) out["navigateHover"] = true;
50
+ // `current` overrides the automatic URL-based active state:
51
+ // false → never active (flow:current.ignore)
52
+ // true → always active, and stays active across SPA navigations (flow:current.force).
53
+ // `data-current` is also set here so the link renders active on first paint,
54
+ // before the client runtime runs.
55
+ if (current === false) out["flow:current.ignore"] = true;
56
+ if (current === true) {
57
+ out["flow:current.force"] = true;
58
+ out["data-current"] = "";
59
+ }
60
+ // `exact` restricts the automatic active state to an exact path match, so an index
61
+ // link (e.g. /section) isn't marked current on its own sub-pages (/section/child).
62
+ if (exact) out["flow:current.exact"] = true;
63
+ return jsx("a", out);
64
+ }
65
+
66
+ // ── <Head> ────────────────────────────────────────────────────────────────
67
+ /**
68
+ * Marks its children as document `<head>` content — they are hoisted into the
69
+ * page head (and reconciled across SPA navigations) rather than rendered inline.
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * <Head>
74
+ * <title>Dashboard</title>
75
+ * <meta name="description" content="Your account overview" />
76
+ * </Head>
77
+ * ```
78
+ *
79
+ * @category Document head
80
+ */
81
+ export function Head(props: { children?: unknown }): HtmlNode {
82
+ return jsx("template", { "data-flow-head": true, children: props.children });
83
+ }
84
+
85
+ // ── <Persist> ───────────────────────────────────────────────────────────────
86
+ /**
87
+ * Preserves its subtree untouched across SPA navigations and morphs — the element
88
+ * with the given `name` is carried over instead of re-rendered, so media keeps
89
+ * playing and DOM state survives.
90
+ *
91
+ * @remarks
92
+ * The children are also marked `flow:ignore`, so Flow never patches inside them.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * <Persist name="player">
97
+ * <audio src={src} controls />
98
+ * </Persist>
99
+ * ```
100
+ *
101
+ * @category Navigation & data
102
+ */
103
+ export function Persist(props: { name: string; children?: unknown }): HtmlNode {
104
+ return jsx("div", {
105
+ "data-flow-persist": props.name,
106
+ "flow:ignore": true,
107
+ children: props.children,
108
+ });
109
+ }
110
+
111
+ // ── <Title> ───────────────────────────────────────────────────────────────────
112
+ /**
113
+ * Shorthand for setting the document `<title>` — wraps its children in a `<title>`
114
+ * inside a {@link Head}.
115
+ *
116
+ * @example
117
+ * ```tsx
118
+ * <Title>Dashboard</Title>
119
+ * ```
120
+ *
121
+ * @category Document head
122
+ */
123
+ export function Title(props: { children?: unknown }): HtmlNode {
124
+ return Head({ children: jsx("title", { children: props.children }) });
125
+ }
126
+
127
+ // ── <Modal> ───────────────────────────────────────────────────────────────────
128
+ /** Props for {@link Modal}: the `show` binding plus title, close behaviour and panel styling. */
129
+ export interface ModalProps {
130
+ show?: unknown;
131
+ /** Explicit prop name override — use when auto-resolve is ambiguous (multiple @expose props share the same value). */
132
+ name?: string;
133
+ onClose?: unknown;
134
+ title?: unknown;
135
+ closable?: boolean;
136
+ class?: string;
137
+ children?: unknown;
138
+ [key: string]: unknown;
139
+ }
140
+
141
+ /**
142
+ * A centered, backdrop-dimmed dialog whose visibility is driven by a bound `@expose`
143
+ * boolean; backdrop click and the × button close it locally (no server round-trip)
144
+ * and focus is trapped while open.
145
+ *
146
+ * @remarks
147
+ * Bind `show` to a reactive boolean — its name is auto-resolved (override with `name`).
148
+ * `closable` (default `true`) renders the × button, `title` fills the header and wires
149
+ * `aria-labelledby`, and `onClose` replaces the default `$flow.<name> = false` close action.
150
+ *
151
+ * @example
152
+ * ```tsx
153
+ * <Modal show={this.confirming} title="Delete post?">
154
+ * <p>This can't be undone.</p>
155
+ * </Modal>
156
+ * ```
157
+ *
158
+ * @category Overlays
159
+ */
160
+ export function Modal(props: ModalProps): HtmlNode {
161
+ const {
162
+ show,
163
+ name: nameProp,
164
+ onClose,
165
+ title,
166
+ closable = true,
167
+ class: panelClass,
168
+ children,
169
+ ...rest
170
+ } = props;
171
+ const name = nameProp ?? _injectedBindKey(props, "show") ?? _resolveReactiveName(show);
172
+ const open = Boolean(show);
173
+
174
+ const closeProps: Record<string, unknown> = onClose
175
+ ? { onClick: onClose }
176
+ : name
177
+ ? { "flow:click": `$flow.${name} = false` }
178
+ : {};
179
+
180
+ const backdrop = jsx("div", {
181
+ class: "absolute inset-0 bg-black/50",
182
+ ...closeProps,
183
+ });
184
+
185
+ const titleId = name ? `flow-modal-title-${name}` : undefined;
186
+ const header =
187
+ title || closable
188
+ ? jsx("div", {
189
+ class: "flex items-center justify-between gap-4 mb-3",
190
+ children: [
191
+ title
192
+ ? jsx("h2", { id: titleId, class: "text-lg font-semibold", children: title })
193
+ : "",
194
+ closable
195
+ ? jsx("button", {
196
+ type: "button",
197
+ "aria-label": "Close",
198
+ class: "text-gray-400 hover:text-white text-2xl leading-none",
199
+ children: "×",
200
+ ...closeProps,
201
+ })
202
+ : "",
203
+ ],
204
+ })
205
+ : "";
206
+
207
+ const panel = jsx("div", {
208
+ class:
209
+ "relative z-10 w-full max-w-lg rounded-xl bg-gray-900 border border-gray-800 p-5 shadow-xl text-white " +
210
+ (panelClass ?? ""),
211
+ "flow:transition": true,
212
+ children: [header, children],
213
+ });
214
+
215
+ const overlay: Record<string, unknown> = {
216
+ ...rest,
217
+ role: "dialog",
218
+ "aria-modal": "true",
219
+ class: "fixed inset-0 z-50 flex items-center justify-center p-4",
220
+ children: [backdrop, panel],
221
+ };
222
+ if (name) {
223
+ overlay["flow:show"] = name;
224
+ overlay["data-flow-modal"] = name;
225
+ overlay["x-trap"] = `$flow.${name}`; // trap focus while open (@alpinejs/focus)
226
+ if (title) overlay["aria-labelledby"] = titleId;
227
+ }
228
+ if (!open) overlay["style"] = { display: "none" };
229
+
230
+ return jsx("div", overlay);
231
+ }
232
+
233
+ // ── <Flash> ───────────────────────────────────────────────────────────────────
234
+ /** Props for {@link Flash}: the default toast `position` and auto-dismiss `duration`. */
235
+ export interface FlashProps {
236
+ position?:
237
+ "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
238
+ duration?: number;
239
+ [key: string]: unknown;
240
+ }
241
+
242
+ // Container positioning is INLINE (not Tailwind classes): the <Flash> markup lives
243
+ // in the flow package, which an app's Tailwind `@source` paths don't scan — so any
244
+ // utility classes here get purged and the toast collapses to the page corner. Inline
245
+ // styles make the component self-contained and correct in every app.
246
+ const _FLASH_POS: Record<string, Record<string, string>> = {
247
+ "top-right": { top: "1rem", right: "1rem", alignItems: "flex-end" },
248
+ "top-left": { top: "1rem", left: "1rem", alignItems: "flex-start" },
249
+ "bottom-right": { bottom: "1rem", right: "1rem", alignItems: "flex-end" },
250
+ "bottom-left": { bottom: "1rem", left: "1rem", alignItems: "flex-start" },
251
+ "top-center": { top: "1rem", left: "50%", transform: "translateX(-50%)", alignItems: "center" },
252
+ "bottom-center": {
253
+ bottom: "1rem",
254
+ left: "50%",
255
+ transform: "translateX(-50%)",
256
+ alignItems: "center",
257
+ },
258
+ };
259
+
260
+ /**
261
+ * The toast host — a single fixed container plus the client runtime that renders
262
+ * flash messages emitted by the server (via `flow:flash` window events) as styled,
263
+ * self-dismissing toasts. Place it once, near the root of your layout.
264
+ *
265
+ * @remarks
266
+ * `position` (default `"bottom-right"`) and `duration` in ms (default `4000`) are the
267
+ * defaults for toasts that don't specify their own; individual toasts can override
268
+ * position, duration, icon, actions and more via the emitted payload.
269
+ *
270
+ * @example
271
+ * ```tsx
272
+ * <Flash position="top-right" duration={6000} />
273
+ * ```
274
+ *
275
+ * @category Feedback
276
+ */
277
+ export function Flash(props: FlashProps = {}): HtmlNode {
278
+ const position = props.position ?? "bottom-right";
279
+ const duration = props.duration ?? 4000;
280
+ const pos = _FLASH_POS[position] ?? _FLASH_POS["bottom-right"];
281
+
282
+ const script = `(function(){
283
+ if (window.__flowFlash) return; window.__flowFlash = 1;
284
+ var root = document.getElementById('flow-flash');
285
+ if (!root) return;
286
+ var defDur = Number(root.getAttribute('data-duration')) || 4000;
287
+ var defPos = root.getAttribute('data-position') || 'bottom-right';
288
+ var FONT = 'system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
289
+ var COLORS = { success:'#16a34a', error:'#dc2626', danger:'#dc2626', warning:'#d97706', info:'#2563eb' };
290
+ // Inline position styles, mirrored from the server _FLASH_POS map, so per-toast
291
+ // positions get a fixed container even though Tailwind classes here are purged.
292
+ var POS = {
293
+ 'top-right':'top:1rem;right:1rem;align-items:flex-end',
294
+ 'top-left':'top:1rem;left:1rem;align-items:flex-start',
295
+ 'bottom-right':'bottom:1rem;right:1rem;align-items:flex-end',
296
+ 'bottom-left':'bottom:1rem;left:1rem;align-items:flex-start',
297
+ 'top-center':'top:1rem;left:50%;transform:translateX(-50%);align-items:center',
298
+ 'bottom-center':'bottom:1rem;left:50%;transform:translateX(-50%);align-items:center'
299
+ };
300
+ var ICONS = {
301
+ success: '<svg viewBox="0 0 20 20" fill="currentColor" width="22" height="22"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>',
302
+ error: '<svg viewBox="0 0 20 20" fill="currentColor" width="22" height="22"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"/></svg>',
303
+ warning: '<svg viewBox="0 0 20 20" fill="currentColor" width="22" height="22"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>',
304
+ info: '<svg viewBox="0 0 20 20" fill="currentColor" width="22" height="22"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>'
305
+ };
306
+ ICONS.danger = ICONS.error;
307
+ var X = '<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>';
308
+ function esc(s){ return String(s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
309
+ // One fixed container per position. The default position reuses the server-rendered
310
+ // #flow-flash element; others are created lazily and appended to <body>.
311
+ var containers = {};
312
+ function container(pos){
313
+ if (pos === defPos) return root;
314
+ if (containers[pos]) return containers[pos];
315
+ var c = document.createElement('div');
316
+ c.style.cssText = 'position:fixed;z-index:50;display:flex;flex-direction:column;gap:8px;pointer-events:none;'+(POS[pos]||POS[defPos]||'');
317
+ document.body.appendChild(c);
318
+ containers[pos] = c;
319
+ return c;
320
+ }
321
+ // Theme is resolved per-toast so it tracks the live OS/app theme: honour an explicit
322
+ // .dark / .light class on <html> first, else fall back to prefers-color-scheme.
323
+ function theme(){
324
+ var el = document.documentElement;
325
+ var dark = el.classList.contains('dark') ||
326
+ (!el.classList.contains('light') && window.matchMedia &&
327
+ matchMedia('(prefers-color-scheme: dark)').matches);
328
+ return dark
329
+ ? { bg:'#1f2937', ring:'rgba(255,255,255,.1)', title:'#f9fafb', body:'#9ca3af', close:'#6b7280', btnBg:'#374151', btnText:'#e5e7eb' }
330
+ : { bg:'#ffffff', ring:'rgba(0,0,0,.05)', title:'#111827', body:'#6b7280', close:'#9ca3af', btnBg:'#f3f4f6', btnText:'#374151' };
331
+ }
332
+ function toast(opt){
333
+ var level = String(opt.level || 'info');
334
+ var color = COLORS[level] || '#334155';
335
+ var c = theme();
336
+ var dur = (opt.duration != null) ? Number(opt.duration) : defDur;
337
+ var sticky = !(dur > 0);
338
+ var dismissible = (opt.dismissible != null) ? !!opt.dismissible : true;
339
+ if (sticky) dismissible = true; // never trap the user with an unclosable toast
340
+ var pos = opt.position || defPos;
341
+ var host = container(pos);
342
+
343
+ // Icon: explicit string = custom (emoji/text); false = hidden; else status SVG.
344
+ var showIcon = opt.icon !== false;
345
+ var iconHtml = '';
346
+ if (showIcon) {
347
+ iconHtml = (typeof opt.icon === 'string' && opt.icon) ? esc(opt.icon) : (ICONS[level] || ICONS.info);
348
+ }
349
+
350
+ // Title/body: explicit title wins (message becomes the body); otherwise the
351
+ // first line of the message is the title and the rest is the body.
352
+ var msg = String(opt.message || ''), title, body;
353
+ if (opt.title != null && opt.title !== '') { title = String(opt.title); body = msg; }
354
+ else { var parts = msg.split('\\n'); title = parts.shift() || ''; body = parts.join(' ').trim(); }
355
+
356
+ var t = document.createElement('div');
357
+ t.setAttribute('role', level === 'error' || level === 'warning' ? 'alert' : 'status');
358
+ t.style.cssText = 'position:relative;overflow:hidden;pointer-events:auto;display:flex;gap:12px;align-items:flex-start;'+
359
+ 'width:24rem;max-width:calc(100vw - 2rem);background:'+c.bg+';border-radius:12px;padding:16px;'+
360
+ 'box-shadow:0 10px 25px rgba(0,0,0,.15),0 0 0 1px '+c.ring+';'+
361
+ 'opacity:0;transform:translateY(8px);transition:opacity .2s ease, transform .2s ease;';
362
+ // Action buttons — inline-styled from a constrained set (color/variant/uppercase)
363
+ // so they always render, even though runtime toasts can't use Tailwind classes.
364
+ function btnStyle(a){
365
+ var accent = a.color ? (COLORS[a.color] || a.color) : '';
366
+ var variant = a.variant || 'soft';
367
+ var base = 'font:600 13px '+FONT+';padding:6px 12px;border-radius:8px;border:0;cursor:pointer;'+
368
+ (a.uppercase ? 'text-transform:uppercase;letter-spacing:.04em;font-size:12px;' : '');
369
+ if (variant === 'solid') return base+'background:'+(accent||'#4f46e5')+';color:#fff;';
370
+ if (variant === 'ghost') return base+'background:none;padding-left:2px;padding-right:2px;color:'+(accent||c.btnText)+';';
371
+ return base+'background:'+c.btnBg+';color:'+(accent||c.btnText)+';';
372
+ }
373
+ var actions = Array.isArray(opt.actions) ? opt.actions : [];
374
+ var btns = '';
375
+ for (var i=0;i<actions.length;i++){
376
+ var a = actions[i];
377
+ if (!a || !a.label || !a.method) continue;
378
+ btns += '<button type="button" data-flow-action="'+i+'" style="'+btnStyle(a)+'">'+esc(a.label)+'</button>';
379
+ }
380
+ t.innerHTML =
381
+ (showIcon ? '<span style="flex-shrink:0;color:'+color+';line-height:0;font-size:20px">'+iconHtml+'</span>' : '')+
382
+ '<div style="flex:1;min-width:0">'+
383
+ '<p style="margin:0;font:600 14px '+FONT+';color:'+c.title+'">'+esc(title)+'</p>'+
384
+ (body ? '<p style="margin:4px 0 0;font:400 13px '+FONT+';color:'+c.body+';line-height:1.5">'+esc(body)+'</p>' : '')+
385
+ (btns ? '<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">'+btns+'</div>' : '')+
386
+ '</div>'+
387
+ (dismissible ? '<button type="button" data-flow-close aria-label="Dismiss" style="flex-shrink:0;margin:-4px -4px 0 0;padding:4px;border:0;background:none;color:'+c.close+';cursor:pointer;line-height:0;border-radius:6px">'+X+'</button>' : '');
388
+
389
+ // Countdown progress bar (only meaningful while auto-dismissing).
390
+ if (opt.progressBar && !sticky) {
391
+ var bar = document.createElement('div');
392
+ bar.style.cssText = 'position:absolute;left:0;bottom:0;height:3px;width:100%;background:'+color+';opacity:.5;transition:width '+dur+'ms linear;';
393
+ t.appendChild(bar);
394
+ requestAnimationFrame(function(){ bar.style.width = '0%'; });
395
+ }
396
+
397
+ // Invoke a @expose method on the emitting component via the bridge (window event).
398
+ function invoke(spec){
399
+ if (!spec || !spec.method) return;
400
+ window.dispatchEvent(new CustomEvent('flow:invoke', { detail: {
401
+ component: opt.component, method: spec.method, args: spec.args || []
402
+ }}));
403
+ }
404
+
405
+ host.appendChild(t);
406
+ requestAnimationFrame(function(){ t.style.opacity='1'; t.style.transform='none'; });
407
+ var done = false;
408
+ function dismiss(){ if(done) return; done=true; invoke(opt.onClose);
409
+ t.style.opacity='0'; t.style.transform='translateY(8px)';
410
+ setTimeout(function(){ t.remove(); }, 200); }
411
+ var closeBtn = t.querySelector('[data-flow-close]');
412
+ if (closeBtn) closeBtn.addEventListener('click', dismiss);
413
+ Array.prototype.forEach.call(t.querySelectorAll('[data-flow-action]'), function(btn){
414
+ var idx = Number(btn.getAttribute('data-flow-action'));
415
+ btn.addEventListener('click', function(){ invoke(actions[idx]); dismiss(); });
416
+ });
417
+ if (!sticky) setTimeout(dismiss, dur);
418
+ }
419
+ window.addEventListener('flow:flash', function(e){
420
+ toast((e && e.detail) || {});
421
+ });
422
+ })();`;
423
+
424
+ return jsx("div", {
425
+ id: "flow-flash",
426
+ "data-duration": duration,
427
+ "data-position": position,
428
+ style: {
429
+ position: "fixed",
430
+ zIndex: "50",
431
+ display: "flex",
432
+ flexDirection: "column",
433
+ gap: "8px",
434
+ pointerEvents: "none",
435
+ ...pos,
436
+ },
437
+ children: jsx("script", { dangerouslySetInnerHTML: { __html: script } }),
438
+ });
439
+ }
440
+
441
+ // ── <Errors> ──────────────────────────────────────────────────────────────────
442
+ /** Props for {@link Errors}: an optional `only` filter limiting which fields' errors show. */
443
+ export interface ErrorsProps {
444
+ only?: string | string[];
445
+ class?: string;
446
+ [key: string]: unknown;
447
+ }
448
+
449
+ /**
450
+ * A reactive validation-error summary — a `role="alert"` list that stays hidden until
451
+ * the component has errors, then shows the current messages.
452
+ *
453
+ * @remarks
454
+ * `only` limits the summary to one or more named fields (a string or array of field
455
+ * names); omit it to show every error.
456
+ *
457
+ * @example
458
+ * ```tsx
459
+ * <Errors />
460
+ * <Errors only={["email", "password"]} />
461
+ * ```
462
+ *
463
+ * @category Forms & inputs
464
+ */
465
+ export function Errors(props: ErrorsProps = {}): HtmlNode {
466
+ const {
467
+ only,
468
+ class: cls,
469
+ children: _ignore,
470
+ ...rest
471
+ } = props as ErrorsProps & { children?: unknown };
472
+ const out: Record<string, unknown> = {
473
+ ...rest,
474
+ "flow:errors": true,
475
+ role: "alert",
476
+ class: cls ?? "text-sm text-red-400 space-y-1 list-disc pl-5",
477
+ style: { display: "none" },
478
+ };
479
+ if (only) out["flow:errors.only"] = Array.isArray(only) ? only.join(",") : only;
480
+ return jsx("ul", out);
481
+ }
482
+
483
+ // ── <ErrorMessage> ──────────────────────────────────────────────────────────────
484
+ /** Props for {@link ErrorMessage}: the target field via `for`. */
485
+ export interface ErrorProps {
486
+ /** The field — `this.<field>` (compiled) or `this.errors.<field>` (runtime). */
487
+ for?: unknown;
488
+ class?: string;
489
+ [key: string]: unknown;
490
+ }
491
+
492
+ /**
493
+ * A single field's first validation message as a reactive, self-hiding `<span>`
494
+ * (hidden when the field has no error). Point it at a field with `for`.
495
+ *
496
+ * @remarks
497
+ * In a compiled page the Flow compiler rewrites this to a `<span flow:error flow:show>`;
498
+ * `for={this.email}` (the bound property) resolves to that field's errors. The runtime
499
+ * body here is the fallback and also accepts the `this.errors.<field>` accessor.
500
+ *
501
+ * @example
502
+ * ```tsx
503
+ * <ErrorMessage for={this.email} />
504
+ * ```
505
+ *
506
+ * @category Forms & inputs
507
+ */
508
+ export function ErrorMessage(props: ErrorProps): HtmlNode {
509
+ const {
510
+ for: forField,
511
+ class: cls,
512
+ children: _ignore,
513
+ ...rest
514
+ } = props as ErrorProps & { children?: unknown };
515
+
516
+ // `error` consumes an ErrorField sentinel (carrying __field) → emits flow:error + flow:show.
517
+ // When `for={this.email}` passes the raw bound VALUE, recover the field name from the
518
+ // render's exposed-key capture / value match and wrap it in a sentinel so binding works.
519
+ let target = forField;
520
+ if (
521
+ target !== undefined &&
522
+ target !== null &&
523
+ !(typeof target === "object" && (target as { __isErrorField?: boolean }).__isErrorField)
524
+ ) {
525
+ try {
526
+ const field = _resolveBindName(target, "for", undefined, true);
527
+ target = { __isErrorField: true, __field: field, __value: "" };
528
+ } catch {
529
+ // Couldn't resolve to a field — leave as-is (renders unbound rather than crashing).
530
+ }
531
+ }
532
+
533
+ return jsx("span", {
534
+ ...rest,
535
+ error: target,
536
+ class: cls ?? "text-red-400 text-xs",
537
+ });
538
+ }
539
+
540
+ // ── <Dropdown> ────────────────────────────────────────────────────────────────
541
+ /** Props for {@link Dropdown}: the trigger (`label` or custom `trigger`), `align`, and styling. */
542
+ export interface DropdownProps {
543
+ label?: unknown;
544
+ trigger?: unknown;
545
+ align?: "left" | "right";
546
+ class?: string;
547
+ panelClass?: string;
548
+ children?: unknown;
549
+ [key: string]: unknown;
550
+ }
551
+
552
+ /**
553
+ * A click-to-open menu (Alpine-driven, client-only) with full keyboard support —
554
+ * arrow keys, Home/End, Escape and click-outside — and correct menu ARIA roles.
555
+ *
556
+ * @remarks
557
+ * Renders a default button labelled by `label` (or `"Menu"`), or your own `trigger`
558
+ * node when provided. `align` (default `"left"`) sets which edge the panel opens from;
559
+ * `children` are the menu items.
560
+ *
561
+ * @example
562
+ * ```tsx
563
+ * <Dropdown label="Account" align="right">
564
+ * <Link href="/profile">Profile</Link>
565
+ * <Link href="/logout">Sign out</Link>
566
+ * </Dropdown>
567
+ * ```
568
+ *
569
+ * @category Overlays
570
+ */
571
+ export function Dropdown(props: DropdownProps): HtmlNode {
572
+ const { label, trigger, align = "left", class: cls, panelClass, children, ...rest } = props;
573
+
574
+ // Trigger carries the menu ARIA + keyboard entry (flowMenu runtime: Down/Up/Enter
575
+ // open and focus the first/last item).
576
+ const triggerCommon = {
577
+ "x-on:click": "toggle()",
578
+ "x-on:keydown": "onButtonKey($event)",
579
+ "aria-haspopup": "menu",
580
+ ":aria-expanded": "open",
581
+ };
582
+ const triggerNode = trigger
583
+ ? jsx("span", {
584
+ ...triggerCommon,
585
+ role: "button",
586
+ tabindex: 0,
587
+ class: "inline-flex cursor-pointer",
588
+ children: trigger,
589
+ })
590
+ : jsx("button", {
591
+ ...triggerCommon,
592
+ type: "button",
593
+ class:
594
+ "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",
595
+ children: [
596
+ label ?? "Menu",
597
+ jsx("span", { "aria-hidden": "true", class: "text-xs", children: "▾" }),
598
+ ],
599
+ });
600
+
601
+ // Panel is the menu: arrow/Home/End/Escape handled by the runtime; click-outside
602
+ // closes without stealing focus back to the trigger.
603
+ const panel = jsx("div", {
604
+ role: "menu",
605
+ "x-show": "open",
606
+ "x-cloak": true,
607
+ "x-transition": true,
608
+ "x-on:keydown": "onKey($event)",
609
+ "x-on:click.outside": "close(false)",
610
+ "x-on:keydown.escape.window": "open && close(false)",
611
+ class:
612
+ "absolute mt-2 min-w-44 rounded-lg bg-gray-900 border border-gray-800 shadow-xl p-1 z-50 " +
613
+ (align === "right" ? "right-0" : "left-0") +
614
+ (panelClass ? " " + panelClass : ""),
615
+ children,
616
+ });
617
+
618
+ return jsx("div", {
619
+ ...rest,
620
+ "x-data": "flowMenu()",
621
+ class: "relative inline-block" + (cls ? " " + cls : ""),
622
+ children: [triggerNode, panel],
623
+ });
624
+ }
625
+
626
+ // ── <Tabs> ────────────────────────────────────────────────────────────────────
627
+ /** A single {@link Tabs} entry: its `label`, panel `content`, and optional stable `name`. */
628
+ export interface TabItem {
629
+ label: unknown;
630
+ content: unknown;
631
+ name?: string;
632
+ }
633
+
634
+ /** Props for {@link Tabs}: the array of tab `items` to render. */
635
+ export interface TabsProps {
636
+ items: TabItem[];
637
+ class?: string;
638
+ [key: string]: unknown;
639
+ }
640
+
641
+ /**
642
+ * A tabbed panel set built from an `items` array, with a `role="tablist"`, roving
643
+ * arrow-key navigation and correct tab/tabpanel ARIA wiring. Selection is client-only
644
+ * (Alpine); the first item is active initially.
645
+ *
646
+ * @remarks
647
+ * Each item supplies a `label`, its panel `content`, and an optional `name` used as the
648
+ * stable id (defaults to the index).
649
+ *
650
+ * @example
651
+ * ```tsx
652
+ * <Tabs items={[
653
+ * { label: "Profile", content: <ProfileForm /> },
654
+ * { label: "Billing", content: <BillingForm /> },
655
+ * ]} />
656
+ * ```
657
+ *
658
+ * @category Overlays
659
+ */
660
+ export function Tabs(props: TabsProps): HtmlNode {
661
+ const { items, class: cls, ...rest } = props;
662
+ const names = items.map((it, i) => it.name ?? String(i));
663
+ const first = names[0] ?? "0";
664
+
665
+ const bar = jsx("div", {
666
+ role: "tablist",
667
+ "x-on:keydown": "onKey($event)", // roving arrow-key navigation (flowTabs)
668
+ class: "flex gap-1 border-b border-gray-800 mb-3",
669
+ children: items.map((it, i) =>
670
+ jsx("button", {
671
+ type: "button",
672
+ role: "tab",
673
+ id: `flow-tab-${names[i]}`,
674
+ "aria-controls": `flow-tabpanel-${names[i]}`,
675
+ ":aria-selected": `tab === ${jsLiteral(names[i])}`,
676
+ ":tabindex": `tab === ${jsLiteral(names[i])} ? 0 : -1`,
677
+ "x-on:click": `tab = ${jsLiteral(names[i])}`,
678
+ ":class": `tab === ${jsLiteral(names[i])} ? 'border-indigo-500 text-white' : 'border-transparent text-gray-400 hover:text-gray-200'`,
679
+ class: "px-3 py-1.5 text-sm -mb-px border-b-2",
680
+ children: it.label,
681
+ }),
682
+ ),
683
+ });
684
+
685
+ const panels = items.map((it, i) =>
686
+ jsx("div", {
687
+ role: "tabpanel",
688
+ id: `flow-tabpanel-${names[i]}`,
689
+ "aria-labelledby": `flow-tab-${names[i]}`,
690
+ tabindex: 0,
691
+ "x-show": `tab === ${jsLiteral(names[i])}`,
692
+ "x-cloak": true,
693
+ children: it.content,
694
+ }),
695
+ );
696
+
697
+ return jsx("div", {
698
+ ...rest,
699
+ "x-data": `flowTabs({ tab: ${jsLiteral(first)} })`,
700
+ class: cls ?? "",
701
+ children: [bar, ...panels],
702
+ });
703
+ }
704
+
705
+ // ── <InfiniteScroll> ──────────────────────────────────────────────────────────
706
+ /** Props for {@link InfiniteScroll}: the `onMore` action, an optional `show` guard, and slot content. */
707
+ export interface InfiniteScrollProps {
708
+ /** Server action to run when the sentinel enters the viewport (method ref). */
709
+ onMore: unknown;
710
+ /**
711
+ * Whether to render the sentinel at all. Pass a boolean expression — e.g.
712
+ * `show={this.visible < this.all.length}` — to stop loading at the end without
713
+ * an outer ternary. Re-evaluated on every render. Defaults to true.
714
+ */
715
+ show?: boolean;
716
+ class?: string;
717
+ children?: unknown;
718
+ [key: string]: unknown;
719
+ }
720
+
721
+ /**
722
+ * A viewport sentinel that runs a server action when it scrolls into view — the
723
+ * building block for load-more / infinite lists.
724
+ *
725
+ * @remarks
726
+ * `onMore` is the method to invoke when the sentinel intersects; pass `show={false}`
727
+ * (typically a boolean expression like `this.visible < this.all.length`) to stop
728
+ * loading at the end. `children` replace the default "Loading more…" text.
729
+ *
730
+ * @example
731
+ * ```tsx
732
+ * <InfiniteScroll onMore={this.loadMore} show={this.visible < this.total} />
733
+ * ```
734
+ *
735
+ * @category Navigation & data
736
+ */
737
+ export function InfiniteScroll(props: InfiniteScrollProps): HtmlNode {
738
+ const { onMore, show, class: cls, children, ...rest } = props;
739
+ if (show === false) return { html: "" }; // past the end — render nothing
740
+ return jsx("div", {
741
+ ...rest,
742
+ onIntersect: onMore,
743
+ class: cls ?? "py-4 text-center text-sm text-gray-500",
744
+ children: children ?? "Loading more…",
745
+ });
746
+ }
747
+
748
+ // ── <Skeleton> ────────────────────────────────────────────────────────────────
749
+ /** Props for {@link Skeleton}: bar `width`/`height`/`rounded`, or `lines` for a multi-line block. */
750
+ export interface SkeletonProps {
751
+ /** Bar width (CSS length). Defaults to 100%. */
752
+ width?: string;
753
+ /** Bar height (CSS length). Defaults to 1rem (0.75rem in multi-line mode). */
754
+ height?: string;
755
+ /** Corner radius: `true` → pill, a string → that radius, omitted → 0.375rem. */
756
+ rounded?: boolean | string;
757
+ /** Render N stacked line bars (the last is shortened for realism). */
758
+ lines?: number;
759
+ class?: string;
760
+ [key: string]: unknown;
761
+ }
762
+
763
+ /**
764
+ * A pulsing placeholder block for content that hasn't loaded yet — pure markup plus
765
+ * the bundled `.flow-skeleton` animation (no app CSS needed). Use it inside a lazy
766
+ * child's `placeholder()` or a {@link Loading} region.
767
+ *
768
+ * @remarks
769
+ * A single bar by default; pass `lines` for a stacked multi-line block (the last bar
770
+ * is shortened for realism). `width`, `height` and `rounded` tune the shape.
771
+ *
772
+ * @example
773
+ * ```tsx
774
+ * <Skeleton height="1.5rem" width="60%" />
775
+ * <Skeleton lines={3} />
776
+ * <Skeleton height="10rem" rounded="0.75rem" />
777
+ * ```
778
+ *
779
+ * @category Feedback
780
+ */
781
+ export function Skeleton(props: SkeletonProps): HtmlNode {
782
+ const { width, height, rounded, lines, class: cls, ...rest } = props;
783
+ const radius = rounded === true ? "9999px" : typeof rounded === "string" ? rounded : "0.375rem";
784
+ const bar = (w: string, h: string): HtmlNode =>
785
+ jsx("div", {
786
+ class: "flow-skeleton",
787
+ style: `width:${w};height:${h};border-radius:${radius};`,
788
+ });
789
+
790
+ if (lines && lines > 1) {
791
+ const h = height ?? "0.75rem";
792
+ const bars = Array.from({ length: lines }, (_, i) =>
793
+ bar(i === lines - 1 ? "60%" : (width ?? "100%"), h),
794
+ );
795
+ return jsx("div", {
796
+ ...rest,
797
+ class: cls ? `flow-skeleton-group ${cls}` : "flow-skeleton-group",
798
+ style: "display:flex;flex-direction:column;gap:0.5rem;",
799
+ children: bars,
800
+ });
801
+ }
802
+
803
+ const single = bar(width ?? "100%", height ?? "1rem");
804
+ if (!cls && Object.keys(rest).length === 0) return single;
805
+ return jsx("div", { ...rest, ...(cls ? { class: cls } : {}), children: single });
806
+ }
807
+
808
+ // ── <For> reactive list ─────────────────────────────────────────────────────────
809
+ /** Props for {@link For}: the `each` array, an optional `keyBy` field, and the item template `children`. */
810
+ export interface ForProps<T> {
811
+ /** The array to render — an `@expose`/`@locked` prop; its name drives the reactive `x-for`. */
812
+ each: readonly T[];
813
+ /** The item field used as Alpine's stable `:key` (e.g. `keyBy="id"`). */
814
+ keyBy?: string;
815
+ /** The item template: `(item, index) => <li>…</li>`. */
816
+ children: (item: T, index: number) => HtmlNode;
817
+ }
818
+
819
+ /**
820
+ * Reactive list. The AOT compiler turns `<For each={this.items} keyBy="id">{(item) => …}</For>`
821
+ * into an Alpine `<template x-for>`, so `appendOptimistic`/`removeOptimistic` (and any client change
822
+ * to the array) re-render it instantly while server patches stay authoritative. This runtime body is
823
+ * a static fallback, used only when a page's render couldn't be ahead-of-time compiled.
824
+ *
825
+ * @example
826
+ * ```tsx
827
+ * <For each={this.items} keyBy="id">
828
+ * {(item) => <li>{item.name}</li>}
829
+ * </For>
830
+ * ```
831
+ *
832
+ * @category Navigation & data
833
+ */
834
+ export function For<T>(props: ForProps<T>): HtmlNode {
835
+ const items = props.each ?? [];
836
+ let html = "";
837
+ for (let i = 0; i < items.length; i++) {
838
+ const node = props.children(items[i]!, i);
839
+ html += node && typeof node === "object" && "html" in node ? (node as HtmlNode).html : "";
840
+ }
841
+ return { html };
842
+ }
843
+
844
+ // ── <Loading> ─────────────────────────────────────────────────────────────────
845
+ /** Props for {@link Loading}: `target`/`hide`/`delay` loading behaviour and optional `skeleton` placeholder. */
846
+ export interface LoadingProps {
847
+ target?: string | string[];
848
+ hide?: boolean;
849
+ delay?: boolean;
850
+ /** Show a `<Skeleton>` while loading (used when no children are given). */
851
+ skeleton?: boolean | SkeletonProps;
852
+ tag?: string;
853
+ class?: string;
854
+ children?: unknown;
855
+ [key: string]: unknown;
856
+ }
857
+
858
+ /**
859
+ * A region that is visible only while a matching server action is in flight — for
860
+ * inline spinners, "Saving…" text or a skeleton placeholder.
861
+ *
862
+ * @remarks
863
+ * `target` scopes it to one or more named actions; `hide` removes the region instead
864
+ * of toggling it, and `delay` waits before showing (to avoid flicker on fast requests).
865
+ * Pass `skeleton` to render a {@link Skeleton} when no children are given; `tag`
866
+ * chooses the wrapper element (default `"span"`).
867
+ *
868
+ * @example
869
+ * ```tsx
870
+ * <Loading target="save">Saving…</Loading>
871
+ * <Loading skeleton delay />
872
+ * ```
873
+ *
874
+ * @category Feedback
875
+ */
876
+ export function Loading(props: LoadingProps): HtmlNode {
877
+ const { target, hide, delay, skeleton, tag, class: cls, children, ...rest } = props;
878
+ const body =
879
+ children ?? (skeleton ? Skeleton(typeof skeleton === "object" ? skeleton : {}) : undefined);
880
+ const out: Record<string, unknown> = { ...rest, children: body };
881
+ if (cls) out["class"] = cls;
882
+ if (hide) out["flow:loading.remove"] = true;
883
+ else if (delay) out["flow:loading.delay"] = true;
884
+ else out["flow:loading"] = true;
885
+ if (target) out["flow:target"] = Array.isArray(target) ? target.join(",") : target;
886
+ // An explicit tag always wins. Otherwise a skeleton placeholder is block-level,
887
+ // so it defaults to <div>; every other case defaults to the inline <span>.
888
+ const element = tag ?? (skeleton && !children ? "div" : "span");
889
+ return jsx(element, out);
890
+ }
891
+
892
+ // ── <Pager> ─────────────────────────────────────────────────────────────────
893
+ /** Props for {@link Pager}: the `paginator`, preserved `params`, and label/style overrides. */
894
+ /**
895
+ * What `<Pager>` needs from a paginator. `Model.paginate()`'s result and the in-memory
896
+ * {@link Paginator} both carry this shape, so either renders the same pager.
897
+ */
898
+ export interface PagerLike {
899
+ page: number;
900
+ lastPage: number;
901
+ onFirstPage: boolean;
902
+ hasMorePages: boolean;
903
+ elements(each?: number): (number | "...")[];
904
+ }
905
+
906
+ export interface PagerProps {
907
+ paginator: PagerLike;
908
+ /** Extra query params to keep in the page links (e.g. a search term). */
909
+ params?: Record<string, string | number | null | undefined>;
910
+ /** Prefetch each page on hover. */
911
+ hover?: boolean;
912
+ /** Labels for the prev/next controls. */
913
+ prevLabel?: string;
914
+ nextLabel?: string;
915
+ class?: string;
916
+ linkClass?: string;
917
+ activeClass?: string;
918
+ [key: string]: unknown;
919
+ }
920
+
921
+ /**
922
+ * Renders a Prev / numbered / Next pager from a `Paginator` (the result of
923
+ * `Model.paginate()` or the in-memory `paginate()` helper). Links are SPA `navigate` anchors to
924
+ * `?page=N`, so they pair with `@url page` with no per-link wiring; renders nothing for
925
+ * a single page.
926
+ *
927
+ * @remarks
928
+ * Pass `params` to preserve other query state (search term, per-page, …). `hover`
929
+ * prefetches each page; `prevLabel`/`nextLabel` and the class props override the
930
+ * defaults. This is the links UI — the Flow equivalent of Livewire's
931
+ * `$paginator->links()` view.
932
+ *
933
+ * @example
934
+ * ```tsx
935
+ * <Pager paginator={p} params={{ q: this.query, perPage: this.perPage }} hover />
936
+ * ```
937
+ *
938
+ * @category Navigation & data
939
+ */
940
+ export function Pager(props: PagerProps): HtmlNode {
941
+ const {
942
+ paginator: p,
943
+ params,
944
+ hover,
945
+ prevLabel = "‹ Prev",
946
+ nextLabel = "Next ›",
947
+ class: cls,
948
+ linkClass,
949
+ activeClass,
950
+ children: _ignore,
951
+ ...rest
952
+ } = props as PagerProps & { children?: unknown };
953
+ if (!p || p.lastPage <= 1) return { html: "" };
954
+
955
+ const href = (page: number) => {
956
+ const sp = new URLSearchParams();
957
+ sp.set("page", String(page));
958
+ if (params)
959
+ for (const [k, v] of Object.entries(params))
960
+ if (v !== null && v !== undefined && v !== "") sp.set(k, String(v));
961
+ return "?" + sp.toString();
962
+ };
963
+
964
+ const base = "px-3 py-1.5 rounded-lg text-sm transition " + (linkClass ?? "");
965
+ const activeCls = activeClass ?? "bg-indigo-600 text-white font-semibold";
966
+ const idleCls = "bg-gray-800 hover:bg-gray-700 text-gray-300";
967
+
968
+ const link = (
969
+ page: number,
970
+ label: unknown,
971
+ opts: { active?: boolean; disabled?: boolean } = {},
972
+ ) =>
973
+ opts.disabled
974
+ ? jsx("span", {
975
+ "aria-disabled": "true",
976
+ class: base + "bg-gray-900 text-gray-700 cursor-not-allowed",
977
+ children: label,
978
+ })
979
+ : jsx("a", {
980
+ href: href(page),
981
+ navigate: true,
982
+ ...(hover ? { navigateHover: true } : {}),
983
+ ...(opts.active ? { "aria-current": "page" } : {}),
984
+ class: base + (opts.active ? activeCls : idleCls),
985
+ children: label,
986
+ });
987
+
988
+ const numbers = p
989
+ .elements()
990
+ .map((el, i) =>
991
+ el === "..."
992
+ ? jsx("span", { key: `gap-${i}`, class: "px-2 text-gray-600", children: "…" })
993
+ : link(el as number, String(el), { active: el === p.page }),
994
+ );
995
+
996
+ return jsx("nav", {
997
+ ...rest,
998
+ role: "navigation",
999
+ "aria-label": "Pagination",
1000
+ class: "flex items-center justify-center gap-1 flex-wrap " + (cls ?? ""),
1001
+ children: [
1002
+ link(p.page - 1, prevLabel, { disabled: p.onFirstPage }),
1003
+ ...numbers,
1004
+ link(p.page + 1, nextLabel, { disabled: !p.hasMorePages }),
1005
+ ],
1006
+ });
1007
+ }
1008
+
1009
+ // ── <FileUpload> ──────────────────────────────────────────────────────────────
1010
+ /** Props for {@link FileUpload}: the `bind` target plus `multiple`, `accept` and `label`. */
1011
+ export interface FileUploadProps {
1012
+ /** Bound @expose property — receives the TemporaryUploadedFile (or array). */
1013
+ bind: unknown;
1014
+ multiple?: boolean;
1015
+ accept?: string;
1016
+ label?: string;
1017
+ class?: string;
1018
+ /** Rendered below the dropzone — e.g. a preview/filename of the current value. */
1019
+ children?: unknown;
1020
+ [key: string]: unknown;
1021
+ }
1022
+
1023
+ /**
1024
+ * A dropzone bound to an `@expose` property. Selecting a file POSTs it to
1025
+ * `/__flow/upload` over HTTP, shows live progress, and `$set`s a signed reference the
1026
+ * server resolves into a `TemporaryUploadedFile`. Pairs with the `FileUploads` mixin.
1027
+ *
1028
+ * @remarks
1029
+ * Bind `bind` to the receiving property; `multiple` uploads an array, `accept` filters
1030
+ * the file picker, and `children` render below the dropzone (e.g. a preview of the
1031
+ * current value).
1032
+ *
1033
+ * @example
1034
+ * ```tsx
1035
+ * <FileUpload bind={this.photo} accept="image/*" />
1036
+ * <FileUpload bind={this.docs} multiple />
1037
+ * ```
1038
+ *
1039
+ * @category Forms & inputs
1040
+ */
1041
+ export function FileUpload(props: FileUploadProps): HtmlNode {
1042
+ const { bind, multiple, accept, label = "Choose a file…", class: cls, children, ...rest } = props;
1043
+ const name = _resolveReactiveName(bind);
1044
+
1045
+ const inputAttrs: Record<string, unknown> = { type: "file", class: "sr-only" };
1046
+ if (name) inputAttrs["flow:model"] = name; // file inputs upload over HTTP, then $set the ref
1047
+ if (multiple) inputAttrs["multiple"] = true;
1048
+ if (accept) inputAttrs["accept"] = accept;
1049
+
1050
+ const dropzone = jsx("label", {
1051
+ class:
1052
+ "flex flex-col items-center justify-center gap-1 cursor-pointer rounded-xl border-2 border-dashed " +
1053
+ "border-gray-700 hover:border-gray-600 p-6 text-center text-sm text-gray-400 transition-colors",
1054
+ children: [
1055
+ jsx("input", inputAttrs),
1056
+ jsx("span", { "x-show": "!uploading", children: label }),
1057
+ jsx("span", { "x-show": "uploading", "x-text": "'Uploading… ' + progress + '%'" }),
1058
+ ],
1059
+ });
1060
+
1061
+ const bar = jsx("div", {
1062
+ "x-show": "uploading",
1063
+ "x-cloak": true,
1064
+ class: "mt-2 h-1.5 w-full rounded bg-gray-800 overflow-hidden",
1065
+ children: jsx("div", {
1066
+ class: "h-full bg-indigo-500 transition-all",
1067
+ ":style": "'width: ' + progress + '%'",
1068
+ }),
1069
+ });
1070
+
1071
+ const err = jsx("p", {
1072
+ "x-show": "error",
1073
+ "x-cloak": true,
1074
+ "x-text": "error",
1075
+ class: "mt-2 text-xs text-red-400",
1076
+ });
1077
+
1078
+ return jsx("div", {
1079
+ ...rest,
1080
+ "x-data": name
1081
+ ? `flowFileUpload({ name: ${jsLiteral(name)} })`
1082
+ : "{ uploading: false, progress: 0, error: '' }",
1083
+ class: "flow-fileupload " + (cls ?? ""),
1084
+ children: [dropzone, bar, err, children],
1085
+ });
1086
+ }
1087
+
1088
+ // ── <Tooltip> ─────────────────────────────────────────────────────────────────
1089
+ /** Props for {@link Tooltip}: the tip text (`content`/`text`) and its `placement`. */
1090
+ export interface TooltipProps {
1091
+ content?: unknown;
1092
+ /** Alias for `content`. */
1093
+ text?: unknown;
1094
+ placement?: "top" | "bottom";
1095
+ class?: string;
1096
+ tooltipClass?: string;
1097
+ children?: unknown;
1098
+ [key: string]: unknown;
1099
+ }
1100
+
1101
+ /**
1102
+ * Shows a small tip on hover/focus of its children, with `aria-describedby` wired to
1103
+ * the tooltip. Client-only (Alpine), positioned with CSS relative to the trigger.
1104
+ *
1105
+ * @remarks
1106
+ * Supply the tip via `content` (or its alias `text`); `placement` is `"top"` (default)
1107
+ * or `"bottom"`. The trigger is whatever you pass as `children`.
1108
+ *
1109
+ * @example
1110
+ * ```tsx
1111
+ * <Tooltip content="Copy to clipboard"><button>📋</button></Tooltip>
1112
+ * ```
1113
+ *
1114
+ * @category Overlays
1115
+ */
1116
+ export function Tooltip(props: TooltipProps): HtmlNode {
1117
+ const { content, text, placement = "top", class: cls, tooltipClass, children, ...rest } = props;
1118
+ const tip = content ?? text;
1119
+ const pos = placement === "bottom" ? "top-full mt-1" : "bottom-full mb-1";
1120
+ return jsx("span", {
1121
+ ...rest,
1122
+ "x-data": "{ open: false }",
1123
+ "x-id": "['flow-tooltip']",
1124
+ "x-on:mouseenter": "open = true",
1125
+ "x-on:mouseleave": "open = false",
1126
+ "x-on:focusin": "open = true",
1127
+ "x-on:focusout": "open = false",
1128
+ class: "relative inline-block " + (cls ?? ""),
1129
+ children: [
1130
+ jsx("span", { ":aria-describedby": "$id('flow-tooltip')", tabindex: 0, children }),
1131
+ jsx("span", {
1132
+ role: "tooltip",
1133
+ ":id": "$id('flow-tooltip')",
1134
+ "x-show": "open",
1135
+ "x-cloak": true,
1136
+ "x-transition": true,
1137
+ class:
1138
+ "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 " +
1139
+ pos +
1140
+ (tooltipClass ? " " + tooltipClass : ""),
1141
+ children: tip,
1142
+ }),
1143
+ ],
1144
+ });
1145
+ }
1146
+
1147
+ // ── <Alert> ───────────────────────────────────────────────────────────────────
1148
+ /** Props for {@link Alert}: the colour/role `variant`, `dismissible` flag and optional `title`. */
1149
+ export interface AlertProps {
1150
+ variant?: "info" | "success" | "warning" | "error";
1151
+ dismissible?: boolean;
1152
+ title?: unknown;
1153
+ class?: string;
1154
+ children?: unknown;
1155
+ [key: string]: unknown;
1156
+ }
1157
+
1158
+ const _ALERT_VARIANT: Record<string, string> = {
1159
+ info: "bg-blue-500/10 border-blue-500/40 text-blue-200",
1160
+ success: "bg-emerald-500/10 border-emerald-500/40 text-emerald-200",
1161
+ warning: "bg-amber-500/10 border-amber-500/40 text-amber-200",
1162
+ error: "bg-red-500/10 border-red-500/40 text-red-200",
1163
+ };
1164
+
1165
+ /**
1166
+ * An inline, statically-styled alert box. `variant` (default `"info"`) sets the colour
1167
+ * and ARIA role — `error`/`warning` announce assertively as `role="alert"`, others as
1168
+ * `role="status"`.
1169
+ *
1170
+ * @remarks
1171
+ * `title` renders a bold heading above the body; `dismissible` adds a client-only ×
1172
+ * that hides the alert with no server round-trip.
1173
+ *
1174
+ * @example
1175
+ * ```tsx
1176
+ * <Alert variant="success" dismissible>Saved!</Alert>
1177
+ * ```
1178
+ *
1179
+ * @category Feedback
1180
+ */
1181
+ export function Alert(props: AlertProps): HtmlNode {
1182
+ const { variant = "info", dismissible, title, class: cls, children, ...rest } = props;
1183
+ const role = variant === "error" || variant === "warning" ? "alert" : "status";
1184
+ const body: unknown[] = [];
1185
+ if (title !== undefined && title !== null) {
1186
+ body.push(jsx("div", { class: "font-semibold mb-0.5", children: title }));
1187
+ }
1188
+ body.push(jsx("div", { class: "min-w-0 flex-1", children }));
1189
+ if (dismissible) {
1190
+ body.push(
1191
+ jsx("button", {
1192
+ type: "button",
1193
+ "aria-label": "Dismiss",
1194
+ "x-on:click": "shown = false",
1195
+ class: "shrink-0 text-current/70 hover:text-current text-lg leading-none",
1196
+ children: "×",
1197
+ }),
1198
+ );
1199
+ }
1200
+ return jsx("div", {
1201
+ ...rest,
1202
+ role,
1203
+ "x-data": "{ shown: true }",
1204
+ "x-show": "shown",
1205
+ class:
1206
+ "flex items-start gap-3 rounded-lg border px-4 py-3 text-sm " +
1207
+ (_ALERT_VARIANT[variant] ?? _ALERT_VARIANT["info"]) +
1208
+ (cls ? " " + cls : ""),
1209
+ children: body,
1210
+ });
1211
+ }
1212
+
1213
+ // ── <Table> ───────────────────────────────────────────────────────────────────
1214
+ /** A {@link Table} column definition: its `key`, `label`, `sortable` flag and optional cell `render`. */
1215
+ export interface TableColumn<T = Record<string, unknown>> {
1216
+ key: string;
1217
+ label: unknown;
1218
+ sortable?: boolean;
1219
+ /** Custom cell renderer; defaults to `row[key]`. */
1220
+ render?: (row: T) => unknown;
1221
+ /** Extra classes for this column's cells. */
1222
+ class?: string;
1223
+ }
1224
+
1225
+ /** Props for {@link Table}: the `columns` and `rows`, current `sortBy`/`sortDir`, and styling. */
1226
+ export interface TableProps<T = Record<string, unknown>> {
1227
+ columns: TableColumn<T>[];
1228
+ rows: T[];
1229
+ sortBy?: string;
1230
+ sortDir?: "asc" | "desc";
1231
+ /** Extra query params to keep in sort links (e.g. a search term). */
1232
+ params?: Record<string, string | number | null | undefined>;
1233
+ /** Field used as the row key (defaults to the first column's key). */
1234
+ rowKey?: string;
1235
+ hover?: boolean;
1236
+ class?: string;
1237
+ theadClass?: string;
1238
+ [key: string]: unknown;
1239
+ }
1240
+
1241
+ /**
1242
+ * A data table with URL-driven sortable headers. Clicking a sortable header navigates
1243
+ * to `?sortBy=key&sortDir=asc|desc` (toggling direction) — pair with `@url sortBy` /
1244
+ * `@url sortDir` and sort the rows server-side in `render()`.
1245
+ *
1246
+ * @remarks
1247
+ * Cells default to `row[col.key]`; give a column a `render` for custom content. `params`
1248
+ * preserves other query state (e.g. a search term), `hover` highlights rows, and `rowKey`
1249
+ * (default the first column's key) identifies rows for morph-stable reordering.
1250
+ *
1251
+ * @example
1252
+ * ```tsx
1253
+ * <Table
1254
+ * columns={[{ key: "name", label: "Name", sortable: true }]}
1255
+ * rows={p.data}
1256
+ * sortBy={this.sortBy}
1257
+ * sortDir={this.sortDir}
1258
+ * />
1259
+ * ```
1260
+ *
1261
+ * @category Navigation & data
1262
+ */
1263
+ export function Table<T extends Record<string, unknown>>(props: TableProps<T>): HtmlNode {
1264
+ const {
1265
+ columns,
1266
+ rows,
1267
+ sortBy,
1268
+ sortDir,
1269
+ params,
1270
+ rowKey,
1271
+ hover,
1272
+ class: cls,
1273
+ theadClass,
1274
+ children: _ignore,
1275
+ ...rest
1276
+ } = props as TableProps<T> & { children?: unknown };
1277
+ const keyField = rowKey ?? columns[0]?.key ?? "id";
1278
+
1279
+ const sortHref = (key: string) => {
1280
+ const nextDir = key === sortBy && sortDir === "asc" ? "desc" : "asc";
1281
+ const sp = new URLSearchParams();
1282
+ sp.set("sortBy", key);
1283
+ sp.set("sortDir", nextDir);
1284
+ if (params)
1285
+ for (const [k, v] of Object.entries(params))
1286
+ if (v !== null && v !== undefined && v !== "") sp.set(k, String(v));
1287
+ return "?" + sp.toString();
1288
+ };
1289
+
1290
+ const headCells = columns.map((col) => {
1291
+ if (!col.sortable) {
1292
+ return jsx("th", {
1293
+ scope: "col",
1294
+ class: "px-3 py-2 text-left font-semibold text-gray-300",
1295
+ children: col.label,
1296
+ });
1297
+ }
1298
+ const active = col.key === sortBy;
1299
+ const indicator = active ? (sortDir === "asc" ? " ▲" : " ▼") : "";
1300
+ return jsx("th", {
1301
+ scope: "col",
1302
+ "aria-sort": active ? (sortDir === "asc" ? "ascending" : "descending") : "none",
1303
+ class: "px-3 py-2 text-left font-semibold text-gray-300",
1304
+ children: jsx("a", {
1305
+ href: sortHref(col.key),
1306
+ navigate: true,
1307
+ ...(hover ? { navigateHover: true } : {}),
1308
+ class: "inline-flex items-center gap-0.5 hover:text-white",
1309
+ children: [col.label, indicator],
1310
+ }),
1311
+ });
1312
+ });
1313
+
1314
+ const bodyRows = rows.map((row) =>
1315
+ jsx("tr", {
1316
+ "flow:key": String(row[keyField] ?? ""), // morph keys rows by identity (sort/filter reorders)
1317
+ class: "border-t border-gray-800 " + (hover ? "hover:bg-gray-800/40" : ""),
1318
+ children: columns.map((col) =>
1319
+ jsx("td", {
1320
+ class: "px-3 py-2 text-gray-200 " + (col.class ?? ""),
1321
+ children: col.render ? col.render(row) : (row[col.key] as unknown),
1322
+ }),
1323
+ ),
1324
+ }),
1325
+ );
1326
+
1327
+ return jsx("table", {
1328
+ ...rest,
1329
+ class: "w-full text-sm border-collapse " + (cls ?? ""),
1330
+ children: [
1331
+ jsx("thead", { class: theadClass ?? "", children: jsx("tr", { children: headCells }) }),
1332
+ jsx("tbody", { children: bodyRows }),
1333
+ ],
1334
+ });
1335
+ }
1336
+
1337
+ // ── <Drawer> ──────────────────────────────────────────────────────────────────
1338
+ /** Props for {@link Drawer}: the `show` binding, the `side` it slides from, and title/close options. */
1339
+ export interface DrawerProps {
1340
+ show?: unknown;
1341
+ /** Explicit prop name override — use when auto-resolve is ambiguous. */
1342
+ name?: string;
1343
+ onClose?: unknown;
1344
+ title?: unknown;
1345
+ closable?: boolean;
1346
+ side?: "left" | "right" | "top" | "bottom";
1347
+ /** Extra classes for the panel. */
1348
+ class?: string;
1349
+ children?: unknown;
1350
+ [key: string]: unknown;
1351
+ }
1352
+
1353
+ const _DRAWER_SIDE: Record<string, { pos: string; closed: string }> = {
1354
+ right: { pos: "inset-y-0 right-0 h-full w-80 max-w-full", closed: "translate-x-full" },
1355
+ left: { pos: "inset-y-0 left-0 h-full w-80 max-w-full", closed: "-translate-x-full" },
1356
+ top: { pos: "inset-x-0 top-0 w-full max-h-full", closed: "-translate-y-full" },
1357
+ bottom: { pos: "inset-x-0 bottom-0 w-full max-h-full", closed: "translate-y-full" },
1358
+ };
1359
+
1360
+ /**
1361
+ * A slide-over panel — the edge-anchored sibling of {@link Modal}. Bind `show` to an
1362
+ * `@expose` boolean; backdrop click, the × button and Escape all close it locally with
1363
+ * no server round-trip, and focus is trapped while open.
1364
+ *
1365
+ * @remarks
1366
+ * Slides in from `side` (default `"right"`; also `"left"`, `"top"`, `"bottom"`). `title`
1367
+ * fills the header, `closable` (default `true`) renders the × button, and `onClose`
1368
+ * overrides the default close action. The prop name is auto-resolved; override with `name`.
1369
+ *
1370
+ * @example
1371
+ * ```tsx
1372
+ * <Drawer show={this.cart} side="right" title="Your cart">
1373
+ * <CartItems />
1374
+ * </Drawer>
1375
+ * ```
1376
+ *
1377
+ * @category Overlays
1378
+ */
1379
+ export function Drawer(props: DrawerProps): HtmlNode {
1380
+ const {
1381
+ show,
1382
+ name: nameProp,
1383
+ onClose,
1384
+ title,
1385
+ closable = true,
1386
+ side = "right",
1387
+ class: panelClass,
1388
+ children,
1389
+ ...rest
1390
+ } = props;
1391
+ const name = nameProp ?? _injectedBindKey(props, "show") ?? _resolveReactiveName(show);
1392
+ const open = Boolean(show);
1393
+ const s = _DRAWER_SIDE[side] ?? _DRAWER_SIDE["right"]!;
1394
+ const shownExpr = name ? `$flow.${name}` : open ? "true" : "false";
1395
+ const inAxis = side === "left" || side === "right" ? "translate-x-0" : "translate-y-0";
1396
+
1397
+ const closeProps: Record<string, unknown> = onClose
1398
+ ? { onClick: onClose }
1399
+ : name
1400
+ ? { "flow:click": `$flow.${name} = false` }
1401
+ : {};
1402
+
1403
+ const backdrop = jsx("div", {
1404
+ "x-show": shownExpr,
1405
+ "x-cloak": true,
1406
+ "x-transition:enter": "transition-opacity ease-out duration-300",
1407
+ "x-transition:enter-start": "opacity-0",
1408
+ "x-transition:enter-end": "opacity-100",
1409
+ "x-transition:leave": "transition-opacity ease-in duration-200",
1410
+ "x-transition:leave-start": "opacity-100",
1411
+ "x-transition:leave-end": "opacity-0",
1412
+ class: "fixed inset-0 z-40 bg-black/50",
1413
+ ...closeProps,
1414
+ });
1415
+
1416
+ const titleId = name ? `flow-drawer-title-${name}` : undefined;
1417
+ const header =
1418
+ title || closable
1419
+ ? jsx("div", {
1420
+ class: "flex items-center justify-between gap-4 mb-3",
1421
+ children: [
1422
+ title
1423
+ ? jsx("h2", { id: titleId, class: "text-lg font-semibold", children: title })
1424
+ : "",
1425
+ closable
1426
+ ? jsx("button", {
1427
+ type: "button",
1428
+ "aria-label": "Close",
1429
+ class: "text-gray-400 hover:text-white text-2xl leading-none",
1430
+ children: "×",
1431
+ ...closeProps,
1432
+ })
1433
+ : "",
1434
+ ],
1435
+ })
1436
+ : "";
1437
+
1438
+ const panelOut: Record<string, unknown> = {
1439
+ role: "dialog",
1440
+ "aria-modal": "true",
1441
+ "x-show": shownExpr,
1442
+ "x-cloak": true,
1443
+ "x-transition:enter": "transform transition ease-out duration-300",
1444
+ "x-transition:enter-start": s.closed,
1445
+ "x-transition:enter-end": inAxis,
1446
+ "x-transition:leave": "transform transition ease-in duration-200",
1447
+ "x-transition:leave-start": inAxis,
1448
+ "x-transition:leave-end": s.closed,
1449
+ class:
1450
+ "fixed z-50 bg-gray-900 border border-gray-800 p-5 shadow-xl text-white overflow-auto " +
1451
+ s.pos +
1452
+ " " +
1453
+ (panelClass ?? ""),
1454
+ children: [header, children],
1455
+ };
1456
+ if (name) {
1457
+ panelOut["data-flow-modal"] = name; // Escape-to-close (shared bridge handler)
1458
+ panelOut["x-trap"] = shownExpr; // trap focus while open
1459
+ if (title) panelOut["aria-labelledby"] = titleId;
1460
+ }
1461
+
1462
+ // `contents` wrapper creates no box, so the fixed backdrop/panel aren't blocked
1463
+ // and nothing intercepts clicks when closed (both are display:none via x-show).
1464
+ return jsx("div", { ...rest, class: "contents", children: [backdrop, jsx("div", panelOut)] });
1465
+ }