@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- 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 {'&':'&','<':'<','>':'>','"':'"'}[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
|
+
}
|