@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,948 @@
|
|
|
1
|
+
// ── Headless client runtime ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Alpine.data() factories for the keyboard/ARIA-rich headless primitives
|
|
4
|
+
// (RadioGroup, Listbox). Registered into Alpine by the client entry, alongside
|
|
5
|
+
// the $flow magic. Selection reads/writes component state via $flow, so values stay
|
|
6
|
+
// in sync with the server and submit like any field. Highlight (`active`) and
|
|
7
|
+
// open/close are local Alpine state — instant, no round-trip.
|
|
8
|
+
//
|
|
9
|
+
// The factories are exported standalone so the selection/keyboard logic is
|
|
10
|
+
// unit-testable with a mocked $flow / $el.
|
|
11
|
+
|
|
12
|
+
type FlowMagic = { $get(prop: string): unknown; $set(prop: string, value: unknown): void };
|
|
13
|
+
|
|
14
|
+
/** Parse an option element's data-value back to its original JSON value. */
|
|
15
|
+
function optValue(el: Element): unknown {
|
|
16
|
+
const raw = el.getAttribute("data-value");
|
|
17
|
+
if (raw === null) return null;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── RadioGroup ────────────────────────────────────────────────────────────────
|
|
26
|
+
// Templates read selection via `$flow.<name> === value` directly (stays in sync
|
|
27
|
+
// with the server); this factory supplies `select()` and arrow-key roving.
|
|
28
|
+
|
|
29
|
+
export function flowRadioGroup(config: { name: string }) {
|
|
30
|
+
return {
|
|
31
|
+
name: config.name,
|
|
32
|
+
_radios(this: any): HTMLElement[] {
|
|
33
|
+
return Array.from(this.$el.querySelectorAll('[role="radio"]'));
|
|
34
|
+
},
|
|
35
|
+
select(this: any, v: unknown) {
|
|
36
|
+
this.$flow.$set(this.name, v);
|
|
37
|
+
},
|
|
38
|
+
onKey(this: any, e: KeyboardEvent) {
|
|
39
|
+
const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
|
|
40
|
+
const back = e.key === "ArrowUp" || e.key === "ArrowLeft";
|
|
41
|
+
if (!fwd && !back) return;
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
const radios = this._radios();
|
|
44
|
+
if (!radios.length) return;
|
|
45
|
+
const i = radios.indexOf(e.target as HTMLElement);
|
|
46
|
+
const next = radios[(i + (fwd ? 1 : radios.length - 1) + radios.length) % radios.length];
|
|
47
|
+
if (next) {
|
|
48
|
+
next.focus();
|
|
49
|
+
next.click();
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Listbox ─────────────────────────────────────────────────────────────────
|
|
56
|
+
// Local `open` + `active` (highlight) state; selection round-trips through $flow.
|
|
57
|
+
// Templates bind data-active / aria-selected reactively off `active` and
|
|
58
|
+
// `isSelected()`, so this factory never touches the DOM for styling.
|
|
59
|
+
|
|
60
|
+
export function flowListbox(config: { name: string; multiple?: boolean }) {
|
|
61
|
+
return {
|
|
62
|
+
name: config.name,
|
|
63
|
+
multiple: !!config.multiple,
|
|
64
|
+
open: false,
|
|
65
|
+
active: -1,
|
|
66
|
+
|
|
67
|
+
_options(this: any): HTMLElement[] {
|
|
68
|
+
return Array.from(this.$el.querySelectorAll('[role="option"]'));
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
isSelected(this: any, v: unknown): boolean {
|
|
72
|
+
const cur = (this.$flow as FlowMagic).$get(this.name);
|
|
73
|
+
return this.multiple ? Array.isArray(cur) && cur.includes(v) : cur === v;
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
toggle(this: any) {
|
|
77
|
+
if (this.open) this.close();
|
|
78
|
+
else this.openList();
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
openList(this: any) {
|
|
82
|
+
this.open = true;
|
|
83
|
+
const opts = this._options();
|
|
84
|
+
const i = opts.findIndex((o: Element) => this.isSelected(optValue(o)));
|
|
85
|
+
this.active = i >= 0 ? i : 0;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
close(this: any) {
|
|
89
|
+
this.open = false;
|
|
90
|
+
this.active = -1;
|
|
91
|
+
(this.$el.querySelector("button") as HTMLElement | null)?.focus();
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
move(this: any, delta: number) {
|
|
95
|
+
const n = this._options().length;
|
|
96
|
+
if (!n) return;
|
|
97
|
+
this.active = (this.active + delta + n) % n;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
select(this: any, v: unknown) {
|
|
101
|
+
const flow = this.$flow as FlowMagic;
|
|
102
|
+
if (this.multiple) {
|
|
103
|
+
const cur = flow.$get(this.name);
|
|
104
|
+
const arr = Array.isArray(cur) ? [...cur] : [];
|
|
105
|
+
const idx = arr.indexOf(v);
|
|
106
|
+
if (idx === -1) arr.push(v);
|
|
107
|
+
else arr.splice(idx, 1);
|
|
108
|
+
flow.$set(this.name, arr);
|
|
109
|
+
} else {
|
|
110
|
+
flow.$set(this.name, v);
|
|
111
|
+
this.close();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
onButtonKey(this: any, e: KeyboardEvent) {
|
|
116
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
this.openList();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
onKey(this: any, e: KeyboardEvent) {
|
|
123
|
+
switch (e.key) {
|
|
124
|
+
case "ArrowDown":
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
this.move(1);
|
|
127
|
+
break;
|
|
128
|
+
case "ArrowUp":
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
this.move(-1);
|
|
131
|
+
break;
|
|
132
|
+
case "Home":
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
this.active = 0;
|
|
135
|
+
break;
|
|
136
|
+
case "End":
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
this.active = this._options().length - 1;
|
|
139
|
+
break;
|
|
140
|
+
case "Enter":
|
|
141
|
+
case " ": {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
const o = this._options()[this.active];
|
|
144
|
+
if (o) this.select(optValue(o));
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "Escape":
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
this.close();
|
|
150
|
+
break;
|
|
151
|
+
case "Tab":
|
|
152
|
+
this.close();
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Combobox ──────────────────────────────────────────────────────────────────
|
|
160
|
+
// Server mode: the input uses flow:model.live, so every keystroke syncs the query
|
|
161
|
+
// prop and the server re-renders the already-filtered options. `open` survives
|
|
162
|
+
// Alpine morph patches so the list stays visible while the user types.
|
|
163
|
+
// Client mode: options have x-show filters driven by the local `query` string.
|
|
164
|
+
|
|
165
|
+
export function flowCombobox(config: { name: string; queryName?: string | null; query?: string }) {
|
|
166
|
+
return {
|
|
167
|
+
name: config.name,
|
|
168
|
+
queryName: config.queryName ?? null,
|
|
169
|
+
open: false,
|
|
170
|
+
active: -1,
|
|
171
|
+
query: config.query ?? "",
|
|
172
|
+
|
|
173
|
+
_options(this: any): HTMLElement[] {
|
|
174
|
+
return Array.from(this.$el?.querySelectorAll('[role="option"]') ?? []);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
isSelected(this: any, v: unknown): boolean {
|
|
178
|
+
return (this.$flow as FlowMagic).$get(this.name) === v;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
openList(this: any) {
|
|
182
|
+
this.open = true;
|
|
183
|
+
const opts = this._options();
|
|
184
|
+
const cur = (this.$flow as FlowMagic).$get(this.name);
|
|
185
|
+
const i = opts.findIndex((o: Element) => {
|
|
186
|
+
const raw = o.getAttribute("data-value");
|
|
187
|
+
try {
|
|
188
|
+
return raw !== null && JSON.parse(raw) === cur;
|
|
189
|
+
} catch {
|
|
190
|
+
return raw === cur;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
this.active = i >= 0 ? i : opts.length ? 0 : -1;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
close(this: any) {
|
|
197
|
+
this.open = false;
|
|
198
|
+
this.active = -1;
|
|
199
|
+
this.$el?.querySelector("input")?.focus();
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
// select(value, label) — called directly (keyboard Enter) or from selectEl.
|
|
203
|
+
select(this: any, v: unknown, label: string) {
|
|
204
|
+
(this.$flow as FlowMagic).$set(this.name, v);
|
|
205
|
+
if (this.queryName) {
|
|
206
|
+
(this.$flow as FlowMagic).$set(this.queryName, label);
|
|
207
|
+
} else {
|
|
208
|
+
this.query = label;
|
|
209
|
+
}
|
|
210
|
+
this.close();
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
selectEl(this: any, el: HTMLElement) {
|
|
214
|
+
const raw = el.getAttribute("data-value");
|
|
215
|
+
let v: unknown;
|
|
216
|
+
try {
|
|
217
|
+
v = JSON.parse(raw ?? "");
|
|
218
|
+
} catch {
|
|
219
|
+
v = raw;
|
|
220
|
+
}
|
|
221
|
+
this.select(v, el.getAttribute("data-label") ?? "");
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
onInput(this: any, e: InputEvent) {
|
|
225
|
+
if (!this.open) this.open = true;
|
|
226
|
+
if (!this.queryName) {
|
|
227
|
+
this.query = (e.target as HTMLInputElement).value;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
move(this: any, delta: number) {
|
|
232
|
+
const all = this._options() as Array<{ style?: { display: string } }>;
|
|
233
|
+
const visible = all.filter((o) => (o as any).style?.display !== "none");
|
|
234
|
+
if (!visible.length) return;
|
|
235
|
+
const cur = all[this.active];
|
|
236
|
+
const visIdx = visible.indexOf(cur as any);
|
|
237
|
+
const start = visIdx >= 0 ? visIdx : 0;
|
|
238
|
+
const next = visible[(start + delta + visible.length) % visible.length];
|
|
239
|
+
this.active = all.indexOf(next as any);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
onKey(this: any, e: KeyboardEvent) {
|
|
243
|
+
switch (e.key) {
|
|
244
|
+
case "ArrowDown":
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
if (!this.open) this.openList();
|
|
247
|
+
else this.move(1);
|
|
248
|
+
break;
|
|
249
|
+
case "ArrowUp":
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
if (!this.open) this.openList();
|
|
252
|
+
else this.move(-1);
|
|
253
|
+
break;
|
|
254
|
+
case "Enter": {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
const o = this._options()[this.active];
|
|
257
|
+
if (o) this.selectEl(o);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "Escape":
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
this.close();
|
|
263
|
+
break;
|
|
264
|
+
case "Tab":
|
|
265
|
+
this.close();
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
273
|
+
// Roving tabindex + arrow-key navigation for the <Tabs> component. Tracks the
|
|
274
|
+
// active tab name (`tab`); the template uses `x-on:click="tab = '<name>'"` to
|
|
275
|
+
// select; this factory only handles keyboard navigation on the tablist.
|
|
276
|
+
|
|
277
|
+
export function flowTabs(config: { tab: string }) {
|
|
278
|
+
return {
|
|
279
|
+
tab: config.tab,
|
|
280
|
+
_tabs(this: any): HTMLElement[] {
|
|
281
|
+
return Array.from(this.$el.querySelectorAll('[role="tab"]'));
|
|
282
|
+
},
|
|
283
|
+
onKey(this: any, e: KeyboardEvent) {
|
|
284
|
+
const fwd = e.key === "ArrowRight" || e.key === "ArrowDown";
|
|
285
|
+
const back = e.key === "ArrowLeft" || e.key === "ArrowUp";
|
|
286
|
+
if (!fwd && !back) return;
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
const tabs = this._tabs();
|
|
289
|
+
if (!tabs.length) return;
|
|
290
|
+
const i = tabs.indexOf(e.target as HTMLElement);
|
|
291
|
+
const next = tabs[(i + (fwd ? 1 : tabs.length - 1) + tabs.length) % tabs.length];
|
|
292
|
+
if (next) {
|
|
293
|
+
next.focus();
|
|
294
|
+
next.click();
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Menu (Dropdown) ────────────────────────────────────────────────────────────
|
|
301
|
+
// ARIA menu keyboard navigation for <Dropdown>. Arrow keys / Home / End move
|
|
302
|
+
// focus through items; Escape closes and returns focus to the trigger. A click
|
|
303
|
+
// outside or Escape-from-window closes without stealing focus.
|
|
304
|
+
|
|
305
|
+
export function flowMenu() {
|
|
306
|
+
// Non-reactive closure vars so neither openMenu's $nextTick nor the $watch
|
|
307
|
+
// callback reads/writes reactive state — preventing spurious re-focus after
|
|
308
|
+
// ArrowDown moves focus to the next item.
|
|
309
|
+
let _pendingLast = false;
|
|
310
|
+
let _activeItem: HTMLElement | null = null;
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
open: false,
|
|
314
|
+
|
|
315
|
+
init(this: any) {
|
|
316
|
+
// $watch fires after Alpine has processed x-show, so the panel is visible
|
|
317
|
+
// and focus() actually lands on the element.
|
|
318
|
+
this.$watch("open", (value: boolean) => {
|
|
319
|
+
if (!value) {
|
|
320
|
+
_activeItem = null;
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const last = _pendingLast;
|
|
324
|
+
this.$nextTick(() => {
|
|
325
|
+
const items = this._items();
|
|
326
|
+
if (!items.length) return;
|
|
327
|
+
const target = (last ? items[items.length - 1] : items[0]) as HTMLElement;
|
|
328
|
+
target?.focus();
|
|
329
|
+
_activeItem = target;
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
_items(this: any): HTMLElement[] {
|
|
335
|
+
// In Alpine v3, $el inside x-on:keydown on the panel is the panel itself,
|
|
336
|
+
// not the x-data root. Use closest() to always find the component root.
|
|
337
|
+
const el = this.$el as HTMLElement;
|
|
338
|
+
const root =
|
|
339
|
+
typeof el.closest === "function" ? ((el.closest("[x-data]") as HTMLElement) ?? el) : el;
|
|
340
|
+
const menu = root.querySelector('[role="menu"]');
|
|
341
|
+
if (!menu) return [];
|
|
342
|
+
return Array.from(
|
|
343
|
+
menu.querySelectorAll(
|
|
344
|
+
'[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],button:not([disabled]),a[href]',
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
_trigger(this: any): HTMLElement | null {
|
|
350
|
+
const el = this.$el as HTMLElement;
|
|
351
|
+
const root =
|
|
352
|
+
typeof el.closest === "function" ? ((el.closest("[x-data]") as HTMLElement) ?? el) : el;
|
|
353
|
+
return root.querySelector("[aria-haspopup]");
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
openMenu(this: any, last = false) {
|
|
357
|
+
_pendingLast = last;
|
|
358
|
+
this.open = true;
|
|
359
|
+
// Unit tests provide a synchronous $nextTick mock and never call init(),
|
|
360
|
+
// so the $watch branch doesn't run — this path handles them.
|
|
361
|
+
this.$nextTick(() => {
|
|
362
|
+
const items = this._items();
|
|
363
|
+
if (!items.length) return;
|
|
364
|
+
const target = (last ? items[items.length - 1] : items[0]) as HTMLElement;
|
|
365
|
+
target?.focus();
|
|
366
|
+
_activeItem = target;
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
close(this: any, refocus = true) {
|
|
371
|
+
this.open = false;
|
|
372
|
+
_activeItem = null;
|
|
373
|
+
if (refocus) this._trigger()?.focus();
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
toggle(this: any) {
|
|
377
|
+
if (this.open) this.close();
|
|
378
|
+
else this.openMenu(false);
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
onButtonKey(this: any, e: KeyboardEvent) {
|
|
382
|
+
if (e.key === "ArrowDown") {
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
this.openMenu(false);
|
|
385
|
+
} else if (e.key === "ArrowUp") {
|
|
386
|
+
e.preventDefault();
|
|
387
|
+
this.openMenu(true);
|
|
388
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
this.openMenu(false);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
onKey(this: any, e: KeyboardEvent) {
|
|
395
|
+
const items = this._items();
|
|
396
|
+
// Prefer _activeItem (set when focus last moved within the menu) over
|
|
397
|
+
// document.activeElement, which can drift if focus is temporarily stolen.
|
|
398
|
+
const cur =
|
|
399
|
+
(_activeItem as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
|
400
|
+
const i = cur ? items.indexOf(cur) : -1;
|
|
401
|
+
const n = items.length;
|
|
402
|
+
switch (e.key) {
|
|
403
|
+
case "ArrowDown":
|
|
404
|
+
e.preventDefault();
|
|
405
|
+
_activeItem = items[(i + 1 + n) % n] ?? null;
|
|
406
|
+
_activeItem?.focus();
|
|
407
|
+
break;
|
|
408
|
+
case "ArrowUp":
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
_activeItem = items[(i - 1 + n) % n] ?? null;
|
|
411
|
+
_activeItem?.focus();
|
|
412
|
+
break;
|
|
413
|
+
case "Home":
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
_activeItem = items[0] ?? null;
|
|
416
|
+
_activeItem?.focus();
|
|
417
|
+
break;
|
|
418
|
+
case "End":
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
_activeItem = items[n - 1] ?? null;
|
|
421
|
+
_activeItem?.focus();
|
|
422
|
+
break;
|
|
423
|
+
case "Escape":
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
this.close(true);
|
|
426
|
+
break;
|
|
427
|
+
case "Tab":
|
|
428
|
+
this.close(false);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Field ─────────────────────────────────────────────────────────────────────
|
|
436
|
+
// Wires ARIA for <Field>: assigns IDs, sets label[for], aria-describedby, and
|
|
437
|
+
// aria-invalid. A MutationObserver keeps aria-invalid in sync as the error text
|
|
438
|
+
// changes during validation (Alpine morph patches the element in place).
|
|
439
|
+
|
|
440
|
+
let _fieldCounter = 0;
|
|
441
|
+
|
|
442
|
+
export function flowField() {
|
|
443
|
+
return {
|
|
444
|
+
_observer: null as MutationObserver | null,
|
|
445
|
+
|
|
446
|
+
init(this: any) {
|
|
447
|
+
const root = this.$el;
|
|
448
|
+
const n = ++_fieldCounter;
|
|
449
|
+
|
|
450
|
+
const control = root.querySelector("input, select, textarea, button");
|
|
451
|
+
if (!control) return;
|
|
452
|
+
if (!control.id) control.id = `flow-field-${n}`;
|
|
453
|
+
|
|
454
|
+
const label = root.querySelector("label");
|
|
455
|
+
if (label) label.setAttribute("for", control.id);
|
|
456
|
+
|
|
457
|
+
const described: string[] = [];
|
|
458
|
+
|
|
459
|
+
const desc = root.querySelector("[data-flow-description]");
|
|
460
|
+
if (desc) {
|
|
461
|
+
if (!desc.id) desc.id = `flow-field-desc-${n}`;
|
|
462
|
+
described.push(desc.id);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const err = root.querySelector(".flow-field-error");
|
|
466
|
+
if (err) {
|
|
467
|
+
if (!err.id) err.id = `flow-field-err-${n}`;
|
|
468
|
+
described.push(err.id);
|
|
469
|
+
const updateInvalid = () => {
|
|
470
|
+
control.setAttribute("aria-invalid", err.textContent?.trim() ? "true" : "false");
|
|
471
|
+
};
|
|
472
|
+
updateInvalid();
|
|
473
|
+
if (typeof MutationObserver !== "undefined") {
|
|
474
|
+
this._observer = new MutationObserver(updateInvalid);
|
|
475
|
+
this._observer.observe(err, { childList: true, characterData: true, subtree: true });
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
control.setAttribute("aria-invalid", "false");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (described.length) control.setAttribute("aria-describedby", described.join(" "));
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
destroy(this: any) {
|
|
485
|
+
this._observer?.disconnect();
|
|
486
|
+
this._observer = null;
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── FileUpload ────────────────────────────────────────────────────────────────
|
|
492
|
+
// Tracks upload lifecycle events dispatched by the Flow upload handler.
|
|
493
|
+
// Filters by `name` so multiple file fields on the same page stay independent.
|
|
494
|
+
|
|
495
|
+
export function flowFileUpload(config: { name: string }) {
|
|
496
|
+
return {
|
|
497
|
+
name: config.name,
|
|
498
|
+
uploading: false,
|
|
499
|
+
progress: 0,
|
|
500
|
+
error: "",
|
|
501
|
+
_handlers: [] as Array<[string, EventListener]>,
|
|
502
|
+
|
|
503
|
+
init(this: any) {
|
|
504
|
+
const on = (event: string, handler: EventListener) => {
|
|
505
|
+
window.addEventListener(event, handler);
|
|
506
|
+
this._handlers.push([event, handler]);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
on("flow:upload-start", ((e: CustomEvent) => {
|
|
510
|
+
if (e.detail.key !== this.name) return;
|
|
511
|
+
this.uploading = true;
|
|
512
|
+
this.error = "";
|
|
513
|
+
}) as EventListener);
|
|
514
|
+
|
|
515
|
+
on("flow:upload-progress", ((e: CustomEvent) => {
|
|
516
|
+
if (e.detail.key !== this.name) return;
|
|
517
|
+
this.progress = e.detail.percent;
|
|
518
|
+
}) as EventListener);
|
|
519
|
+
|
|
520
|
+
on("flow:upload-error", ((e: CustomEvent) => {
|
|
521
|
+
if (e.detail.key !== this.name) return;
|
|
522
|
+
this.uploading = false;
|
|
523
|
+
this.error = e.detail.error ?? "Upload failed";
|
|
524
|
+
}) as EventListener);
|
|
525
|
+
|
|
526
|
+
on("flow:upload-done", ((e: CustomEvent) => {
|
|
527
|
+
if (e.detail.key !== this.name) return;
|
|
528
|
+
this.uploading = false;
|
|
529
|
+
this.progress = 100;
|
|
530
|
+
}) as EventListener);
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
destroy(this: any) {
|
|
534
|
+
for (const [event, handler] of this._handlers) {
|
|
535
|
+
window.removeEventListener(event, handler);
|
|
536
|
+
}
|
|
537
|
+
this._handlers = [];
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* What Alpine injects into a `data()` factory's methods.
|
|
544
|
+
*
|
|
545
|
+
* Typed once and applied with `this:` so the newer factories keep their `$flow`
|
|
546
|
+
* and `$refs` access checked, rather than each method opting out with `any`.
|
|
547
|
+
*/
|
|
548
|
+
interface AlpineCtx {
|
|
549
|
+
$flow?: Partial<FlowMagic>;
|
|
550
|
+
$el: HTMLElement;
|
|
551
|
+
$refs: Record<string, HTMLElement | undefined>;
|
|
552
|
+
$nextTick(fn: () => void): void;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The reactive state each factory owns, so `this` stays checked inside them. */
|
|
556
|
+
interface SliderState {
|
|
557
|
+
value: number;
|
|
558
|
+
dragging: boolean;
|
|
559
|
+
percent(): number;
|
|
560
|
+
onInput(event: Event): void;
|
|
561
|
+
commit(): void;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
interface ToggleGroupState {
|
|
565
|
+
selected: string[];
|
|
566
|
+
isOn(value: string): boolean;
|
|
567
|
+
toggle(value: string): void;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interface CalendarState {
|
|
571
|
+
month: string;
|
|
572
|
+
value: string;
|
|
573
|
+
today: string;
|
|
574
|
+
shift(by: number): void;
|
|
575
|
+
cells(): { day: string; inMonth: boolean }[];
|
|
576
|
+
isDisabled(day: string): boolean;
|
|
577
|
+
select(day: string): void;
|
|
578
|
+
label(): string;
|
|
579
|
+
dayNumber(day: string): string;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
interface ChartState {
|
|
583
|
+
hover: number;
|
|
584
|
+
tipX: number;
|
|
585
|
+
ready: boolean;
|
|
586
|
+
onMove(event: PointerEvent): void;
|
|
587
|
+
onLeave(): void;
|
|
588
|
+
tip(): FlowChartPoint | null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface CommandState {
|
|
592
|
+
open: boolean;
|
|
593
|
+
query: string;
|
|
594
|
+
active: number;
|
|
595
|
+
_hotkey?: (event: KeyboardEvent) => void;
|
|
596
|
+
results(): FlowCommandItem[];
|
|
597
|
+
startsGroup(index: number): boolean;
|
|
598
|
+
show(): void;
|
|
599
|
+
hide(): void;
|
|
600
|
+
toggle(): void;
|
|
601
|
+
move(by: number): void;
|
|
602
|
+
choose(index?: number): void;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── Slider ────────────────────────────────────────────────────────────────────
|
|
606
|
+
// The value is local Alpine state so the thumb and the readout track the pointer
|
|
607
|
+
// at frame rate; `$flow` is told once the drag ends. Syncing on every pointermove
|
|
608
|
+
// would put a network round-trip between the finger and the pixel, which is
|
|
609
|
+
// exactly the lag a slider makes visible.
|
|
610
|
+
|
|
611
|
+
export function flowSlider(config: { name?: string; min: number; max: number; step: number }) {
|
|
612
|
+
return {
|
|
613
|
+
value: 0,
|
|
614
|
+
dragging: false,
|
|
615
|
+
init(this: AlpineCtx & SliderState) {
|
|
616
|
+
// Seeded from the server's value, so the first paint is already right.
|
|
617
|
+
const initial = config.name ? this.$flow?.$get?.(config.name) : undefined;
|
|
618
|
+
this.value = Number(initial ?? this.$el.querySelector("input")?.value ?? config.min);
|
|
619
|
+
},
|
|
620
|
+
/** Percentage along the track, for the fill and the readout. */
|
|
621
|
+
percent(this: AlpineCtx & SliderState): number {
|
|
622
|
+
const span = config.max - config.min || 1;
|
|
623
|
+
return Math.min(100, Math.max(0, ((this.value - config.min) / span) * 100));
|
|
624
|
+
},
|
|
625
|
+
/** Every input event — local only, so dragging stays smooth. */
|
|
626
|
+
onInput(this: AlpineCtx & SliderState, event: Event) {
|
|
627
|
+
this.dragging = true;
|
|
628
|
+
this.value = Number((event.target as HTMLInputElement).value);
|
|
629
|
+
},
|
|
630
|
+
/** The drag or keypress settling — the one moment the server hears about. */
|
|
631
|
+
commit(this: AlpineCtx & SliderState) {
|
|
632
|
+
this.dragging = false;
|
|
633
|
+
if (config.name) this.$flow?.$set?.(config.name, this.value);
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── Toggle group ──────────────────────────────────────────────────────────────
|
|
639
|
+
// Pressing is local state first and a `$flow` write second, so the button reacts
|
|
640
|
+
// on click rather than after a round-trip. A toggle that waits for the server
|
|
641
|
+
// before it looks pressed feels broken even when the server is fast.
|
|
642
|
+
|
|
643
|
+
export function flowToggleGroup(config: { name?: string; multiple?: boolean }) {
|
|
644
|
+
return {
|
|
645
|
+
selected: [] as string[],
|
|
646
|
+
init(this: AlpineCtx & ToggleGroupState) {
|
|
647
|
+
const initial = config.name ? this.$flow?.$get?.(config.name) : undefined;
|
|
648
|
+
this.selected = Array.isArray(initial) ? [...initial] : initial ? [String(initial)] : [];
|
|
649
|
+
},
|
|
650
|
+
isOn(this: AlpineCtx & ToggleGroupState, value: string): boolean {
|
|
651
|
+
return this.selected.includes(value);
|
|
652
|
+
},
|
|
653
|
+
toggle(this: AlpineCtx & ToggleGroupState, value: string) {
|
|
654
|
+
if (config.multiple) {
|
|
655
|
+
this.selected = this.isOn(value)
|
|
656
|
+
? this.selected.filter((v: string) => v !== value)
|
|
657
|
+
: [...this.selected, value];
|
|
658
|
+
} else {
|
|
659
|
+
// A single-choice group deselects on a second press, which is what makes
|
|
660
|
+
// it a filter rather than a radio set you can never clear.
|
|
661
|
+
this.selected = this.isOn(value) ? [] : [value];
|
|
662
|
+
}
|
|
663
|
+
if (config.name) {
|
|
664
|
+
this.$flow?.$set?.(
|
|
665
|
+
config.name,
|
|
666
|
+
config.multiple ? this.selected : (this.selected[0] ?? null),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Calendar ──────────────────────────────────────────────────────────────────
|
|
674
|
+
// Month paging and day selection are entirely local: the grid for any month is
|
|
675
|
+
// arithmetic the browser can already do, so asking the server for it means a
|
|
676
|
+
// round-trip to answer a question that needs no data. `$flow` hears only the
|
|
677
|
+
// chosen day.
|
|
678
|
+
|
|
679
|
+
/** `YYYY-MM-DD` for a local date — never `toISOString`, which shifts to UTC. */
|
|
680
|
+
function localDay(date: Date): string {
|
|
681
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
682
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
683
|
+
return `${date.getFullYear()}-${m}-${d}`;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const MONTH_NAMES = [
|
|
687
|
+
"January",
|
|
688
|
+
"February",
|
|
689
|
+
"March",
|
|
690
|
+
"April",
|
|
691
|
+
"May",
|
|
692
|
+
"June",
|
|
693
|
+
"July",
|
|
694
|
+
"August",
|
|
695
|
+
"September",
|
|
696
|
+
"October",
|
|
697
|
+
"November",
|
|
698
|
+
"December",
|
|
699
|
+
];
|
|
700
|
+
|
|
701
|
+
export function flowCalendar(config: {
|
|
702
|
+
name?: string;
|
|
703
|
+
month: string;
|
|
704
|
+
sundayFirst?: boolean;
|
|
705
|
+
min?: string;
|
|
706
|
+
max?: string;
|
|
707
|
+
}) {
|
|
708
|
+
return {
|
|
709
|
+
month: config.month,
|
|
710
|
+
value: "",
|
|
711
|
+
today: localDay(new Date()),
|
|
712
|
+
init(this: AlpineCtx & CalendarState) {
|
|
713
|
+
const initial = config.name ? this.$flow?.$get?.(config.name) : undefined;
|
|
714
|
+
if (typeof initial === "string" && initial) {
|
|
715
|
+
this.value = initial;
|
|
716
|
+
this.month = initial.slice(0, 7);
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
/** Step the month, wrapping the year. */
|
|
720
|
+
shift(this: AlpineCtx & CalendarState, by: number) {
|
|
721
|
+
const [y = 1970, m = 1] = this.month.split("-").map(Number);
|
|
722
|
+
const date = new Date(y, m - 1 + by, 1);
|
|
723
|
+
this.month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
724
|
+
},
|
|
725
|
+
/** Six weeks of cells, so the grid never changes height between months. */
|
|
726
|
+
cells(this: AlpineCtx & CalendarState): { day: string; inMonth: boolean }[] {
|
|
727
|
+
const [y = 1970, m = 1] = this.month.split("-").map(Number);
|
|
728
|
+
const first = new Date(y, m - 1, 1);
|
|
729
|
+
const weekday = config.sundayFirst ? first.getDay() : (first.getDay() + 6) % 7;
|
|
730
|
+
const start = new Date(first);
|
|
731
|
+
start.setDate(first.getDate() - weekday);
|
|
732
|
+
|
|
733
|
+
const out: { day: string; inMonth: boolean }[] = [];
|
|
734
|
+
for (let i = 0; i < 42; i++) {
|
|
735
|
+
const date = new Date(start);
|
|
736
|
+
date.setDate(start.getDate() + i);
|
|
737
|
+
out.push({ day: localDay(date), inMonth: date.getMonth() === m - 1 });
|
|
738
|
+
}
|
|
739
|
+
return out;
|
|
740
|
+
},
|
|
741
|
+
isDisabled(this: AlpineCtx & CalendarState, day: string): boolean {
|
|
742
|
+
return Boolean((config.min && day < config.min) || (config.max && day > config.max));
|
|
743
|
+
},
|
|
744
|
+
select(this: AlpineCtx & CalendarState, day: string) {
|
|
745
|
+
if (this.isDisabled(day)) return;
|
|
746
|
+
this.value = day;
|
|
747
|
+
if (config.name) this.$flow?.$set?.(config.name, day);
|
|
748
|
+
},
|
|
749
|
+
label(this: AlpineCtx & CalendarState): string {
|
|
750
|
+
const [y = 1970, m = 1] = this.month.split("-").map(Number);
|
|
751
|
+
return `${MONTH_NAMES[m - 1]} ${y}`;
|
|
752
|
+
},
|
|
753
|
+
dayNumber(this: AlpineCtx & CalendarState, day: string): string {
|
|
754
|
+
return String(Number(day.slice(-2)));
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Chart ─────────────────────────────────────────────────────────────────────
|
|
760
|
+
// The SVG geometry is rendered on the server, so a chart is in the HTML on first
|
|
761
|
+
// paint with no library to download. This factory adds only what genuinely needs
|
|
762
|
+
// a pointer: a tooltip that follows the cursor, and a draw-in on mount.
|
|
763
|
+
//
|
|
764
|
+
// Keeping the geometry server-side and the interaction client-side is the
|
|
765
|
+
// deliberate split — the browser never recomputes what the markup already says.
|
|
766
|
+
|
|
767
|
+
export interface FlowChartPoint {
|
|
768
|
+
x: number;
|
|
769
|
+
label: string;
|
|
770
|
+
values: { label: string; value: string; color: string }[];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function flowChart(config: { points: FlowChartPoint[] }) {
|
|
774
|
+
return {
|
|
775
|
+
hover: -1,
|
|
776
|
+
tipX: 0,
|
|
777
|
+
ready: false,
|
|
778
|
+
init(this: AlpineCtx & ChartState) {
|
|
779
|
+
// Next frame, so the transition has a state to animate away from.
|
|
780
|
+
requestAnimationFrame(() => {
|
|
781
|
+
this.ready = true;
|
|
782
|
+
});
|
|
783
|
+
},
|
|
784
|
+
/** Nearest data point to the pointer, in the element's own coordinate space. */
|
|
785
|
+
onMove(this: AlpineCtx & ChartState, event: PointerEvent) {
|
|
786
|
+
const box = this.$el.getBoundingClientRect();
|
|
787
|
+
if (!box.width || !config.points.length) return;
|
|
788
|
+
const ratio = (event.clientX - box.left) / box.width;
|
|
789
|
+
|
|
790
|
+
let best = 0;
|
|
791
|
+
let bestGap = Infinity;
|
|
792
|
+
for (let i = 0; i < config.points.length; i++) {
|
|
793
|
+
const gap = Math.abs(config.points[i]!.x - ratio);
|
|
794
|
+
if (gap < bestGap) {
|
|
795
|
+
bestGap = gap;
|
|
796
|
+
best = i;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
this.hover = best;
|
|
800
|
+
this.tipX = config.points[best]!.x * box.width;
|
|
801
|
+
},
|
|
802
|
+
onLeave(this: AlpineCtx & ChartState) {
|
|
803
|
+
this.hover = -1;
|
|
804
|
+
},
|
|
805
|
+
tip(this: AlpineCtx & ChartState) {
|
|
806
|
+
return this.hover < 0 ? null : config.points[this.hover];
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── Command menu ──────────────────────────────────────────────────────────────
|
|
812
|
+
// Filtering, ranking and keyboard navigation all run locally. A palette that
|
|
813
|
+
// waits for the network between keystrokes is worse than no palette — the whole
|
|
814
|
+
// promise is that it keeps up with typing — so the items ship once and every
|
|
815
|
+
// keystroke is answered from memory.
|
|
816
|
+
//
|
|
817
|
+
// That puts a practical ceiling on how many items belong here: a few hundred
|
|
818
|
+
// destinations is fine, a table of ten thousand records wants a search page with
|
|
819
|
+
// a server query behind it.
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Subsequence match with a score.
|
|
823
|
+
*
|
|
824
|
+
* Earlier matches and adjacent characters rank higher, so "prod" prefers
|
|
825
|
+
* "Products" over "Pending Orders" — the behaviour an editor's fuzzy-open has
|
|
826
|
+
* trained everyone to expect. A negative score means no match at all.
|
|
827
|
+
*/
|
|
828
|
+
export function flowCommandScore(haystack: string, needle: string): number {
|
|
829
|
+
if (!needle) return 0;
|
|
830
|
+
const h = haystack.toLowerCase();
|
|
831
|
+
const n = needle.toLowerCase();
|
|
832
|
+
let hi = 0;
|
|
833
|
+
let ni = 0;
|
|
834
|
+
let score = 0;
|
|
835
|
+
let streak = 0;
|
|
836
|
+
while (hi < h.length && ni < n.length) {
|
|
837
|
+
if (h[hi] === n[ni]) {
|
|
838
|
+
streak++;
|
|
839
|
+
score += 10 + streak * 5 - Math.min(hi, 20);
|
|
840
|
+
ni++;
|
|
841
|
+
} else {
|
|
842
|
+
streak = 0;
|
|
843
|
+
}
|
|
844
|
+
hi++;
|
|
845
|
+
}
|
|
846
|
+
return ni === n.length ? score : -1;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
export interface FlowCommandItem {
|
|
850
|
+
label: string;
|
|
851
|
+
href?: string;
|
|
852
|
+
action?: string;
|
|
853
|
+
group?: string;
|
|
854
|
+
keywords?: string;
|
|
855
|
+
shortcut?: string;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export function flowCommand(config: { items: FlowCommandItem[]; hotkey?: string | null }) {
|
|
859
|
+
return {
|
|
860
|
+
open: false,
|
|
861
|
+
query: "",
|
|
862
|
+
active: 0,
|
|
863
|
+
init(this: AlpineCtx & CommandState) {
|
|
864
|
+
if (!config.hotkey) return;
|
|
865
|
+
this._hotkey = (event: KeyboardEvent) => {
|
|
866
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === config.hotkey) {
|
|
867
|
+
event.preventDefault();
|
|
868
|
+
this.toggle();
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
window.addEventListener("keydown", this._hotkey);
|
|
872
|
+
},
|
|
873
|
+
destroy(this: AlpineCtx & CommandState) {
|
|
874
|
+
if (this._hotkey) window.removeEventListener("keydown", this._hotkey);
|
|
875
|
+
},
|
|
876
|
+
/** Matching items, best first. Recomputed per keystroke — cheap at this size. */
|
|
877
|
+
results(this: AlpineCtx & CommandState): FlowCommandItem[] {
|
|
878
|
+
const q = this.query.trim();
|
|
879
|
+
return config.items
|
|
880
|
+
.map((item, i) => {
|
|
881
|
+
const hay = `${item.label} ${item.keywords ?? ""} ${item.group ?? ""}`;
|
|
882
|
+
return { item, score: q ? flowCommandScore(hay, q) : 1000 - i };
|
|
883
|
+
})
|
|
884
|
+
.filter((r) => r.score >= 0)
|
|
885
|
+
.sort((a, b) => b.score - a.score)
|
|
886
|
+
.map((r) => r.item);
|
|
887
|
+
},
|
|
888
|
+
/** Whether this row starts a new group, so the heading renders once. */
|
|
889
|
+
startsGroup(this: AlpineCtx & CommandState, index: number): boolean {
|
|
890
|
+
const rows = this.results();
|
|
891
|
+
const group = rows[index]?.group;
|
|
892
|
+
return Boolean(group) && (index === 0 || rows[index - 1]?.group !== group);
|
|
893
|
+
},
|
|
894
|
+
show(this: AlpineCtx & CommandState) {
|
|
895
|
+
this.open = true;
|
|
896
|
+
this.query = "";
|
|
897
|
+
this.active = 0;
|
|
898
|
+
this.$nextTick(() => this.$refs.field?.focus());
|
|
899
|
+
},
|
|
900
|
+
hide(this: AlpineCtx & CommandState) {
|
|
901
|
+
this.open = false;
|
|
902
|
+
},
|
|
903
|
+
toggle(this: AlpineCtx & CommandState) {
|
|
904
|
+
this.open ? this.hide() : this.show();
|
|
905
|
+
},
|
|
906
|
+
move(this: AlpineCtx & CommandState, by: number) {
|
|
907
|
+
const total = this.results().length;
|
|
908
|
+
if (!total) return;
|
|
909
|
+
this.active = Math.min(total - 1, Math.max(0, this.active + by));
|
|
910
|
+
},
|
|
911
|
+
choose(this: AlpineCtx & CommandState, index?: number) {
|
|
912
|
+
const item = this.results()[index ?? this.active];
|
|
913
|
+
if (!item) return;
|
|
914
|
+
this.hide();
|
|
915
|
+
if (item.action) {
|
|
916
|
+
window.dispatchEvent(
|
|
917
|
+
new CustomEvent("flow:invoke", { detail: { expression: item.action } }),
|
|
918
|
+
);
|
|
919
|
+
} else if (item.href) {
|
|
920
|
+
// Uses the client router when one is present, so the palette navigates
|
|
921
|
+
// the way a link does rather than reloading the page.
|
|
922
|
+
const nav = (window as any).Flow?.navigate;
|
|
923
|
+
if (nav) nav(item.href);
|
|
924
|
+
else window.location.href = item.href;
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ── registerHeadless ──────────────────────────────────────────────────────────
|
|
931
|
+
// Called by the client bundle entry to wire all factories into Alpine.data().
|
|
932
|
+
|
|
933
|
+
type AlpineInstance = { data(name: string, factory: (config: any) => object): void };
|
|
934
|
+
|
|
935
|
+
export function registerHeadless(Alpine: AlpineInstance): void {
|
|
936
|
+
Alpine.data("flowRadioGroup", flowRadioGroup as any);
|
|
937
|
+
Alpine.data("flowListbox", flowListbox as any);
|
|
938
|
+
Alpine.data("flowCombobox", flowCombobox as any);
|
|
939
|
+
Alpine.data("flowTabs", flowTabs as any);
|
|
940
|
+
Alpine.data("flowMenu", flowMenu as any);
|
|
941
|
+
Alpine.data("flowField", flowField as any);
|
|
942
|
+
Alpine.data("flowFileUpload", flowFileUpload as any);
|
|
943
|
+
Alpine.data("flowSlider", flowSlider as any);
|
|
944
|
+
Alpine.data("flowToggleGroup", flowToggleGroup as any);
|
|
945
|
+
Alpine.data("flowCalendar", flowCalendar as any);
|
|
946
|
+
Alpine.data("flowChart", flowChart as any);
|
|
947
|
+
Alpine.data("flowCommand", flowCommand as any);
|
|
948
|
+
}
|