@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,32 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import { iconSizeClasses, toneClasses } from '../../ui/classes';
6
+ import XqIcon from '../../ui/XqIcon.vue';
7
+
8
+ const props = defineProps<{ node: SchemaNode }>();
9
+ const options = computed(() => props.node.props ?? {});
10
+ const accessibleName = computed(
11
+ () => props.node.extraAttributes?.['aria-label'] ?? options.value.tooltip,
12
+ );
13
+ </script>
14
+
15
+ <template>
16
+ <span
17
+ class="inline-flex"
18
+ :role="accessibleName ? 'img' : undefined"
19
+ :aria-label="accessibleName"
20
+ :title="options.tooltip"
21
+ >
22
+ <XqIcon
23
+ :name="options.icon"
24
+ :class="
25
+ cn(
26
+ iconSizeClasses[options.size] ?? 'size-5',
27
+ toneClasses[options.color] ?? 'text-xq-muted-foreground',
28
+ )
29
+ "
30
+ />
31
+ </span>
32
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+
6
+ const props = defineProps<{ node: SchemaNode }>();
7
+ const options = computed(() => props.node.props ?? {});
8
+
9
+ const alignments: Record<string, string> = {
10
+ start: 'justify-start',
11
+ center: 'justify-center',
12
+ end: 'justify-end',
13
+ };
14
+
15
+ function cssSize(size: unknown): string | undefined {
16
+ return typeof size === 'number' ? `${size}px` : (size as string | undefined);
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <div :class="cn('flex', alignments[options.alignment])">
22
+ <img
23
+ :src="options.url"
24
+ :alt="options.alt"
25
+ :title="options.tooltip"
26
+ :style="{ width: cssSize(options.width), height: cssSize(options.height) }"
27
+ class="max-w-full rounded-xq-md"
28
+ />
29
+ </div>
30
+ </template>
@@ -6,7 +6,9 @@ import { gridStyle } from '../../composables/layout';
6
6
  import { cn } from '../../lib/utils';
7
7
  import type { SchemaNode } from '../../schema/types';
8
8
  import XqNode from '../XqNode.vue';
9
+ import { useTranslator } from '../../composables/context';
9
10
 
11
+ const t = useTranslator();
10
12
  const props = defineProps<{ node: SchemaNode }>();
11
13
  const options = computed(() => props.node.props ?? {});
12
14
  const collapsed = ref<boolean>(!!props.node.props?.collapsed);
@@ -42,7 +44,7 @@ provide(HEADING_LEVEL, Math.min(headingLevel + 1, 6));
42
44
  type="button"
43
45
  class="size-7 flex cursor-pointer items-center justify-center rounded-xq-md text-xq-muted-foreground hover:bg-xq-accent"
44
46
  :aria-expanded="!collapsed"
45
- :aria-label="collapsed ? 'Expand section' : 'Collapse section'"
47
+ :aria-label="collapsed ? t('Expand section') : t('Collapse section')"
46
48
  @click="collapsed = !collapsed"
47
49
  >
48
50
  <ChevronDown
@@ -2,7 +2,7 @@
2
2
  import { computed, ref, watch } from 'vue';
3
3
  import { useFormStore } from '../../composables/context';
4
4
  import { gridStyle } from '../../composables/layout';
5
- import { eachNode } from '../../core/patch';
5
+ import { usePanelErrors } from '../../composables/usePanelErrors';
6
6
  import type { SchemaNode } from '../../schema/types';
7
7
  import XqTabs from '../../ui/XqTabs.vue';
8
8
  import XqTabsContent from '../../ui/XqTabsContent.vue';
@@ -21,29 +21,11 @@ watch(tabs, (visible) => {
21
21
  }
22
22
  });
23
23
 
24
- function pathsIn(tab: SchemaNode): string[] {
25
- const paths: string[] = [];
26
- eachNode(tab.children ?? [], (child) => child.path && paths.push(child.path));
27
-
28
- return paths;
29
- }
30
-
31
- function errorCount(tab: SchemaNode): number {
32
- return pathsIn(tab).filter((path) => store.errors[path]).length;
33
- }
34
-
35
- watch(store.focusRequest, (request) => {
36
- const tab =
37
- request && tabs.value.find((candidate) => pathsIn(candidate).includes(request.path));
38
-
39
- if (tab) {
40
- active.value = tab.key;
41
- }
42
- });
24
+ const { errorCount } = usePanelErrors(tabs, (tab) => (active.value = tab.key));
43
25
  </script>
44
26
 
45
27
  <template>
46
- <XqTabs v-model="active">
28
+ <XqTabs v-model="active" :unmount-on-hide="false">
47
29
  <XqTabsList>
48
30
  <XqTabsTrigger v-for="tab in tabs" :key="tab.key" :value="tab.key">
49
31
  {{ tab.label }}
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import { textSizeClasses, toneClasses } from '../../ui/classes';
6
+ import XqBadge from '../../ui/XqBadge.vue';
7
+ import XqIcon from '../../ui/XqIcon.vue';
8
+
9
+ const props = defineProps<{ node: SchemaNode }>();
10
+ const options = computed(() => props.node.props ?? {});
11
+
12
+ const weights: Record<string, string> = {
13
+ thin: 'font-thin',
14
+ light: 'font-light',
15
+ normal: 'font-normal',
16
+ medium: 'font-medium',
17
+ semibold: 'font-semibold',
18
+ bold: 'font-bold',
19
+ black: 'font-black',
20
+ };
21
+ const families: Record<string, string> = {
22
+ sans: 'font-sans',
23
+ serif: 'font-serif',
24
+ mono: 'font-mono',
25
+ };
26
+ const classes = computed(() =>
27
+ cn(
28
+ textSizeClasses[options.value.size] ?? 'text-sm',
29
+ toneClasses[options.value.color] ?? 'text-xq-muted-foreground',
30
+ weights[options.value.weight],
31
+ families[options.value.fontFamily],
32
+ ),
33
+ );
34
+ </script>
35
+
36
+ <template>
37
+ <XqBadge v-if="options.badge" variant="secondary" :class="classes" :title="options.tooltip">
38
+ <XqIcon v-if="options.icon" :name="options.icon" class="size-3" />
39
+ {{ options.content }}
40
+ </XqBadge>
41
+ <!-- Only an Htmlable from PHP lands here, so the form's author vouched for it. -->
42
+ <div
43
+ v-else-if="options.html !== null && options.html !== undefined"
44
+ :class="classes"
45
+ :title="options.tooltip"
46
+ v-html="options.html"
47
+ />
48
+ <p v-else :class="classes" :title="options.tooltip">{{ options.content }}</p>
49
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '../../lib/utils';
4
+ import type { SchemaNode } from '../../schema/types';
5
+ import { textSizeClasses } from '../../ui/classes';
6
+ import XqNode from '../XqNode.vue';
7
+
8
+ const props = defineProps<{ node: SchemaNode }>();
9
+ const size = computed(() => textSizeClasses[props.node.props?.size] ?? 'text-sm');
10
+ </script>
11
+
12
+ <template>
13
+ <ul :class="cn('gap-1 ps-5 grid list-disc text-xq-muted-foreground', size)">
14
+ <li v-for="child in node.children ?? []" :key="child.key">
15
+ <XqNode :node="child" />
16
+ </li>
17
+ </ul>
18
+ </template>
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { router } from '@inertiajs/vue3';
3
+ import { Check, ChevronLeft, ChevronRight, LoaderCircle } from 'lucide-vue-next';
4
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
5
+ import { useFormStore } from '../../composables/context';
6
+ import { gridStyle } from '../../composables/layout';
7
+ import { usePanelErrors } from '../../composables/usePanelErrors';
8
+ import { cn } from '../../lib/utils';
9
+ import type { SchemaNode } from '../../schema/types';
10
+ import XqButton from '../../ui/XqButton.vue';
11
+ import XqIcon from '../../ui/XqIcon.vue';
12
+ import XqNode from '../XqNode.vue';
13
+
14
+ const props = defineProps<{ node: SchemaNode }>();
15
+ const store = useFormStore();
16
+ const { t, validating } = store;
17
+ const options = computed(() => props.node.props ?? {});
18
+ const steps = computed(() => (props.node.children ?? []).filter((step) => store.isVisible(step)));
19
+ const startKey = options.value.startKey;
20
+ const active = ref<string>(
21
+ steps.value.find((step) => step.key === startKey)?.key ?? steps.value[0]?.key ?? '',
22
+ );
23
+ const activeIndex = computed(() => steps.value.findIndex((step) => step.key === active.value));
24
+ const panels = ref<Record<string, HTMLElement>>({});
25
+
26
+ const { errorCount } = usePanelErrors(steps, (step) => (active.value = step.key));
27
+
28
+ watch(steps, (visible) => {
29
+ if (!visible.some((step) => step.key === active.value)) {
30
+ active.value = visible[0]?.key ?? '';
31
+ }
32
+ });
33
+
34
+ watch(active, (key) => {
35
+ const slug = steps.value.find((step) => step.key === key)?.props?.slug;
36
+
37
+ if (options.value.queryKey && slug) {
38
+ const url = new URL(window.location.href);
39
+
40
+ url.searchParams.set(options.value.queryKey, slug);
41
+ router.replace({
42
+ url: `${url.pathname}${url.search}`,
43
+ preserveState: true,
44
+ preserveScroll: true,
45
+ });
46
+ }
47
+ });
48
+
49
+ async function show(key: string): Promise<void> {
50
+ active.value = key;
51
+ await nextTick();
52
+ panels.value[key]?.focus({ preventScroll: true });
53
+ panels.value[key]?.scrollIntoView?.({ block: 'nearest' });
54
+ }
55
+
56
+ async function next(): Promise<void> {
57
+ const step = steps.value[activeIndex.value];
58
+
59
+ if (!step || validating.value) {
60
+ return;
61
+ }
62
+
63
+ if (options.value.skippable || (await store.validateStep(step))) {
64
+ // Looked up now: what a step's hooks set can hide the step that was next.
65
+ const following = steps.value[activeIndex.value + 1];
66
+
67
+ if (following) {
68
+ await show(following.key);
69
+ }
70
+ }
71
+ }
72
+
73
+ function canOpen(index: number): boolean {
74
+ return !!options.value.skippable || index <= activeIndex.value;
75
+ }
76
+
77
+ const stepper = { hasNext: () => activeIndex.value < steps.value.length - 1, next };
78
+
79
+ onMounted(() => store.steppers.add(stepper));
80
+ onBeforeUnmount(() => store.steppers.delete(stepper));
81
+ </script>
82
+
83
+ <template>
84
+ <div class="gap-5 grid">
85
+ <ol class="gap-2 sm:flex-row sm:gap-4 flex flex-col">
86
+ <li v-for="(step, index) in steps" :key="step.key" class="min-w-0 flex-1">
87
+ <button
88
+ type="button"
89
+ class="gap-2.5 flex w-full cursor-pointer items-center rounded-xq-md text-start disabled:cursor-default"
90
+ :aria-current="step.key === active ? 'step' : undefined"
91
+ :disabled="!canOpen(index)"
92
+ @click="show(step.key)"
93
+ >
94
+ <span
95
+ :class="
96
+ cn(
97
+ 'size-7 text-xs font-medium flex shrink-0 items-center justify-center rounded-full border border-xq-border',
98
+ step.key === active &&
99
+ 'border-xq-primary bg-xq-primary text-xq-primary-foreground',
100
+ index < activeIndex && 'border-xq-primary text-xq-primary',
101
+ errorCount(step) > 0 && 'border-xq-destructive text-xq-destructive',
102
+ )
103
+ "
104
+ >
105
+ <XqIcon
106
+ v-if="index < activeIndex && step.props?.completedIcon"
107
+ :name="step.props.completedIcon"
108
+ />
109
+ <Check v-else-if="index < activeIndex" class="size-4" aria-hidden="true" />
110
+ <XqIcon v-else-if="step.props?.icon" :name="step.props.icon" />
111
+ <template v-else>{{ index + 1 }}</template>
112
+ </span>
113
+ <span class="min-w-0 grid">
114
+ <span class="text-sm font-medium truncate">{{ step.label }}</span>
115
+ <span
116
+ v-if="step.props?.description"
117
+ class="text-xs truncate text-xq-muted-foreground"
118
+ >
119
+ {{ step.props.description }}
120
+ </span>
121
+ </span>
122
+ </button>
123
+ </li>
124
+ </ol>
125
+ <section
126
+ v-for="step in steps"
127
+ :key="step.key"
128
+ :ref="(element) => (panels[step.key] = element as HTMLElement)"
129
+ :hidden="step.key !== active"
130
+ :aria-label="step.label ?? undefined"
131
+ tabindex="-1"
132
+ class="outline-none"
133
+ >
134
+ <div class="xq-grid" :style="gridStyle(step.props?.columns)">
135
+ <XqNode v-for="child in step.children ?? []" :key="child.key" :node="child" />
136
+ </div>
137
+ </section>
138
+ <div class="gap-3 flex items-center justify-between">
139
+ <XqButton
140
+ v-if="activeIndex > 0"
141
+ type="button"
142
+ variant="outline"
143
+ size="sm"
144
+ data-xq-wizard-back
145
+ @click="show(steps[activeIndex - 1].key)"
146
+ >
147
+ <ChevronLeft />{{ t('Back') }}
148
+ </XqButton>
149
+ <XqButton
150
+ v-if="activeIndex < steps.length - 1"
151
+ type="button"
152
+ size="sm"
153
+ class="ms-auto"
154
+ data-xq-wizard-next
155
+ :disabled="validating"
156
+ @click="next"
157
+ >
158
+ {{ t('Next') }}
159
+ <LoaderCircle
160
+ v-if="validating"
161
+ class="motion-safe:animate-spin"
162
+ aria-hidden="true"
163
+ />
164
+ <ChevronRight v-else />
165
+ </XqButton>
166
+ </div>
167
+ </div>
168
+ </template>
@@ -1,12 +1,16 @@
1
1
  import { inject, provide } from 'vue';
2
2
  import type { InjectionKey } from 'vue';
3
3
  import type { FormStore } from '../core/store';
4
+ import { translate } from '../core/translate';
4
5
 
5
6
  export const FORM_STORE: InjectionKey<FormStore> = Symbol('xelqun-form-store');
6
7
 
7
8
  /** Heading level for the next section title. Nested sections go one level deeper. */
8
9
  export const HEADING_LEVEL: InjectionKey<number> = Symbol('xelqun-heading-level');
9
10
 
11
+ /** Set inside a FusedGroup, which lists its fields' errors below the whole group. */
12
+ export const FUSED_GROUP: InjectionKey<boolean> = Symbol('xelqun-fused-group');
13
+
10
14
  export function provideFormStore(store: FormStore): void {
11
15
  provide(FORM_STORE, store);
12
16
  }
@@ -20,3 +24,13 @@ export function useFormStore(): FormStore {
20
24
 
21
25
  return store;
22
26
  }
27
+
28
+ /**
29
+ * The form's translator, or plain English for a control used outside a form.
30
+ */
31
+ export function useTranslator(): FormStore['t'] {
32
+ return (
33
+ inject(FORM_STORE, null)?.t ??
34
+ ((text, replacements) => translate(undefined, text, replacements))
35
+ );
36
+ }
@@ -1,7 +1,15 @@
1
1
  import { computed } from 'vue';
2
2
  import type { SchemaNode } from '../schema/types';
3
+ import type { FormStore } from '../core/store';
3
4
  import { useFormStore } from './context';
4
5
 
6
+ /**
7
+ * The id of a field's control. Its error and hint hang off this.
8
+ */
9
+ export function fieldId(store: FormStore, path: string): string {
10
+ return `xq-${store.id}-${path}`.replace(/[^\w-]/g, '-');
11
+ }
12
+
5
13
  /**
6
14
  * Everything a field component needs: a writable value bound to the form
7
15
  * state, its error, and the evaluated required/disabled rules.
@@ -10,8 +18,9 @@ export function useField<T = any>(nodeOrPath: SchemaNode | string) {
10
18
  const store = useFormStore();
11
19
  const node = typeof nodeOrPath === 'string' ? store.nodes.get(nodeOrPath)! : nodeOrPath;
12
20
  const path = node.path ?? node.key;
13
- const id = `xq-${store.id}-${path}`.replace(/[^\w-]/g, '-');
21
+ const id = fieldId(store, path);
14
22
  const error = computed(() => store.errors[path] ?? null);
23
+ const hint = computed(() => store.text(node, 'hint'));
15
24
 
16
25
  const value = computed<T>({
17
26
  get: () => store.get(path),
@@ -25,17 +34,26 @@ export function useField<T = any>(nodeOrPath: SchemaNode | string) {
25
34
  id,
26
35
  value,
27
36
  error,
37
+ label: computed(() => store.text(node, 'label')),
38
+ hint,
39
+ placeholder: computed(() => store.text(node, 'placeholder')),
28
40
  /** The id for aria-describedby: the error when there is one, otherwise the hint. */
29
41
  describedBy: computed(() => {
30
42
  if (error.value) {
31
43
  return `${id}-error`;
32
44
  }
33
45
 
34
- return node.hint ? `${id}-hint` : undefined;
46
+ return hint.value ? `${id}-hint` : undefined;
35
47
  }),
36
48
  loading: computed(() => store.isLoading(node)),
49
+ /** Delayed version of loading, for the spinner: fast requests never show one. */
50
+ showLoading: computed(() => store.showsLoadingIndicator(node)),
37
51
  required: computed(() => store.isRequired(node)),
38
- disabled: computed(() => store.isDisabled(node) || store.processing.value),
52
+ /** True when read-only too, so a control with no read-only mode can't be changed. */
53
+ disabled: computed(
54
+ () => store.isDisabled(node) || !!node.readOnly || store.processing.value,
55
+ ),
56
+ readOnly: computed(() => !!node.readOnly),
39
57
  onBlur: () => store.blur(path),
40
58
  };
41
59
  }
@@ -0,0 +1,37 @@
1
+ import { watch } from 'vue';
2
+ import type { ComputedRef } from 'vue';
3
+ import { eachNode } from '../core/patch';
4
+ import type { SchemaNode } from '../schema/types';
5
+ import { useFormStore } from './context';
6
+
7
+ /**
8
+ * For layouts that show one panel at a time: count each panel's errors, and open
9
+ * the panel holding the field the form wants to focus.
10
+ */
11
+ export function usePanelErrors(
12
+ panels: ComputedRef<SchemaNode[]>,
13
+ open: (panel: SchemaNode) => void,
14
+ ) {
15
+ const store = useFormStore();
16
+
17
+ function pathsIn(panel: SchemaNode): string[] {
18
+ const paths: string[] = [];
19
+ eachNode(panel.children ?? [], (child) => child.path && paths.push(child.path));
20
+
21
+ return paths;
22
+ }
23
+
24
+ watch(store.focusRequest, (request) => {
25
+ const panel =
26
+ request && panels.value.find((candidate) => pathsIn(candidate).includes(request.path));
27
+
28
+ if (panel) {
29
+ open(panel);
30
+ }
31
+ });
32
+
33
+ return {
34
+ errorCount: (panel: SchemaNode) =>
35
+ pathsIn(panel).filter((path) => store.errors[path]).length,
36
+ };
37
+ }
@@ -0,0 +1,70 @@
1
+ import type SortableType from 'sortablejs';
2
+ import { onBeforeUnmount, watch } from 'vue';
3
+ import type { Ref } from 'vue';
4
+
5
+ export interface SortableOptions {
6
+ /** Which children can move. Start with ">" so a nested list's items don't count. */
7
+ draggable: string;
8
+ /** Only this part starts a drag. Without it, the whole item does. */
9
+ handle?: string;
10
+ enabled: () => boolean;
11
+ /** Called with indexes among the draggable items, once the drag ends somewhere new. */
12
+ onMove: (from: number, to: number) => void;
13
+ }
14
+
15
+ /**
16
+ * Drag to reorder. SortableJS loads only when a list can actually be dragged, and
17
+ * move buttons stay next to it, because keyboard users can't drag.
18
+ */
19
+ export function useSortable(container: Ref<HTMLElement | null>, options: SortableOptions): void {
20
+ let sortable: SortableType | null = null;
21
+
22
+ function destroy(): void {
23
+ sortable?.destroy();
24
+ sortable = null;
25
+ }
26
+
27
+ watch(
28
+ [container, options.enabled],
29
+ async ([element, enabled]) => {
30
+ destroy();
31
+
32
+ if (!element || !enabled) {
33
+ return;
34
+ }
35
+
36
+ const { default: Sortable } = await import('sortablejs');
37
+
38
+ if (container.value !== element || !options.enabled() || sortable) {
39
+ return;
40
+ }
41
+
42
+ sortable = Sortable.create(element, {
43
+ draggable: options.draggable,
44
+ handle: options.handle,
45
+ filter: 'input, textarea, button:not([data-xq-drag])',
46
+ preventOnFilter: false,
47
+ animation: 150,
48
+ ghostClass: 'opacity-40',
49
+ // The same drag for mouse and touch, and no text dropped into inputs.
50
+ forceFallback: true,
51
+ onEnd({ item, from, oldIndex, newIndex, oldDraggableIndex, newDraggableIndex }) {
52
+ if (oldDraggableIndex === newDraggableIndex) {
53
+ return;
54
+ }
55
+
56
+ // Put the element back: Vue owns this order and moves it from the state.
57
+ from.insertBefore(
58
+ item,
59
+ from.children[oldIndex! + (oldIndex! > newIndex! ? 1 : 0)] ?? null,
60
+ );
61
+
62
+ options.onMove(oldDraggableIndex!, newDraggableIndex!);
63
+ },
64
+ });
65
+ },
66
+ { immediate: true },
67
+ );
68
+
69
+ onBeforeUnmount(destroy);
70
+ }
@@ -0,0 +1,48 @@
1
+ import { router } from '@inertiajs/vue3';
2
+ import { nextTick, onBeforeUnmount, onMounted } from 'vue';
3
+ import type { FormStore } from '../core/store';
4
+
5
+ /**
6
+ * Warn before the user leaves with changes, when the form asks for it. The
7
+ * browser's own dialog covers closing the tab, because nothing else may run there.
8
+ */
9
+ export function useUnsavedChangesAlert(store: FormStore): void {
10
+ if (!store.schema.unsavedChangesAlert) {
11
+ return;
12
+ }
13
+
14
+ let stopListening = (): void => {};
15
+
16
+ function warnBeforeUnload(event: BeforeUnloadEvent): void {
17
+ if (store.isDirty()) {
18
+ event.preventDefault();
19
+ }
20
+ }
21
+
22
+ onMounted(async () => {
23
+ // Computed fields settle a tick after they mount, and that isn't an edit.
24
+ await nextTick();
25
+ store.markClean();
26
+
27
+ window.addEventListener('beforeunload', warnBeforeUnload);
28
+ stopListening = router.on('before', (event) => {
29
+ const { visit } = event.detail;
30
+ const staysOnPage =
31
+ store.processing.value ||
32
+ visit.prefetch ||
33
+ visit.only.length > 0 ||
34
+ (visit.preserveState === true && visit.url.pathname === window.location.pathname);
35
+
36
+ if (!staysOnPage && store.isDirty()) {
37
+ if (!window.confirm(store.t('You have unsaved changes. Leave this page?'))) {
38
+ event.preventDefault();
39
+ }
40
+ }
41
+ });
42
+ });
43
+
44
+ onBeforeUnmount(() => {
45
+ window.removeEventListener('beforeunload', warnBeforeUnload);
46
+ stopListening();
47
+ });
48
+ }
@@ -0,0 +1,38 @@
1
+ function name(date: Date, options: Intl.DateTimeFormatOptions, locale?: string): string {
2
+ return new Intl.DateTimeFormat(locale, options).format(date);
3
+ }
4
+
5
+ function pad(value: number): string {
6
+ return String(value).padStart(2, '0');
7
+ }
8
+
9
+ const TOKENS: Record<string, (date: Date, locale?: string) => string> = {
10
+ d: (date) => pad(date.getDate()),
11
+ j: (date) => String(date.getDate()),
12
+ D: (date, locale) => name(date, { weekday: 'short' }, locale),
13
+ l: (date, locale) => name(date, { weekday: 'long' }, locale),
14
+ m: (date) => pad(date.getMonth() + 1),
15
+ n: (date) => String(date.getMonth() + 1),
16
+ M: (date, locale) => name(date, { month: 'short' }, locale),
17
+ F: (date, locale) => name(date, { month: 'long' }, locale),
18
+ Y: (date) => String(date.getFullYear()),
19
+ y: (date) => String(date.getFullYear()).slice(-2),
20
+ H: (date) => pad(date.getHours()),
21
+ G: (date) => String(date.getHours()),
22
+ h: (date) => pad(date.getHours() % 12 || 12),
23
+ g: (date) => String(date.getHours() % 12 || 12),
24
+ i: (date) => pad(date.getMinutes()),
25
+ s: (date) => pad(date.getSeconds()),
26
+ A: (date) => (date.getHours() < 12 ? 'AM' : 'PM'),
27
+ a: (date) => (date.getHours() < 12 ? 'am' : 'pm'),
28
+ };
29
+
30
+ /**
31
+ * Format a date with PHP's date tokens, so a format written in PHP reads the same
32
+ * in the browser. A backslash escapes the next character.
33
+ */
34
+ export function formatPhpDate(date: Date, format: string, locale?: string): string {
35
+ return format.replace(/\\(.)|[a-zA-Z]/g, (token, escaped?: string) => {
36
+ return escaped ?? TOKENS[token]?.(date, locale) ?? token;
37
+ });
38
+ }