@xelqun/forms 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -2
  3. package/package.json +56 -5
  4. package/resources/css/xelqun.css +162 -0
  5. package/resources/js/components/XqFieldWrapper.vue +59 -0
  6. package/resources/js/components/XqForm.vue +128 -0
  7. package/resources/js/components/XqNode.vue +22 -0
  8. package/resources/js/components/XqUnknownNode.vue +15 -0
  9. package/resources/js/components/defaults.ts +44 -0
  10. package/resources/js/components/fields/XqCheckboxField.vue +48 -0
  11. package/resources/js/components/fields/XqDatePickerField.vue +96 -0
  12. package/resources/js/components/fields/XqFileUploadField.vue +247 -0
  13. package/resources/js/components/fields/XqRadioField.vue +45 -0
  14. package/resources/js/components/fields/XqRepeaterField.vue +166 -0
  15. package/resources/js/components/fields/XqSelectField.vue +151 -0
  16. package/resources/js/components/fields/XqTextInputField.vue +100 -0
  17. package/resources/js/components/fields/XqTextareaField.vue +28 -0
  18. package/resources/js/components/fields/XqToggleField.vue +47 -0
  19. package/resources/js/components/layouts/XqFieldset.vue +16 -0
  20. package/resources/js/components/layouts/XqGrid.vue +13 -0
  21. package/resources/js/components/layouts/XqSection.vue +62 -0
  22. package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
  23. package/resources/js/composables/context.ts +22 -0
  24. package/resources/js/composables/layout.ts +55 -0
  25. package/resources/js/composables/registry.ts +25 -0
  26. package/resources/js/composables/useField.ts +41 -0
  27. package/resources/js/composables/useXqForm.ts +16 -0
  28. package/resources/js/core/conditions.ts +52 -0
  29. package/resources/js/core/expressions.ts +131 -0
  30. package/resources/js/core/http.ts +101 -0
  31. package/resources/js/core/log.ts +93 -0
  32. package/resources/js/core/patch.ts +65 -0
  33. package/resources/js/core/paths.ts +122 -0
  34. package/resources/js/core/queue.ts +190 -0
  35. package/resources/js/core/rows.ts +26 -0
  36. package/resources/js/core/store.ts +560 -0
  37. package/resources/js/core/values.ts +161 -0
  38. package/resources/js/index.ts +24 -0
  39. package/resources/js/lib/utils.ts +18 -0
  40. package/resources/js/plugin.ts +17 -0
  41. package/resources/js/schema/types.ts +99 -0
  42. package/resources/js/ui/XqBadge.vue +33 -0
  43. package/resources/js/ui/XqButton.vue +59 -0
  44. package/resources/js/ui/XqCalendar.vue +90 -0
  45. package/resources/js/ui/XqCard.vue +20 -0
  46. package/resources/js/ui/XqCheckbox.vue +28 -0
  47. package/resources/js/ui/XqCombobox.vue +184 -0
  48. package/resources/js/ui/XqInput.vue +16 -0
  49. package/resources/js/ui/XqLabel.vue +17 -0
  50. package/resources/js/ui/XqRadioGroup.vue +24 -0
  51. package/resources/js/ui/XqRadioGroupItem.vue +31 -0
  52. package/resources/js/ui/XqSelect.vue +79 -0
  53. package/resources/js/ui/XqSwitch.vue +27 -0
  54. package/resources/js/ui/XqTabs.vue +19 -0
  55. package/resources/js/ui/XqTabsContent.vue +17 -0
  56. package/resources/js/ui/XqTabsList.vue +21 -0
  57. package/resources/js/ui/XqTabsTrigger.vue +27 -0
  58. package/resources/js/ui/XqTextarea.vue +16 -0
  59. package/resources/js/ui/classes.ts +12 -0
  60. package/resources/js/ui/index.ts +17 -0
@@ -0,0 +1,190 @@
1
+ import { ref } from 'vue';
2
+ import type { LiveConfig, UpdatePatch, UpdateRequest } from '../schema/types';
3
+ import { RequestLog } from './log';
4
+ import type { RequestLogEntry } from './log';
5
+
6
+ export interface RequestQueueOptions {
7
+ /** Build the request body for the given sequence number and changed paths. */
8
+ buildRequest: (seq: number, changed: string[]) => UpdateRequest;
9
+ /** Snapshot of the per-path edit counters at send time. */
10
+ snapshotVersions: () => Map<string, number>;
11
+ send: (request: UpdateRequest, signal: AbortSignal) => Promise<UpdatePatch>;
12
+ apply: (patch: UpdatePatch, versions: Map<string, number>) => void;
13
+ onError?: (error: unknown) => void;
14
+ /** Shared request history; a private one is created when omitted. */
15
+ log?: RequestLog;
16
+ }
17
+
18
+ /**
19
+ * Batches live changes into as few update requests as possible.
20
+ *
21
+ * - "change" flushes on the next tick, "debounce" after the quiet period,
22
+ * "blur" only when the field loses focus.
23
+ * - Sending a new request aborts the one in flight and carries its paths along.
24
+ * - Responses for anything but the latest sequence number are ignored.
25
+ */
26
+ export class RequestQueue {
27
+ readonly requests: RequestLog;
28
+
29
+ readonly busy = ref(false);
30
+
31
+ /** Paths of the request in flight, so fields that depend on them can show progress. */
32
+ readonly loadingPaths = ref<string[]>([]);
33
+
34
+ private pending = new Set<string>();
35
+
36
+ /** Paths of the last failed request. They go out again with the next one. */
37
+ private failed = new Set<string>();
38
+
39
+ private timer: ReturnType<typeof setTimeout> | null = null;
40
+
41
+ private inflight: { controller: AbortController; paths: string[]; seq: number } | null = null;
42
+
43
+ private seq = 0;
44
+
45
+ private waiters: Array<() => void> = [];
46
+
47
+ constructor(private readonly options: RequestQueueOptions) {
48
+ this.requests = options.log ?? new RequestLog();
49
+ }
50
+
51
+ get log(): RequestLogEntry[] {
52
+ return this.requests.entries;
53
+ }
54
+
55
+ notify(path: string, live: LiveConfig): void {
56
+ this.pending.add(path);
57
+
58
+ if (live.mode === 'blur') {
59
+ return;
60
+ }
61
+
62
+ this.schedule(live.mode === 'debounce' ? (live.ms ?? 400) : 0);
63
+ }
64
+
65
+ blur(path: string): void {
66
+ if (this.pending.has(path)) {
67
+ this.schedule(0);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Send everything that is pending now and resolve once nothing is in flight.
73
+ */
74
+ idle(): Promise<void> {
75
+ if (this.pending.size > 0) {
76
+ this.clearTimer();
77
+ void this.flush();
78
+ }
79
+
80
+ if (this.isIdle()) {
81
+ return Promise.resolve();
82
+ }
83
+
84
+ return new Promise((resolve) => this.waiters.push(resolve));
85
+ }
86
+
87
+ hasPending(): boolean {
88
+ return !this.isIdle();
89
+ }
90
+
91
+ /**
92
+ * Send the paths of the last failed request again.
93
+ */
94
+ retry(): Promise<void> {
95
+ this.clearTimer();
96
+
97
+ return this.flush();
98
+ }
99
+
100
+ async flush(): Promise<void> {
101
+ this.timer = null;
102
+ this.failed.forEach((path) => this.pending.add(path));
103
+ this.failed.clear();
104
+
105
+ if (this.pending.size === 0) {
106
+ this.settle();
107
+
108
+ return;
109
+ }
110
+
111
+ if (this.inflight) {
112
+ this.inflight.controller.abort();
113
+ this.inflight.paths.forEach((path) => this.pending.add(path));
114
+ }
115
+
116
+ const paths = [...this.pending];
117
+ const seq = ++this.seq;
118
+ const controller = new AbortController();
119
+ const versions = this.options.snapshotVersions();
120
+ const request = this.options.buildRequest(seq, paths);
121
+ const entry = this.requests.start('update', paths, null, seq);
122
+
123
+ this.pending.clear();
124
+ this.inflight = { controller, paths, seq };
125
+ this.busy.value = true;
126
+ this.loadingPaths.value = paths;
127
+
128
+ try {
129
+ const patch = await this.options.send(request, controller.signal);
130
+ entry.ms = Date.now() - entry.startedAt;
131
+ entry.patch = patch;
132
+ entry.response = patch;
133
+
134
+ if (seq !== this.seq || patch.seq !== seq) {
135
+ entry.status = 'stale';
136
+
137
+ return;
138
+ }
139
+
140
+ this.options.apply(patch, versions);
141
+ entry.status = 'ok';
142
+ } catch (error) {
143
+ entry.ms = Date.now() - entry.startedAt;
144
+
145
+ if (controller.signal.aborted) {
146
+ entry.status = 'aborted';
147
+ } else {
148
+ entry.status = 'error';
149
+ entry.error = error instanceof Error ? error.message : String(error);
150
+
151
+ if (seq === this.seq) {
152
+ paths.forEach((path) => this.failed.add(path));
153
+ }
154
+
155
+ this.options.onError?.(error);
156
+ }
157
+ } finally {
158
+ if (this.inflight?.seq === seq) {
159
+ this.inflight = null;
160
+ }
161
+
162
+ this.settle();
163
+ }
164
+ }
165
+
166
+ private schedule(ms: number): void {
167
+ this.clearTimer();
168
+ this.timer = setTimeout(() => void this.flush(), ms);
169
+ }
170
+
171
+ private clearTimer(): void {
172
+ if (this.timer !== null) {
173
+ clearTimeout(this.timer);
174
+ this.timer = null;
175
+ }
176
+ }
177
+
178
+ private isIdle(): boolean {
179
+ return this.inflight === null && this.timer === null && this.pending.size === 0;
180
+ }
181
+
182
+ private settle(): void {
183
+ this.busy.value = this.inflight !== null;
184
+ this.loadingPaths.value = this.inflight?.paths ?? [];
185
+
186
+ if (this.isIdle()) {
187
+ this.waiters.splice(0).forEach((resolve) => resolve());
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Stable keys for repeater rows. crypto.randomUUID() needs a secure context,
3
+ * so fall back to getRandomValues() on plain-http dev domains.
4
+ */
5
+ export function newRowKey(): string {
6
+ const cryptoApi = globalThis.crypto;
7
+
8
+ if (typeof cryptoApi?.randomUUID === 'function' && globalThis.isSecureContext !== false) {
9
+ return cryptoApi.randomUUID();
10
+ }
11
+
12
+ const bytes = new Uint8Array(16);
13
+
14
+ if (cryptoApi?.getRandomValues) {
15
+ cryptoApi.getRandomValues(bytes);
16
+ } else {
17
+ bytes.forEach((_, index) => (bytes[index] = Math.floor(Math.random() * 256)));
18
+ }
19
+
20
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
21
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
22
+
23
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
24
+
25
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
26
+ }