@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
package/src/headless.ts
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
// ── Headless components (@zerotal/flow) ──────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Unstyled, fully-accessible interactive primitives — the Headless UI philosophy
|
|
4
|
+
// adapted to Flow. They ship NO visual classes: state is exposed via `data-*`
|
|
5
|
+
// attributes (`data-open`, `data-checked`, `data-active`) so you Tailwind-style
|
|
6
|
+
// them yourself, e.g. `class="data-[open]:rotate-180"`.
|
|
7
|
+
//
|
|
8
|
+
// Behaviour is driven by Alpine (open/close, keyboard, focus) and ARIA roles are
|
|
9
|
+
// wired in. Components that hold a value (Switch) bind to an @expose prop and sync
|
|
10
|
+
// to the server via $flow.$set — so the value persists and submits like any field.
|
|
11
|
+
//
|
|
12
|
+
// <Switch bind={this.enabled} />
|
|
13
|
+
// <Disclosure label="Details">…</Disclosure>
|
|
14
|
+
// <Accordion items={[{ label, content }]} />
|
|
15
|
+
// <Popover trigger={<button>Menu</button>}>…</Popover>
|
|
16
|
+
|
|
17
|
+
import { jsx, _resolveReactiveName, _injectedBindKey } from "./jsx-runtime.ts";
|
|
18
|
+
import type { HtmlNode } from "./jsx-runtime.ts";
|
|
19
|
+
|
|
20
|
+
// Append extra classes without clobbering a structural base.
|
|
21
|
+
function cx(base: string, extra?: string): string {
|
|
22
|
+
return extra ? `${base} ${extra}`.trim() : base;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── <Switch> ──────────────────────────────────────────────────────────────────
|
|
26
|
+
// An accessible on/off toggle (role="switch"). Bind it to an @expose boolean:
|
|
27
|
+
// clicking (or Space/Enter — native on a <button>) flips it and syncs to the
|
|
28
|
+
// server. Style the on-state with `data-[checked]:…`.
|
|
29
|
+
//
|
|
30
|
+
// <Switch bind={this.notify} class="… data-[checked]:bg-indigo-600" />
|
|
31
|
+
|
|
32
|
+
export interface SwitchProps {
|
|
33
|
+
/** Bound @expose boolean — `this.enabled`. */
|
|
34
|
+
bind: unknown;
|
|
35
|
+
class?: string;
|
|
36
|
+
children?: unknown;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An accessible on/off toggle (`role="switch"`) bound to an `@expose` boolean: clicking
|
|
42
|
+
* (or Space/Enter) flips it and syncs to the server via `$flow.$set`. Unstyled — target the
|
|
43
|
+
* on-state with `data-[checked]:…`.
|
|
44
|
+
* @category Headless components
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Switch bind={this.notify} class="… data-[checked]:bg-indigo-600" />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function Switch(props: SwitchProps): HtmlNode {
|
|
52
|
+
const { bind, class: cls, children, ...rest } = props;
|
|
53
|
+
const name = _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
54
|
+
const on = Boolean(bind);
|
|
55
|
+
|
|
56
|
+
const out: Record<string, unknown> = {
|
|
57
|
+
...rest,
|
|
58
|
+
type: "button",
|
|
59
|
+
role: "switch",
|
|
60
|
+
tabindex: 0,
|
|
61
|
+
"aria-checked": on ? "true" : "false", // ARIA wants the string, not a bare attr
|
|
62
|
+
// `group` so a child knob can react to the on-state with `group-data-[checked]:…`
|
|
63
|
+
// (the data-checked attribute lives on this button, not on the child).
|
|
64
|
+
class: cx("flow-switch group", cls),
|
|
65
|
+
children,
|
|
66
|
+
};
|
|
67
|
+
if (on) out["data-checked"] = "";
|
|
68
|
+
|
|
69
|
+
if (name) {
|
|
70
|
+
// flow:bind:attr is imperatively synced by the bridge on every state change,
|
|
71
|
+
// avoiding the Firefox SpiderMonkey issue where reading $flow through a nested
|
|
72
|
+
// Proxy doesn't establish Alpine reactive subscriptions for :attr bindings.
|
|
73
|
+
out["flow:bind:attr"] = JSON.stringify({ "aria-checked": name, "data-checked": name });
|
|
74
|
+
out["x-on:click"] = `$flow.$set('${name}', !$flow.${name})`;
|
|
75
|
+
}
|
|
76
|
+
return jsx("button", out);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── <Disclosure> ────────────────────────────────────────────────────────────────
|
|
80
|
+
// A single collapsible section (button + panel) with correct aria-expanded /
|
|
81
|
+
// aria-controls wiring. Client-side only. `data-open` is exposed on both the
|
|
82
|
+
// trigger and the panel for styling.
|
|
83
|
+
//
|
|
84
|
+
// <Disclosure label="What is your refund policy?">Full refund within 30 days.</Disclosure>
|
|
85
|
+
|
|
86
|
+
export interface DisclosureProps {
|
|
87
|
+
label?: unknown;
|
|
88
|
+
/** Custom trigger (overrides `label`). */
|
|
89
|
+
trigger?: unknown;
|
|
90
|
+
defaultOpen?: boolean;
|
|
91
|
+
class?: string;
|
|
92
|
+
/** Classes for the trigger button. */
|
|
93
|
+
buttonClass?: string;
|
|
94
|
+
/** Classes for the panel. */
|
|
95
|
+
panelClass?: string;
|
|
96
|
+
children?: unknown;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A single collapsible section (trigger button + panel) with correct `aria-expanded` /
|
|
102
|
+
* `aria-controls` wiring. Client-side only; `data-open` is exposed on both parts for styling.
|
|
103
|
+
* @category Headless components
|
|
104
|
+
*/
|
|
105
|
+
export function Disclosure(props: DisclosureProps): HtmlNode {
|
|
106
|
+
const {
|
|
107
|
+
label,
|
|
108
|
+
trigger,
|
|
109
|
+
defaultOpen,
|
|
110
|
+
class: cls,
|
|
111
|
+
buttonClass,
|
|
112
|
+
panelClass,
|
|
113
|
+
children,
|
|
114
|
+
...rest
|
|
115
|
+
} = props;
|
|
116
|
+
|
|
117
|
+
const button = trigger
|
|
118
|
+
? jsx("span", {
|
|
119
|
+
"x-on:click": "open = !open",
|
|
120
|
+
":aria-expanded": "open",
|
|
121
|
+
":data-open": "open ? '' : null",
|
|
122
|
+
"aria-controls": "",
|
|
123
|
+
":aria-controls": "$id('flow-disclosure')",
|
|
124
|
+
role: "button",
|
|
125
|
+
tabindex: 0,
|
|
126
|
+
class: cx("flow-disclosure-button", buttonClass),
|
|
127
|
+
children: trigger,
|
|
128
|
+
})
|
|
129
|
+
: jsx("button", {
|
|
130
|
+
type: "button",
|
|
131
|
+
"x-on:click": "open = !open",
|
|
132
|
+
":aria-expanded": "open",
|
|
133
|
+
":data-open": "open ? '' : null",
|
|
134
|
+
":aria-controls": "$id('flow-disclosure')",
|
|
135
|
+
class: cx("flow-disclosure-button", buttonClass),
|
|
136
|
+
children: label ?? "Details",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const panel = jsx("div", {
|
|
140
|
+
":id": "$id('flow-disclosure')",
|
|
141
|
+
"x-show": "open",
|
|
142
|
+
"x-cloak": true,
|
|
143
|
+
":data-open": "open ? '' : null",
|
|
144
|
+
class: cx("flow-disclosure-panel", panelClass),
|
|
145
|
+
children,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return jsx("div", {
|
|
149
|
+
...rest,
|
|
150
|
+
"x-data": `{ open: ${defaultOpen ? "true" : "false"} }`,
|
|
151
|
+
"x-id": "['flow-disclosure']",
|
|
152
|
+
class: cx("flow-disclosure", cls),
|
|
153
|
+
children: [button, panel],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── <Accordion> ───────────────────────────────────────────────────────────────
|
|
158
|
+
// A group of disclosures. Single-open by default (an "exclusive" accordion); pass
|
|
159
|
+
// `multiple` to allow several open at once. Each item gets aria-expanded/controls.
|
|
160
|
+
//
|
|
161
|
+
// <Accordion items={[{ label: 'A', content: <p/> }, …]} />
|
|
162
|
+
|
|
163
|
+
export interface AccordionItem {
|
|
164
|
+
label: unknown;
|
|
165
|
+
content: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface AccordionProps {
|
|
169
|
+
items: AccordionItem[];
|
|
170
|
+
/** Allow multiple panels open simultaneously (default false → exclusive). */
|
|
171
|
+
multiple?: boolean;
|
|
172
|
+
/** Index open initially (single mode), or -1 for none. */
|
|
173
|
+
defaultIndex?: number;
|
|
174
|
+
class?: string;
|
|
175
|
+
itemClass?: string;
|
|
176
|
+
buttonClass?: string;
|
|
177
|
+
panelClass?: string;
|
|
178
|
+
[key: string]: unknown;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A group of disclosures. Single-open (exclusive) by default; pass `multiple` to allow
|
|
183
|
+
* several open at once. Each item gets `aria-expanded`/`aria-controls` wiring.
|
|
184
|
+
* @category Headless components
|
|
185
|
+
*/
|
|
186
|
+
export function Accordion(props: AccordionProps): HtmlNode {
|
|
187
|
+
const {
|
|
188
|
+
items,
|
|
189
|
+
multiple,
|
|
190
|
+
defaultIndex = -1,
|
|
191
|
+
class: cls,
|
|
192
|
+
itemClass,
|
|
193
|
+
buttonClass,
|
|
194
|
+
panelClass,
|
|
195
|
+
...rest
|
|
196
|
+
} = props;
|
|
197
|
+
|
|
198
|
+
// Single mode tracks one active index; multiple mode tracks an open-map.
|
|
199
|
+
const data = multiple ? `{ open: {} }` : `{ active: ${defaultIndex} }`;
|
|
200
|
+
const isOpen = (i: number) => (multiple ? `open[${i}]` : `active === ${i}`);
|
|
201
|
+
const toggle = (i: number) =>
|
|
202
|
+
multiple ? `open[${i}] = !open[${i}]` : `active = active === ${i} ? -1 : ${i}`;
|
|
203
|
+
|
|
204
|
+
const rows = items.map((it, i) =>
|
|
205
|
+
jsx("div", {
|
|
206
|
+
class: cx("flow-accordion-item", itemClass),
|
|
207
|
+
"x-id": "['flow-accordion']",
|
|
208
|
+
children: [
|
|
209
|
+
jsx("button", {
|
|
210
|
+
type: "button",
|
|
211
|
+
"x-on:click": toggle(i),
|
|
212
|
+
":aria-expanded": isOpen(i),
|
|
213
|
+
":data-open": `${isOpen(i)} ? '' : null`,
|
|
214
|
+
":aria-controls": "$id('flow-accordion')",
|
|
215
|
+
class: cx("flow-accordion-button", buttonClass),
|
|
216
|
+
children: it.label,
|
|
217
|
+
}),
|
|
218
|
+
jsx("div", {
|
|
219
|
+
":id": "$id('flow-accordion')",
|
|
220
|
+
"x-show": isOpen(i),
|
|
221
|
+
"x-cloak": true,
|
|
222
|
+
":data-open": `${isOpen(i)} ? '' : null`,
|
|
223
|
+
class: cx("flow-accordion-panel", panelClass),
|
|
224
|
+
children: it.content,
|
|
225
|
+
}),
|
|
226
|
+
],
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return jsx("div", {
|
|
231
|
+
...rest,
|
|
232
|
+
"x-data": data,
|
|
233
|
+
class: cx("flow-accordion", cls),
|
|
234
|
+
children: rows,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── <Popover> ─────────────────────────────────────────────────────────────────
|
|
239
|
+
// A generic anchored panel: click to open, click-outside or Escape to close. The
|
|
240
|
+
// rigorous, unstyled cousin of <Dropdown>. `data-open` is exposed for styling.
|
|
241
|
+
//
|
|
242
|
+
// <Popover trigger={<button>Solutions ▾</button>}>… arbitrary content …</Popover>
|
|
243
|
+
|
|
244
|
+
export interface PopoverProps {
|
|
245
|
+
label?: unknown;
|
|
246
|
+
trigger?: unknown;
|
|
247
|
+
class?: string;
|
|
248
|
+
buttonClass?: string;
|
|
249
|
+
panelClass?: string;
|
|
250
|
+
children?: unknown;
|
|
251
|
+
[key: string]: unknown;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A generic anchored panel: click to open, click-outside or Escape to close — the rigorous,
|
|
256
|
+
* unstyled cousin of `<Dropdown>`. `data-open` is exposed for styling.
|
|
257
|
+
* @category Headless components
|
|
258
|
+
*/
|
|
259
|
+
export function Popover(props: PopoverProps): HtmlNode {
|
|
260
|
+
const { label, trigger, class: cls, buttonClass, panelClass, children, ...rest } = props;
|
|
261
|
+
|
|
262
|
+
const button = trigger
|
|
263
|
+
? jsx("span", {
|
|
264
|
+
"x-on:click": "open = !open",
|
|
265
|
+
":aria-expanded": "open",
|
|
266
|
+
":data-open": "open ? '' : null",
|
|
267
|
+
role: "button",
|
|
268
|
+
tabindex: 0,
|
|
269
|
+
class: cx("flow-popover-button", buttonClass),
|
|
270
|
+
children: trigger,
|
|
271
|
+
})
|
|
272
|
+
: jsx("button", {
|
|
273
|
+
type: "button",
|
|
274
|
+
"x-on:click": "open = !open",
|
|
275
|
+
":aria-expanded": "open",
|
|
276
|
+
":data-open": "open ? '' : null",
|
|
277
|
+
class: cx("flow-popover-button", buttonClass),
|
|
278
|
+
children: label ?? "Open",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const panel = jsx("div", {
|
|
282
|
+
"x-show": "open",
|
|
283
|
+
"x-cloak": true,
|
|
284
|
+
"x-transition": true,
|
|
285
|
+
"x-on:click.outside": "open = false",
|
|
286
|
+
"x-on:keydown.escape.window": "open = false",
|
|
287
|
+
":data-open": "open ? '' : null",
|
|
288
|
+
class: cx("flow-popover-panel", panelClass),
|
|
289
|
+
children,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return jsx("div", {
|
|
293
|
+
...rest,
|
|
294
|
+
"x-data": "{ open: false }",
|
|
295
|
+
class: cx("flow-popover", cls),
|
|
296
|
+
children: [button, panel],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── <Checkbox> ──────────────────────────────────────────────────────────────
|
|
301
|
+
// An accessible checkbox (role="checkbox") bound to an @expose boolean. Like
|
|
302
|
+
// <Switch> but checkbox-semantic. Style the checked state with `data-[checked]:…`.
|
|
303
|
+
//
|
|
304
|
+
// <Checkbox bind={this.agree} class="… data-[checked]:bg-indigo-600" />
|
|
305
|
+
|
|
306
|
+
export interface CheckboxProps {
|
|
307
|
+
bind: unknown;
|
|
308
|
+
class?: string;
|
|
309
|
+
children?: unknown;
|
|
310
|
+
[key: string]: unknown;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* An accessible checkbox (`role="checkbox"`) bound to an `@expose` boolean — like
|
|
315
|
+
* {@link Switch} but checkbox-semantic. Style the checked state with `data-[checked]:…`.
|
|
316
|
+
* @category Headless components
|
|
317
|
+
*/
|
|
318
|
+
export function Checkbox(props: CheckboxProps): HtmlNode {
|
|
319
|
+
const { bind, class: cls, children, ...rest } = props;
|
|
320
|
+
const name = _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
321
|
+
const on = Boolean(bind);
|
|
322
|
+
|
|
323
|
+
const out: Record<string, unknown> = {
|
|
324
|
+
...rest,
|
|
325
|
+
type: "button",
|
|
326
|
+
role: "checkbox",
|
|
327
|
+
tabindex: 0,
|
|
328
|
+
"aria-checked": on ? "true" : "false",
|
|
329
|
+
class: cx("flow-checkbox", cls),
|
|
330
|
+
children,
|
|
331
|
+
};
|
|
332
|
+
if (on) out["data-checked"] = "";
|
|
333
|
+
if (name) {
|
|
334
|
+
out["flow:bind:attr"] = JSON.stringify({ "aria-checked": name, "data-checked": name });
|
|
335
|
+
out["x-on:click"] = `$flow.$set('${name}', !$flow.${name})`;
|
|
336
|
+
}
|
|
337
|
+
return jsx("button", out);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── <Select> ──────────────────────────────────────────────────────────────────
|
|
341
|
+
// A styled native <select> bound to an @expose value (via flow:model). Reliable
|
|
342
|
+
// and fully accessible for free — reach for <Listbox> only when you need custom
|
|
343
|
+
// option markup.
|
|
344
|
+
//
|
|
345
|
+
// <Select bind={this.country} options={[{ label: 'Canada', value: 'ca' }]} />
|
|
346
|
+
|
|
347
|
+
export interface SelectOption {
|
|
348
|
+
label: unknown;
|
|
349
|
+
value: string | number;
|
|
350
|
+
}
|
|
351
|
+
export interface SelectProps {
|
|
352
|
+
bind: unknown;
|
|
353
|
+
options: SelectOption[];
|
|
354
|
+
/** Optional empty/placeholder first option. */
|
|
355
|
+
placeholder?: string;
|
|
356
|
+
class?: string;
|
|
357
|
+
[key: string]: unknown;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* A styled native `<select>` bound to an `@expose` value via `flow:model` — reliable and
|
|
362
|
+
* fully accessible for free. Reach for {@link Listbox} only when you need custom option markup.
|
|
363
|
+
* @category Headless components
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```tsx
|
|
367
|
+
* <Select bind={this.country} options={[{ label: 'Canada', value: 'ca' }]} />
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
export function Select(props: SelectProps): HtmlNode {
|
|
371
|
+
const {
|
|
372
|
+
bind,
|
|
373
|
+
options,
|
|
374
|
+
placeholder,
|
|
375
|
+
class: cls,
|
|
376
|
+
children: _ignore,
|
|
377
|
+
...rest
|
|
378
|
+
} = props as SelectProps & { children?: unknown };
|
|
379
|
+
const name = _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
380
|
+
const current = bind as string | number | undefined;
|
|
381
|
+
|
|
382
|
+
const opts = options.map((o) =>
|
|
383
|
+
jsx("option", {
|
|
384
|
+
value: o.value,
|
|
385
|
+
...(o.value === current ? { selected: true } : {}),
|
|
386
|
+
children: o.label,
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
const all = placeholder
|
|
390
|
+
? [
|
|
391
|
+
jsx("option", {
|
|
392
|
+
value: "",
|
|
393
|
+
disabled: true,
|
|
394
|
+
...(current == null || current === "" ? { selected: true } : {}),
|
|
395
|
+
children: placeholder,
|
|
396
|
+
}),
|
|
397
|
+
...opts,
|
|
398
|
+
]
|
|
399
|
+
: opts;
|
|
400
|
+
|
|
401
|
+
const out: Record<string, unknown> = { ...rest, class: cx("flow-select", cls), children: all };
|
|
402
|
+
if (name) out["flow:model"] = name;
|
|
403
|
+
return jsx("select", out);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── <RadioGroup> ──────────────────────────────────────────────────────────────
|
|
407
|
+
// An accessible set of radios (role="radiogroup"/"radio") bound to an @expose
|
|
408
|
+
// value, with arrow-key roving (flowRadioGroup runtime). Style the selected option
|
|
409
|
+
// with `data-[checked]:…`.
|
|
410
|
+
//
|
|
411
|
+
// <RadioGroup bind={this.plan} options={[{ label: 'Pro', value: 'pro' }]} />
|
|
412
|
+
|
|
413
|
+
export interface RadioOption {
|
|
414
|
+
label: unknown;
|
|
415
|
+
value: string | number;
|
|
416
|
+
}
|
|
417
|
+
export interface RadioGroupProps {
|
|
418
|
+
bind: unknown;
|
|
419
|
+
options: RadioOption[];
|
|
420
|
+
class?: string;
|
|
421
|
+
optionClass?: string;
|
|
422
|
+
[key: string]: unknown;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* An accessible set of radios (`role="radiogroup"`/`"radio"`) bound to an `@expose` value,
|
|
427
|
+
* with arrow-key roving. Style the selected option with `data-[checked]:…`.
|
|
428
|
+
* @category Headless components
|
|
429
|
+
*/
|
|
430
|
+
export function RadioGroup(props: RadioGroupProps): HtmlNode {
|
|
431
|
+
const {
|
|
432
|
+
bind,
|
|
433
|
+
options,
|
|
434
|
+
class: cls,
|
|
435
|
+
optionClass,
|
|
436
|
+
children: _ignore,
|
|
437
|
+
...rest
|
|
438
|
+
} = props as RadioGroupProps & { children?: unknown };
|
|
439
|
+
const name = _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
440
|
+
const current = bind;
|
|
441
|
+
|
|
442
|
+
const radios = options.map((o, i) => {
|
|
443
|
+
const v = JSON.stringify(o.value);
|
|
444
|
+
const selected = o.value === current;
|
|
445
|
+
const out: Record<string, unknown> = {
|
|
446
|
+
role: "radio",
|
|
447
|
+
"aria-checked": selected ? "true" : "false",
|
|
448
|
+
class: cx("flow-radio", optionClass),
|
|
449
|
+
children: o.label,
|
|
450
|
+
};
|
|
451
|
+
if (selected) out["data-checked"] = "";
|
|
452
|
+
// first option is tab-reachable when nothing is selected (roving tabindex)
|
|
453
|
+
out["tabindex"] = selected || (current == null && i === 0) ? 0 : -1;
|
|
454
|
+
if (name) {
|
|
455
|
+
out[":aria-checked"] = `$flow.${name} === ${v}`;
|
|
456
|
+
out[":data-checked"] = `$flow.${name} === ${v} ? '' : null`;
|
|
457
|
+
out[":tabindex"] = `$flow.${name} === ${v} || ($flow.${name} == null && ${i === 0}) ? 0 : -1`;
|
|
458
|
+
out["x-on:click"] = `select(${v})`;
|
|
459
|
+
out["x-on:keydown"] = "onKey($event)";
|
|
460
|
+
}
|
|
461
|
+
return jsx("div", out);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return jsx("div", {
|
|
465
|
+
...rest,
|
|
466
|
+
role: "radiogroup",
|
|
467
|
+
"x-data": name ? `flowRadioGroup({ name: '${name}' })` : "{}",
|
|
468
|
+
class: cx("flow-radiogroup", cls),
|
|
469
|
+
children: radios,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── <Listbox> ─────────────────────────────────────────────────────────────────
|
|
474
|
+
// A custom, fully-keyboard-navigable select (role="listbox") bound to an @expose
|
|
475
|
+
// value — arrow keys, Home/End, Enter/Escape, aria-activedescendant. Use custom
|
|
476
|
+
// option markup; the committed value round-trips through the server. Pass
|
|
477
|
+
// `multiple` for multi-select (value is an array). States: data-[selected],
|
|
478
|
+
// data-[active], data-[open].
|
|
479
|
+
//
|
|
480
|
+
// <Listbox bind={this.assignee} options={[{ label: 'Jo', value: 1 }]} />
|
|
481
|
+
|
|
482
|
+
export interface ListboxOption {
|
|
483
|
+
label: unknown;
|
|
484
|
+
value: string | number;
|
|
485
|
+
}
|
|
486
|
+
export interface ListboxProps {
|
|
487
|
+
bind: unknown;
|
|
488
|
+
options: ListboxOption[];
|
|
489
|
+
multiple?: boolean;
|
|
490
|
+
placeholder?: string;
|
|
491
|
+
class?: string;
|
|
492
|
+
buttonClass?: string;
|
|
493
|
+
optionsClass?: string;
|
|
494
|
+
optionClass?: string;
|
|
495
|
+
[key: string]: unknown;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A custom, fully keyboard-navigable select (`role="listbox"`) bound to an `@expose` value —
|
|
500
|
+
* arrow keys, Home/End, Enter/Escape, `aria-activedescendant`. Pass `multiple` for
|
|
501
|
+
* multi-select (value is an array). States: `data-[selected]`, `data-[active]`, `data-[open]`.
|
|
502
|
+
* @category Headless components
|
|
503
|
+
*/
|
|
504
|
+
export function Listbox(props: ListboxProps): HtmlNode {
|
|
505
|
+
const {
|
|
506
|
+
bind,
|
|
507
|
+
options,
|
|
508
|
+
multiple,
|
|
509
|
+
placeholder,
|
|
510
|
+
class: cls,
|
|
511
|
+
buttonClass,
|
|
512
|
+
optionsClass,
|
|
513
|
+
optionClass,
|
|
514
|
+
children: _ignore,
|
|
515
|
+
...rest
|
|
516
|
+
} = props as ListboxProps & { children?: unknown };
|
|
517
|
+
const name = _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
518
|
+
const current = bind;
|
|
519
|
+
|
|
520
|
+
// Server-rendered button label (updates on the round-trip after select).
|
|
521
|
+
const selectedLabel = (() => {
|
|
522
|
+
if (multiple) {
|
|
523
|
+
const arr = Array.isArray(current) ? current : [];
|
|
524
|
+
const labels = options.filter((o) => arr.includes(o.value)).map((o) => o.label);
|
|
525
|
+
return labels.length ? labels.join(", ") : (placeholder ?? "Select…");
|
|
526
|
+
}
|
|
527
|
+
const hit = options.find((o) => o.value === current);
|
|
528
|
+
return hit ? hit.label : (placeholder ?? "Select…");
|
|
529
|
+
})();
|
|
530
|
+
|
|
531
|
+
const button = jsx("button", {
|
|
532
|
+
type: "button",
|
|
533
|
+
"x-on:click": "toggle()",
|
|
534
|
+
"x-on:keydown": "onButtonKey($event)",
|
|
535
|
+
":aria-expanded": "open",
|
|
536
|
+
"aria-haspopup": "listbox",
|
|
537
|
+
":data-open": "open ? '' : null",
|
|
538
|
+
class: cx("flow-listbox-button", buttonClass),
|
|
539
|
+
children: selectedLabel,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const optionEls = options.map((o, i) => {
|
|
543
|
+
const v = JSON.stringify(o.value);
|
|
544
|
+
const selected = multiple
|
|
545
|
+
? Array.isArray(current) && current.includes(o.value)
|
|
546
|
+
: o.value === current;
|
|
547
|
+
const out: Record<string, unknown> = {
|
|
548
|
+
id: name ? `${name}-opt-${i}` : undefined,
|
|
549
|
+
role: "option",
|
|
550
|
+
"data-value": v,
|
|
551
|
+
"aria-selected": selected ? "true" : "false",
|
|
552
|
+
":aria-selected": `isSelected(${v})`,
|
|
553
|
+
":data-selected": `isSelected(${v}) ? '' : null`,
|
|
554
|
+
":data-active": `active === ${i} ? '' : null`,
|
|
555
|
+
"x-on:click": `select(${v})`,
|
|
556
|
+
"x-on:mousemove": `active = ${i}`,
|
|
557
|
+
class: cx("flow-listbox-option", optionClass),
|
|
558
|
+
children: o.label,
|
|
559
|
+
};
|
|
560
|
+
if (selected) out["data-selected"] = "";
|
|
561
|
+
return jsx("li", out);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const list = jsx("ul", {
|
|
565
|
+
role: "listbox",
|
|
566
|
+
tabindex: -1,
|
|
567
|
+
"x-show": "open",
|
|
568
|
+
"x-cloak": true,
|
|
569
|
+
"x-on:keydown": "onKey($event)",
|
|
570
|
+
"x-on:click.outside": "close()",
|
|
571
|
+
":aria-activedescendant": name ? `active >= 0 ? '${name}-opt-' + active : null` : "null",
|
|
572
|
+
...(multiple ? { "aria-multiselectable": "true" } : {}),
|
|
573
|
+
class: cx("flow-listbox-options", optionsClass),
|
|
574
|
+
children: optionEls,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return jsx("div", {
|
|
578
|
+
...rest,
|
|
579
|
+
"x-data": name
|
|
580
|
+
? `flowListbox({ name: '${name}', multiple: ${!!multiple} })`
|
|
581
|
+
: "{ open: false, active: -1 }",
|
|
582
|
+
class: cx("flow-listbox", cls),
|
|
583
|
+
children: [button, list],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── <Combobox> ────────────────────────────────────────────────────────────────
|
|
588
|
+
// An autocomplete: a text input + a filtered option list, bound to an @expose
|
|
589
|
+
// value. Two modes:
|
|
590
|
+
// • client (default) — options are rendered once and filtered locally as you type.
|
|
591
|
+
// • server — pass `query={this.search}`: the input uses flow:model.live, so the
|
|
592
|
+
// SERVER re-renders the (already-filtered) options every keystroke. Render
|
|
593
|
+
// `options` from your filtered server list. This is server-side autocomplete
|
|
594
|
+
// with full headless UX.
|
|
595
|
+
//
|
|
596
|
+
// <Combobox bind={this.assignee} options={people} /> // client filter
|
|
597
|
+
// <Combobox bind={this.cityId} query={this.search} options={hits} /> // server filter
|
|
598
|
+
//
|
|
599
|
+
// For an unambiguous binding you can pass explicit prop names (`name` / `queryName`
|
|
600
|
+
// strings) instead of `bind` / `query`. States: data-[active], data-[selected].
|
|
601
|
+
// ARIA: role=combobox/listbox/option.
|
|
602
|
+
|
|
603
|
+
export interface ComboboxOption {
|
|
604
|
+
label: string;
|
|
605
|
+
value: string | number;
|
|
606
|
+
}
|
|
607
|
+
export interface ComboboxProps {
|
|
608
|
+
bind?: unknown;
|
|
609
|
+
/** Present → server-filter mode: the bound query @expose prop. */
|
|
610
|
+
query?: unknown;
|
|
611
|
+
/** Explicit value prop-name (escape hatch instead of `bind`). */
|
|
612
|
+
name?: string;
|
|
613
|
+
/** Explicit query prop-name (escape hatch instead of `query`). */
|
|
614
|
+
queryName?: string;
|
|
615
|
+
options: ComboboxOption[];
|
|
616
|
+
placeholder?: string;
|
|
617
|
+
class?: string;
|
|
618
|
+
inputClass?: string;
|
|
619
|
+
optionsClass?: string;
|
|
620
|
+
optionClass?: string;
|
|
621
|
+
[key: string]: unknown;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* An autocomplete: a text input plus a filtered option list bound to an `@expose` value.
|
|
626
|
+
* Client mode (default) filters locally; passing `query={this.search}` switches to server-side
|
|
627
|
+
* filtering, where the input uses `flow:model.live` and the server re-renders matches per
|
|
628
|
+
* keystroke. States: `data-[active]`, `data-[selected]`.
|
|
629
|
+
* @category Headless components
|
|
630
|
+
*
|
|
631
|
+
* @example
|
|
632
|
+
* ```tsx
|
|
633
|
+
* <Combobox bind={this.assignee} options={people} /> // client filter
|
|
634
|
+
* <Combobox bind={this.cityId} query={this.search} options={hits} /> // server filter
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
export function Combobox(props: ComboboxProps): HtmlNode {
|
|
638
|
+
const {
|
|
639
|
+
bind,
|
|
640
|
+
query,
|
|
641
|
+
name: nameProp,
|
|
642
|
+
queryName: queryNameProp,
|
|
643
|
+
options,
|
|
644
|
+
placeholder,
|
|
645
|
+
class: cls,
|
|
646
|
+
inputClass,
|
|
647
|
+
optionsClass,
|
|
648
|
+
optionClass,
|
|
649
|
+
children: _ignore,
|
|
650
|
+
...rest
|
|
651
|
+
} = props as ComboboxProps & { children?: unknown };
|
|
652
|
+
|
|
653
|
+
// Resolve the QUERY binding first (its capture is freshest — it's evaluated last
|
|
654
|
+
// in the props), then the value binding (identity fallback). `name`/`queryName`
|
|
655
|
+
// strings are explicit overrides — note `bind`/`query` are reactive *values*, so
|
|
656
|
+
// we never treat a string value as a name.
|
|
657
|
+
const queryName =
|
|
658
|
+
queryNameProp ??
|
|
659
|
+
_injectedBindKey(props, "query") ??
|
|
660
|
+
(query === undefined ? null : _resolveReactiveName(query));
|
|
661
|
+
const name = nameProp ?? _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
662
|
+
const server = queryName != null;
|
|
663
|
+
const current = bind;
|
|
664
|
+
|
|
665
|
+
const selectedLabel = (() => {
|
|
666
|
+
const hit = options.find((o) => o.value === current);
|
|
667
|
+
return hit ? hit.label : "";
|
|
668
|
+
})();
|
|
669
|
+
|
|
670
|
+
const listId = name ? `${name}-list` : "flow-combobox-list";
|
|
671
|
+
|
|
672
|
+
const input = jsx("input", {
|
|
673
|
+
type: "text",
|
|
674
|
+
role: "combobox",
|
|
675
|
+
autocomplete: "off",
|
|
676
|
+
"aria-autocomplete": "list",
|
|
677
|
+
"aria-controls": listId,
|
|
678
|
+
":aria-expanded": "open",
|
|
679
|
+
...(placeholder ? { placeholder } : {}),
|
|
680
|
+
...(server ? (queryName ? { "flow:model.live": queryName } : {}) : { ":value": "query" }),
|
|
681
|
+
"x-on:input": "onInput($event)",
|
|
682
|
+
"x-on:focus": "openList()",
|
|
683
|
+
"x-on:click": "openList()",
|
|
684
|
+
"x-on:keydown": "onKey($event)",
|
|
685
|
+
class: cx("flow-combobox-input", inputClass),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const optionEls = options.map((o, i) => {
|
|
689
|
+
const v = JSON.stringify(o.value);
|
|
690
|
+
const labelLower = JSON.stringify(o.label.toLowerCase());
|
|
691
|
+
const selected = o.value === current;
|
|
692
|
+
const out: Record<string, unknown> = {
|
|
693
|
+
id: name ? `${name}-opt-${i}` : undefined,
|
|
694
|
+
role: "option",
|
|
695
|
+
"data-value": v,
|
|
696
|
+
"data-label": o.label,
|
|
697
|
+
"aria-selected": selected ? "true" : "false",
|
|
698
|
+
":aria-selected": `isSelected(${v})`,
|
|
699
|
+
":data-selected": `isSelected(${v}) ? '' : null`,
|
|
700
|
+
":data-active": `active === ${i} ? '' : null`,
|
|
701
|
+
"x-on:click": "selectEl($event.currentTarget)",
|
|
702
|
+
"x-on:mousemove": `active = ${i}`,
|
|
703
|
+
class: cx("flow-combobox-option", optionClass),
|
|
704
|
+
children: o.label,
|
|
705
|
+
};
|
|
706
|
+
if (selected) out["data-selected"] = "";
|
|
707
|
+
// Client mode filters locally; server mode renders only matches already.
|
|
708
|
+
if (!server) out["x-show"] = `!query || ${labelLower}.includes(query.toLowerCase())`;
|
|
709
|
+
return jsx("li", out);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const list = jsx("ul", {
|
|
713
|
+
id: listId,
|
|
714
|
+
role: "listbox",
|
|
715
|
+
"x-show": "open",
|
|
716
|
+
"x-cloak": true,
|
|
717
|
+
"x-on:click.outside": "close()",
|
|
718
|
+
":aria-activedescendant": name ? `active >= 0 ? '${name}-opt-' + active : null` : "null",
|
|
719
|
+
class: cx("flow-combobox-options", optionsClass),
|
|
720
|
+
children: optionEls,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const config = name
|
|
724
|
+
? `flowCombobox({ name: '${name}', queryName: ${queryName ? `'${queryName}'` : "null"}, query: ${JSON.stringify(server ? "" : selectedLabel)} })`
|
|
725
|
+
: "{ open: false, active: -1, query: '' }";
|
|
726
|
+
|
|
727
|
+
return jsx("div", {
|
|
728
|
+
...rest,
|
|
729
|
+
"x-data": config,
|
|
730
|
+
class: cx("flow-combobox", cls),
|
|
731
|
+
children: [input, list],
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── <Field> / <Label> / <Description> ─────────────────────────────────────────
|
|
736
|
+
// Accessibility glue around a single control. <Field> wraps a label, the control,
|
|
737
|
+
// an optional description, and an error, and (via the flowField runtime) wires
|
|
738
|
+
// `for`/`id`/`aria-describedby` and keeps `aria-invalid` in sync with the error.
|
|
739
|
+
//
|
|
740
|
+
// <Field label="Email" description="We never share it." error={this.errors.email}>
|
|
741
|
+
// <input value={this.form.email} />
|
|
742
|
+
// </Field>
|
|
743
|
+
//
|
|
744
|
+
// You can also compose <Label>/<Description> manually inside <Field>.
|
|
745
|
+
|
|
746
|
+
export interface LabelProps {
|
|
747
|
+
class?: string;
|
|
748
|
+
children?: unknown;
|
|
749
|
+
[key: string]: unknown;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* A styled `<label>`. Compose inside {@link Field} (which wires `for`/`id`) or use standalone.
|
|
753
|
+
* @category Headless components
|
|
754
|
+
*/
|
|
755
|
+
export function Label(props: LabelProps): HtmlNode {
|
|
756
|
+
const { class: cls, children, ...rest } = props;
|
|
757
|
+
return jsx("label", { ...rest, class: cx("flow-label", cls), children });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export interface DescriptionProps {
|
|
761
|
+
class?: string;
|
|
762
|
+
children?: unknown;
|
|
763
|
+
[key: string]: unknown;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Help/description text for a control; wired as `aria-describedby` when composed in {@link Field}.
|
|
767
|
+
* @category Headless components
|
|
768
|
+
*/
|
|
769
|
+
export function Description(props: DescriptionProps): HtmlNode {
|
|
770
|
+
const { class: cls, children, ...rest } = props;
|
|
771
|
+
return jsx("p", {
|
|
772
|
+
...rest,
|
|
773
|
+
"data-flow-description": true,
|
|
774
|
+
class: cx("flow-description", cls),
|
|
775
|
+
children,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export interface FieldProps {
|
|
780
|
+
label?: unknown;
|
|
781
|
+
description?: unknown;
|
|
782
|
+
/** The field accessor — `this.errors.<field>`; renders a self-hiding message. */
|
|
783
|
+
error?: unknown;
|
|
784
|
+
class?: string;
|
|
785
|
+
children?: unknown;
|
|
786
|
+
[key: string]: unknown;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Accessibility glue around a single control: wraps a label, the control, an optional
|
|
791
|
+
* description, and an error, wiring `for`/`id`/`aria-describedby` and keeping `aria-invalid`
|
|
792
|
+
* in sync with the error. Compose `<Label>`/`<Description>` manually or pass them as props.
|
|
793
|
+
* @category Headless components
|
|
794
|
+
*
|
|
795
|
+
* @example
|
|
796
|
+
* ```tsx
|
|
797
|
+
* <Field label="Email" description="We never share it." error={this.errors.email}>
|
|
798
|
+
* <input value={this.form.email} />
|
|
799
|
+
* </Field>
|
|
800
|
+
* ```
|
|
801
|
+
*/
|
|
802
|
+
export function Field(props: FieldProps): HtmlNode {
|
|
803
|
+
const { label, description, error, class: cls, children, ...rest } = props;
|
|
804
|
+
const kids: unknown[] = [];
|
|
805
|
+
if (label !== undefined && label !== null) kids.push(Label({ children: label }));
|
|
806
|
+
kids.push(children);
|
|
807
|
+
if (description !== undefined && description !== null)
|
|
808
|
+
kids.push(Description({ children: description }));
|
|
809
|
+
if (error !== undefined && error !== null) {
|
|
810
|
+
// Same as <Error>: the `error` directive emits flow:error + flow:show + SSR text.
|
|
811
|
+
kids.push(jsx("span", { error, class: "flow-field-error text-red-400 text-xs" }));
|
|
812
|
+
}
|
|
813
|
+
return jsx("div", {
|
|
814
|
+
...rest,
|
|
815
|
+
"x-data": "flowField()",
|
|
816
|
+
class: cx("flow-field", cls),
|
|
817
|
+
children: kids,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── <Fieldset> / <Legend> ───────────────────────────────────────────────────────
|
|
822
|
+
// Group related fields. A native <fieldset disabled> cascades the disabled state
|
|
823
|
+
// to every control inside it.
|
|
824
|
+
//
|
|
825
|
+
// <Fieldset legend="Shipping" disabled={this.saving}>…</Fieldset>
|
|
826
|
+
|
|
827
|
+
export interface LegendProps {
|
|
828
|
+
class?: string;
|
|
829
|
+
children?: unknown;
|
|
830
|
+
[key: string]: unknown;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* A styled `<legend>` for a {@link Fieldset}.
|
|
834
|
+
* @category Headless components
|
|
835
|
+
*/
|
|
836
|
+
export function Legend(props: LegendProps): HtmlNode {
|
|
837
|
+
const { class: cls, children, ...rest } = props;
|
|
838
|
+
return jsx("legend", { ...rest, class: cx("flow-legend", cls), children });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export interface FieldsetProps {
|
|
842
|
+
legend?: unknown;
|
|
843
|
+
disabled?: boolean;
|
|
844
|
+
class?: string;
|
|
845
|
+
children?: unknown;
|
|
846
|
+
[key: string]: unknown;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Groups related fields under an optional legend. A native `<fieldset disabled>` cascades the
|
|
851
|
+
* disabled state to every control inside it.
|
|
852
|
+
* @category Headless components
|
|
853
|
+
*
|
|
854
|
+
* @example
|
|
855
|
+
* ```tsx
|
|
856
|
+
* <Fieldset legend="Shipping" disabled={this.saving}>…</Fieldset>
|
|
857
|
+
* ```
|
|
858
|
+
*/
|
|
859
|
+
export function Fieldset(props: FieldsetProps): HtmlNode {
|
|
860
|
+
const { legend, disabled, class: cls, children, ...rest } = props;
|
|
861
|
+
const kids: unknown[] = [];
|
|
862
|
+
if (legend !== undefined && legend !== null) kids.push(Legend({ children: legend }));
|
|
863
|
+
kids.push(children);
|
|
864
|
+
const out: Record<string, unknown> = { ...rest, class: cx("flow-fieldset", cls), children: kids };
|
|
865
|
+
if (disabled) out["disabled"] = true;
|
|
866
|
+
return jsx("fieldset", out);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ── <Slider> ──────────────────────────────────────────────────────────────────
|
|
870
|
+
// A value chosen from a range, over a native `<input type="range">` so keyboard,
|
|
871
|
+
// touch and screen-reader support come from the platform rather than being
|
|
872
|
+
// rebuilt. Alpine owns the live value: the fill and any readout follow the thumb
|
|
873
|
+
// at frame rate, and the bound @expose prop is written once the drag settles.
|
|
874
|
+
//
|
|
875
|
+
// `data-dragging` is exposed while the pointer is down, so a readout can be
|
|
876
|
+
// shown only during the drag if you'd rather it not sit there permanently.
|
|
877
|
+
//
|
|
878
|
+
// <Slider bind={this.volume} min={0} max={100} />
|
|
879
|
+
|
|
880
|
+
export interface SliderProps {
|
|
881
|
+
/** Bound @expose number — `this.volume`. */
|
|
882
|
+
bind?: unknown;
|
|
883
|
+
name?: string;
|
|
884
|
+
min?: number;
|
|
885
|
+
max?: number;
|
|
886
|
+
step?: number;
|
|
887
|
+
class?: string;
|
|
888
|
+
inputClass?: string;
|
|
889
|
+
/**
|
|
890
|
+
* Initial inline style for the input — a track gradient, typically.
|
|
891
|
+
*
|
|
892
|
+
* Server-rendered so the track is filled on first paint, then replaced by
|
|
893
|
+
* {@link SliderProps.inputStyleExpression} once Alpine takes over.
|
|
894
|
+
*/
|
|
895
|
+
inputStyle?: string;
|
|
896
|
+
/**
|
|
897
|
+
* Alpine expression producing the input's style while dragging. `value` and
|
|
898
|
+
* `percent()` are in scope.
|
|
899
|
+
*/
|
|
900
|
+
inputStyleExpression?: string;
|
|
901
|
+
/** Rendered inside the wrapper, after the input — a readout, a tick strip. */
|
|
902
|
+
children?: unknown;
|
|
903
|
+
[key: string]: unknown;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export function Slider(props: SliderProps): HtmlNode {
|
|
907
|
+
const {
|
|
908
|
+
bind,
|
|
909
|
+
name: nameProp,
|
|
910
|
+
min = 0,
|
|
911
|
+
max = 100,
|
|
912
|
+
step = 1,
|
|
913
|
+
class: cls,
|
|
914
|
+
inputClass,
|
|
915
|
+
inputStyle,
|
|
916
|
+
inputStyleExpression,
|
|
917
|
+
children,
|
|
918
|
+
...rest
|
|
919
|
+
} = props;
|
|
920
|
+
|
|
921
|
+
const name = nameProp ?? _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
922
|
+
const value = typeof bind === "number" ? bind : min;
|
|
923
|
+
|
|
924
|
+
const config = JSON.stringify({ name, min, max, step });
|
|
925
|
+
|
|
926
|
+
return jsx("div", {
|
|
927
|
+
...rest,
|
|
928
|
+
"x-data": `flowSlider(${config})`,
|
|
929
|
+
":data-dragging": "dragging ? '' : null",
|
|
930
|
+
class: cx("flow-slider", cls),
|
|
931
|
+
children: [
|
|
932
|
+
jsx("input", {
|
|
933
|
+
type: "range",
|
|
934
|
+
min,
|
|
935
|
+
max,
|
|
936
|
+
step,
|
|
937
|
+
value,
|
|
938
|
+
...(inputStyle ? { style: inputStyle } : {}),
|
|
939
|
+
...(inputStyleExpression ? { ":style": inputStyleExpression } : {}),
|
|
940
|
+
"x-model.number": "value",
|
|
941
|
+
// Local while moving, synced on release: the two events are what keep a
|
|
942
|
+
// drag smooth and the server eventually correct.
|
|
943
|
+
"x-on:input": "onInput($event)",
|
|
944
|
+
"x-on:change": "commit()",
|
|
945
|
+
"x-on:keyup.debounce.200ms": "commit()",
|
|
946
|
+
class: cx("flow-slider-input", inputClass),
|
|
947
|
+
}),
|
|
948
|
+
children,
|
|
949
|
+
],
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ── <Toggle> / <ToggleGroup> ──────────────────────────────────────────────────
|
|
954
|
+
// A button that stays pressed, and a set of them. Distinct from Switch: a switch
|
|
955
|
+
// is a setting that applies immediately, a toggle is a mode you are in — which
|
|
956
|
+
// is why this reports `aria-pressed` rather than `role="switch"`.
|
|
957
|
+
//
|
|
958
|
+
// The pressed state flips locally the instant it is clicked and syncs to the
|
|
959
|
+
// bound prop after, so the button never waits on the network to look pressed.
|
|
960
|
+
//
|
|
961
|
+
// <ToggleGroup bind={this.view} options={[{ value: "grid", label: "Grid" }]} />
|
|
962
|
+
|
|
963
|
+
export interface ToggleOption {
|
|
964
|
+
value: string;
|
|
965
|
+
label?: unknown;
|
|
966
|
+
disabled?: boolean;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export interface ToggleGroupProps {
|
|
970
|
+
/** Bound @expose value — a string, or an array when `multiple`. */
|
|
971
|
+
bind?: unknown;
|
|
972
|
+
name?: string;
|
|
973
|
+
options: ToggleOption[];
|
|
974
|
+
/** Allow several pressed at once; the bound value becomes an array. */
|
|
975
|
+
multiple?: boolean;
|
|
976
|
+
class?: string;
|
|
977
|
+
optionClass?: string;
|
|
978
|
+
[key: string]: unknown;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function ToggleGroup(props: ToggleGroupProps): HtmlNode {
|
|
982
|
+
const {
|
|
983
|
+
bind,
|
|
984
|
+
name: nameProp,
|
|
985
|
+
options,
|
|
986
|
+
multiple,
|
|
987
|
+
class: cls,
|
|
988
|
+
optionClass,
|
|
989
|
+
children: _ignore,
|
|
990
|
+
...rest
|
|
991
|
+
} = props as ToggleGroupProps & { children?: unknown };
|
|
992
|
+
|
|
993
|
+
const name = nameProp ?? _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
994
|
+
const current = new Set(Array.isArray(bind) ? bind.map(String) : bind ? [String(bind)] : []);
|
|
995
|
+
|
|
996
|
+
const buttons = options.map((option) => {
|
|
997
|
+
const v = JSON.stringify(option.value);
|
|
998
|
+
const on = current.has(option.value);
|
|
999
|
+
const out: Record<string, unknown> = {
|
|
1000
|
+
type: "button",
|
|
1001
|
+
// Server-rendered pressed state, so the first paint is correct before
|
|
1002
|
+
// Alpine has initialised.
|
|
1003
|
+
"aria-pressed": on ? "true" : "false",
|
|
1004
|
+
":aria-pressed": `isOn(${v})`,
|
|
1005
|
+
":data-pressed": `isOn(${v}) ? '' : null`,
|
|
1006
|
+
"x-on:click": `toggle(${v})`,
|
|
1007
|
+
class: cx("flow-toggle", optionClass),
|
|
1008
|
+
children: option.label ?? option.value,
|
|
1009
|
+
};
|
|
1010
|
+
if (on) out["data-pressed"] = "";
|
|
1011
|
+
if (option.disabled) out["disabled"] = true;
|
|
1012
|
+
return jsx("button", out);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
return jsx("div", {
|
|
1016
|
+
...rest,
|
|
1017
|
+
// Single-choice reads as a radiogroup; multiple is a plain group of buttons.
|
|
1018
|
+
role: multiple ? "group" : "radiogroup",
|
|
1019
|
+
"x-data": `flowToggleGroup(${JSON.stringify({ name, multiple: Boolean(multiple) })})`,
|
|
1020
|
+
class: cx("flow-toggle-group", cls),
|
|
1021
|
+
children: buttons,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ── <Calendar> ────────────────────────────────────────────────────────────────
|
|
1026
|
+
// A month grid for picking a date. Paging between months and selecting a day are
|
|
1027
|
+
// entirely client-side — the grid is arithmetic, so a round-trip would be asking
|
|
1028
|
+
// the server a question that needs no data. Only the chosen day is synced.
|
|
1029
|
+
//
|
|
1030
|
+
// Every cell is a real `<button>` inside a grid with `role="grid"`, so arrow-key
|
|
1031
|
+
// and screen-reader behaviour come from the semantics rather than from script.
|
|
1032
|
+
//
|
|
1033
|
+
// <Calendar bind={this.due} min={today} />
|
|
1034
|
+
|
|
1035
|
+
export interface CalendarProps {
|
|
1036
|
+
/** Bound @expose `YYYY-MM-DD` string. */
|
|
1037
|
+
bind?: unknown;
|
|
1038
|
+
name?: string;
|
|
1039
|
+
/** Month first shown, `YYYY-MM`. Defaults to the bound value's month, else now. */
|
|
1040
|
+
month?: string;
|
|
1041
|
+
/** Start weeks on Sunday instead of Monday. */
|
|
1042
|
+
sundayFirst?: boolean;
|
|
1043
|
+
min?: string;
|
|
1044
|
+
max?: string;
|
|
1045
|
+
class?: string;
|
|
1046
|
+
headerClass?: string;
|
|
1047
|
+
navClass?: string;
|
|
1048
|
+
weekdayClass?: string;
|
|
1049
|
+
gridClass?: string;
|
|
1050
|
+
dayClass?: string;
|
|
1051
|
+
[key: string]: unknown;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const FLOW_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
1055
|
+
|
|
1056
|
+
export function Calendar(props: CalendarProps): HtmlNode {
|
|
1057
|
+
const {
|
|
1058
|
+
bind,
|
|
1059
|
+
name: nameProp,
|
|
1060
|
+
month: monthProp,
|
|
1061
|
+
sundayFirst,
|
|
1062
|
+
min,
|
|
1063
|
+
max,
|
|
1064
|
+
class: cls,
|
|
1065
|
+
headerClass,
|
|
1066
|
+
navClass,
|
|
1067
|
+
weekdayClass,
|
|
1068
|
+
gridClass,
|
|
1069
|
+
dayClass,
|
|
1070
|
+
children: _ignore,
|
|
1071
|
+
...rest
|
|
1072
|
+
} = props as CalendarProps & { children?: unknown };
|
|
1073
|
+
|
|
1074
|
+
const name = nameProp ?? _injectedBindKey(props, "bind") ?? _resolveReactiveName(bind);
|
|
1075
|
+
const selected = typeof bind === "string" ? bind : "";
|
|
1076
|
+
const now = new Date();
|
|
1077
|
+
const month =
|
|
1078
|
+
monthProp ??
|
|
1079
|
+
(selected
|
|
1080
|
+
? selected.slice(0, 7)
|
|
1081
|
+
: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`);
|
|
1082
|
+
|
|
1083
|
+
const weekdays = sundayFirst ? ["Sun", ...FLOW_WEEKDAYS.slice(0, 6)] : FLOW_WEEKDAYS;
|
|
1084
|
+
const config = JSON.stringify({ name, month, sundayFirst: Boolean(sundayFirst), min, max });
|
|
1085
|
+
|
|
1086
|
+
return jsx("div", {
|
|
1087
|
+
...rest,
|
|
1088
|
+
"x-data": `flowCalendar(${config})`,
|
|
1089
|
+
class: cx("flow-calendar", cls),
|
|
1090
|
+
children: [
|
|
1091
|
+
jsx("div", {
|
|
1092
|
+
class: cx("flow-calendar-header", headerClass),
|
|
1093
|
+
children: [
|
|
1094
|
+
jsx("button", {
|
|
1095
|
+
type: "button",
|
|
1096
|
+
"aria-label": "Previous month",
|
|
1097
|
+
"x-on:click": "shift(-1)",
|
|
1098
|
+
class: cx("flow-calendar-nav", navClass),
|
|
1099
|
+
children: "‹",
|
|
1100
|
+
}),
|
|
1101
|
+
jsx("span", { "x-text": "label()", class: "flow-calendar-label", children: "" }),
|
|
1102
|
+
jsx("button", {
|
|
1103
|
+
type: "button",
|
|
1104
|
+
"aria-label": "Next month",
|
|
1105
|
+
"x-on:click": "shift(1)",
|
|
1106
|
+
class: cx("flow-calendar-nav", navClass),
|
|
1107
|
+
children: "›",
|
|
1108
|
+
}),
|
|
1109
|
+
],
|
|
1110
|
+
}),
|
|
1111
|
+
jsx("div", {
|
|
1112
|
+
role: "grid",
|
|
1113
|
+
class: cx("flow-calendar-grid", gridClass),
|
|
1114
|
+
children: [
|
|
1115
|
+
...weekdays.map((d) =>
|
|
1116
|
+
jsx("span", {
|
|
1117
|
+
role: "columnheader",
|
|
1118
|
+
class: cx("flow-calendar-weekday", weekdayClass),
|
|
1119
|
+
children: d,
|
|
1120
|
+
}),
|
|
1121
|
+
),
|
|
1122
|
+
// One template for all 42 cells: the markup ships once and Alpine
|
|
1123
|
+
// repeats it, rather than the server emitting six weeks of buttons
|
|
1124
|
+
// that are replaced the moment somebody pages the month.
|
|
1125
|
+
jsx("template", {
|
|
1126
|
+
"x-for": "cell in cells()",
|
|
1127
|
+
":key": "cell.day",
|
|
1128
|
+
children: jsx("button", {
|
|
1129
|
+
type: "button",
|
|
1130
|
+
role: "gridcell",
|
|
1131
|
+
":aria-label": "cell.day",
|
|
1132
|
+
":aria-selected": "value === cell.day",
|
|
1133
|
+
":data-selected": "value === cell.day ? '' : null",
|
|
1134
|
+
":data-today": "today === cell.day ? '' : null",
|
|
1135
|
+
":data-outside": "cell.inMonth ? null : ''",
|
|
1136
|
+
":disabled": "isDisabled(cell.day)",
|
|
1137
|
+
"x-on:click": "select(cell.day)",
|
|
1138
|
+
"x-text": "dayNumber(cell.day)",
|
|
1139
|
+
class: cx("flow-calendar-day", dayClass),
|
|
1140
|
+
children: "",
|
|
1141
|
+
}),
|
|
1142
|
+
}),
|
|
1143
|
+
],
|
|
1144
|
+
}),
|
|
1145
|
+
],
|
|
1146
|
+
});
|
|
1147
|
+
}
|