@xelqun/forms 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +46 -61
  2. package/package.json +32 -2
  3. package/resources/css/xelqun.css +121 -0
  4. package/resources/js/components/XqAction.vue +143 -0
  5. package/resources/js/components/XqFieldSlot.vue +20 -0
  6. package/resources/js/components/XqFieldWrapper.vue +74 -30
  7. package/resources/js/components/XqForm.vue +36 -9
  8. package/resources/js/components/XqNode.vue +8 -1
  9. package/resources/js/components/XqUnknownNode.vue +2 -1
  10. package/resources/js/components/defaults.ts +36 -0
  11. package/resources/js/components/fields/XqBlockPicker.vue +68 -0
  12. package/resources/js/components/fields/XqCheckboxField.vue +35 -9
  13. package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
  14. package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
  15. package/resources/js/components/fields/XqColorPickerField.vue +156 -0
  16. package/resources/js/components/fields/XqDatePickerField.vue +149 -53
  17. package/resources/js/components/fields/XqFileUploadField.vue +524 -165
  18. package/resources/js/components/fields/XqImageEditor.vue +165 -0
  19. package/resources/js/components/fields/XqKeyValueField.vue +168 -0
  20. package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
  21. package/resources/js/components/fields/XqRadioField.vue +9 -5
  22. package/resources/js/components/fields/XqRepeaterField.vue +314 -79
  23. package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
  24. package/resources/js/components/fields/XqRichEditorField.vue +280 -0
  25. package/resources/js/components/fields/XqSelectField.vue +29 -12
  26. package/resources/js/components/fields/XqSliderField.vue +47 -0
  27. package/resources/js/components/fields/XqTagsInputField.vue +127 -0
  28. package/resources/js/components/fields/XqTextInputField.vue +121 -12
  29. package/resources/js/components/fields/XqTextareaField.vue +39 -16
  30. package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
  31. package/resources/js/components/fields/XqToggleField.vue +33 -9
  32. package/resources/js/components/layouts/XqActions.vue +19 -0
  33. package/resources/js/components/layouts/XqBlock.vue +52 -0
  34. package/resources/js/components/layouts/XqCallout.vue +46 -0
  35. package/resources/js/components/layouts/XqEmptyState.vue +37 -0
  36. package/resources/js/components/layouts/XqFlex.vue +23 -0
  37. package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
  38. package/resources/js/components/layouts/XqIconNode.vue +32 -0
  39. package/resources/js/components/layouts/XqImageNode.vue +30 -0
  40. package/resources/js/components/layouts/XqSection.vue +3 -1
  41. package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
  42. package/resources/js/components/layouts/XqTextNode.vue +49 -0
  43. package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
  44. package/resources/js/components/layouts/XqWizard.vue +168 -0
  45. package/resources/js/composables/context.ts +14 -0
  46. package/resources/js/composables/useField.ts +21 -3
  47. package/resources/js/composables/usePanelErrors.ts +37 -0
  48. package/resources/js/composables/useSortable.ts +70 -0
  49. package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
  50. package/resources/js/core/dates.ts +38 -0
  51. package/resources/js/core/images.ts +110 -0
  52. package/resources/js/core/log.ts +1 -1
  53. package/resources/js/core/markdown.ts +61 -0
  54. package/resources/js/core/mask.ts +42 -0
  55. package/resources/js/core/patch.ts +6 -3
  56. package/resources/js/core/paths.ts +11 -14
  57. package/resources/js/core/store.ts +515 -25
  58. package/resources/js/core/translate.ts +17 -0
  59. package/resources/js/core/validation.ts +126 -0
  60. package/resources/js/index.ts +4 -3
  61. package/resources/js/lib/utils.ts +15 -0
  62. package/resources/js/plugin.ts +103 -6
  63. package/resources/js/schema/types.ts +102 -0
  64. package/resources/js/testing.ts +50 -0
  65. package/resources/js/ui/XqCalendar.vue +4 -0
  66. package/resources/js/ui/XqCombobox.vue +89 -32
  67. package/resources/js/ui/XqConfirmDialog.vue +58 -0
  68. package/resources/js/ui/XqDialog.vue +46 -0
  69. package/resources/js/ui/XqIcon.vue +28 -0
  70. package/resources/js/ui/XqSelect.vue +38 -18
  71. package/resources/js/ui/XqTabs.vue +1 -1
  72. package/resources/js/ui/XqTimeField.vue +66 -0
  73. package/resources/js/ui/classes.ts +33 -0
  74. package/resources/js/ui/icons.ts +95 -0
  75. package/resources/js/ui/index.ts +5 -0
  76. package/resources/js/ui/options.ts +46 -0
@@ -0,0 +1,110 @@
1
+ export interface ImageResize {
2
+ ratio?: string | null;
3
+ mode?: 'force' | 'cover' | 'contain' | null;
4
+ width?: number | null;
5
+ height?: number | null;
6
+ upscale?: boolean;
7
+ }
8
+
9
+ export interface ImageFrame {
10
+ /** The part of the source to draw. */
11
+ crop: { x: number; y: number; width: number; height: number };
12
+ width: number;
13
+ height: number;
14
+ }
15
+
16
+ function parseRatio(ratio: string | null | undefined): number | null {
17
+ const [width, height] = (ratio ?? '').split(':').map(Number);
18
+
19
+ return width > 0 && height > 0 ? width / height : null;
20
+ }
21
+
22
+ /** The middle of the source at this shape. */
23
+ function centerCrop(sourceWidth: number, sourceHeight: number, ratio: number): ImageFrame['crop'] {
24
+ const width = Math.min(sourceWidth, sourceHeight * ratio);
25
+ const height = width / ratio;
26
+
27
+ return {
28
+ x: (sourceWidth - width) / 2,
29
+ y: (sourceHeight - height) / 2,
30
+ width,
31
+ height,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * What to draw, and at what size. Works on numbers alone, so the rules can be
37
+ * tested without a canvas.
38
+ */
39
+ export function computeFrame(
40
+ sourceWidth: number,
41
+ sourceHeight: number,
42
+ { ratio, mode, width, height, upscale = true }: ImageResize,
43
+ ): ImageFrame {
44
+ const shape = parseRatio(ratio);
45
+ let crop = shape
46
+ ? centerCrop(sourceWidth, sourceHeight, shape)
47
+ : { x: 0, y: 0, width: sourceWidth, height: sourceHeight };
48
+
49
+ if (!width && !height) {
50
+ return { crop, width: Math.round(crop.width), height: Math.round(crop.height) };
51
+ }
52
+
53
+ const aspect = crop.width / crop.height;
54
+ const boxWidth = width ?? height! * aspect;
55
+ const boxHeight = height ?? width! / aspect;
56
+
57
+ if (mode === 'cover') {
58
+ const inner = centerCrop(crop.width, crop.height, boxWidth / boxHeight);
59
+
60
+ crop = { ...inner, x: crop.x + inner.x, y: crop.y + inner.y };
61
+ }
62
+
63
+ if (mode === 'force' || mode === 'cover') {
64
+ const outputWidth = upscale ? boxWidth : Math.min(boxWidth, crop.width);
65
+ const outputHeight = upscale ? boxHeight : Math.min(boxHeight, crop.height);
66
+
67
+ return { crop, width: Math.round(outputWidth), height: Math.round(outputHeight) };
68
+ }
69
+
70
+ const fit = Math.min(boxWidth / crop.width, boxHeight / crop.height);
71
+ const scale = upscale ? fit : Math.min(fit, 1);
72
+
73
+ return { crop, width: Math.round(crop.width * scale), height: Math.round(crop.height * scale) };
74
+ }
75
+
76
+ /**
77
+ * Crop and resize a picked image in the browser, before it uploads. Anything
78
+ * this can't redraw goes up as it is: an animated GIF would lose its frames.
79
+ */
80
+ export async function prepareImage(
81
+ file: File,
82
+ resize: ImageResize | null | undefined,
83
+ ): Promise<File> {
84
+ if (!resize || !['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
85
+ return file;
86
+ }
87
+
88
+ // Sent as it is, a file the browser can't read gets a proper reason from the server.
89
+ const bitmap = await createImageBitmap(file).catch(() => null);
90
+
91
+ if (!bitmap) {
92
+ return file;
93
+ }
94
+
95
+ const { crop, width, height } = computeFrame(bitmap.width, bitmap.height, resize);
96
+ const canvas = document.createElement('canvas');
97
+
98
+ canvas.width = width;
99
+ canvas.height = height;
100
+ canvas
101
+ .getContext('2d')
102
+ ?.drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, width, height);
103
+ bitmap.close();
104
+
105
+ return new Promise((resolve) => {
106
+ canvas.toBlob((blob) => {
107
+ resolve(blob ? new File([blob], file.name, { type: file.type }) : file);
108
+ }, file.type);
109
+ });
110
+ }
@@ -1,7 +1,7 @@
1
1
  import { reactive } from 'vue';
2
2
  import type { UpdatePatch } from '../schema/types';
3
3
 
4
- export type RequestKind = 'update' | 'search' | 'upload';
4
+ export type RequestKind = 'update' | 'search' | 'upload' | 'validate' | 'action';
5
5
 
6
6
  export interface RequestLogEntry {
7
7
  id: number;
@@ -0,0 +1,61 @@
1
+ export interface TextEdit {
2
+ text: string;
3
+ selectionStart: number;
4
+ selectionEnd: number;
5
+ }
6
+
7
+ /**
8
+ * Put marks around the selection, keeping it selected.
9
+ */
10
+ export function wrapSelection(
11
+ text: string,
12
+ start: number,
13
+ end: number,
14
+ before: string,
15
+ after: string,
16
+ placeholder = '',
17
+ ): TextEdit {
18
+ const inner = text.slice(start, end) || placeholder;
19
+
20
+ return {
21
+ text: `${text.slice(0, start)}${before}${inner}${after}${text.slice(end)}`,
22
+ selectionStart: start + before.length,
23
+ selectionEnd: start + before.length + inner.length,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Start every selected line with a prefix. "#" numbers them, for an ordered list.
29
+ */
30
+ export function prefixLines(text: string, start: number, end: number, prefix: string): TextEdit {
31
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
32
+ const nextBreak = text.indexOf('\n', end);
33
+ const lineEnd = nextBreak === -1 ? text.length : nextBreak;
34
+ const lines = text
35
+ .slice(lineStart, lineEnd)
36
+ .split('\n')
37
+ .map((line, index) => (prefix === '#' ? `${index + 1}. ${line}` : `${prefix}${line}`))
38
+ .join('\n');
39
+
40
+ return {
41
+ text: `${text.slice(0, lineStart)}${lines}${text.slice(lineEnd)}`,
42
+ selectionStart: lineStart,
43
+ selectionEnd: lineStart + lines.length,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Add a block on lines of its own.
49
+ */
50
+ export function insertBlock(text: string, position: number, block: string): TextEdit {
51
+ const before = text.slice(0, position);
52
+ const lead =
53
+ before === '' || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
54
+ const inserted = `${lead}${block}\n`;
55
+
56
+ return {
57
+ text: `${before}${inserted}${text.slice(position)}`,
58
+ selectionStart: position + inserted.length,
59
+ selectionEnd: position + inserted.length,
60
+ };
61
+ }
@@ -0,0 +1,42 @@
1
+ const TOKENS: Record<string, RegExp> = {
2
+ '9': /\d/,
3
+ a: /[a-zA-Z]/,
4
+ '*': /[a-zA-Z0-9]/,
5
+ };
6
+
7
+ /**
8
+ * Fit a value to a mask: 9 is a digit, a is a letter, * is either, and anything
9
+ * else is typed for the user. It stops where the value runs out, so a separator
10
+ * only appears once something follows it and backspace can remove it.
11
+ */
12
+ export function applyMask(value: string, mask: string): string {
13
+ let output = '';
14
+ let source = 0;
15
+
16
+ for (const character of mask) {
17
+ const token = TOKENS[character];
18
+
19
+ if (!token) {
20
+ if (source >= value.length) {
21
+ break;
22
+ }
23
+
24
+ output += character;
25
+ source += value[source] === character ? 1 : 0;
26
+
27
+ continue;
28
+ }
29
+
30
+ while (source < value.length && !token.test(value[source])) {
31
+ source++;
32
+ }
33
+
34
+ if (source >= value.length) {
35
+ break;
36
+ }
37
+
38
+ output += value[source++];
39
+ }
40
+
41
+ return output;
42
+ }
@@ -20,7 +20,10 @@ export function mergeNode(target: Record<string, any>, patch: Record<string, any
20
20
  for (const [key, value] of Object.entries(patch)) {
21
21
  const current = target[key];
22
22
 
23
- if (isNodeList(value) && Array.isArray(current)) {
23
+ if (key === 'extraAttributes') {
24
+ // Replaced whole: an attribute the server stopped sending has to go.
25
+ target[key] = value;
26
+ } else if (isNodeList(value) && Array.isArray(current)) {
24
27
  target[key] = mergeNodeList(current, value);
25
28
  } else if (isPlainObject(value) && isPlainObject(current) && !Array.isArray(value)) {
26
29
  mergeNode(current, value);
@@ -47,14 +50,14 @@ function mergeNodeList(current: SchemaNode[], incoming: SchemaNode[]): SchemaNod
47
50
  }
48
51
 
49
52
  /**
50
- * Visit a node and every node below it, including repeater rows (but not templates).
53
+ * Visit a node and every node below it, including the rows of fields that have them (but not templates).
51
54
  */
52
55
  export function eachNode(nodes: SchemaNode[], callback: (node: SchemaNode) => void): void {
53
56
  for (const node of nodes) {
54
57
  callback(node);
55
58
  eachNode(node.children ?? [], callback);
56
59
 
57
- if (node.type === 'repeater' && isPlainObject(node.props?.rows)) {
60
+ if (node.containsRows && isPlainObject(node.props?.rows)) {
58
61
  for (const rowNodes of Object.values(
59
62
  node.props!.rows as Record<string, SchemaNode[]>,
60
63
  )) {
@@ -3,6 +3,8 @@ import { isPlainObject } from './values';
3
3
  export const ROOT = 'data';
4
4
  export const ROW_PLACEHOLDER = '{row}';
5
5
 
6
+ const ROW_PLACEHOLDER_PATTERN = /^\{row(:\d+)?\}$/;
7
+
6
8
  /**
7
9
  * Read a value by dot path. Paths containing "*" return a flat list,
8
10
  * matching Xelqun\Forms\Support\StatePath::get().
@@ -78,8 +80,12 @@ export function deletePath(state: Record<string, any>, path: string): void {
78
80
  }
79
81
  }
80
82
 
83
+ function matchesAnySegment(segment: string): boolean {
84
+ return segment === '*' || ROW_PLACEHOLDER_PATTERN.test(segment);
85
+ }
86
+
81
87
  /**
82
- * Whether two paths touch the same state ("*" and "{row}" match any segment).
88
+ * Whether two paths touch the same state ("*" and row placeholders match any segment).
83
89
  */
84
90
  export function overlaps(first: string, second: string): boolean {
85
91
  const a = first.split('.');
@@ -87,16 +93,7 @@ export function overlaps(first: string, second: string): boolean {
87
93
  const length = Math.min(a.length, b.length);
88
94
 
89
95
  for (let index = 0; index < length; index++) {
90
- const left = a[index];
91
- const right = b[index];
92
-
93
- if (
94
- left !== right &&
95
- left !== '*' &&
96
- right !== '*' &&
97
- left !== ROW_PLACEHOLDER &&
98
- right !== ROW_PLACEHOLDER
99
- ) {
96
+ if (a[index] !== b[index] && !matchesAnySegment(a[index]) && !matchesAnySegment(b[index])) {
100
97
  return false;
101
98
  }
102
99
  }
@@ -109,10 +106,10 @@ export function startsWithPath(path: string, prefix: string): boolean {
109
106
  }
110
107
 
111
108
  /**
112
- * Deep-copy a value and replace every "{row}" placeholder in its strings.
109
+ * Deep-copy a value and replace one level's row placeholder in its strings.
113
110
  */
114
- export function replaceRow<T>(value: T, rowKey: string): T {
115
- return JSON.parse(JSON.stringify(value).split(ROW_PLACEHOLDER).join(rowKey)) as T;
111
+ export function replaceRow<T>(value: T, rowKey: string, placeholder = ROW_PLACEHOLDER): T {
112
+ return JSON.parse(JSON.stringify(value).split(placeholder).join(rowKey)) as T;
116
113
  }
117
114
 
118
115
  export function clone<T>(value: T): T {