@xelqun/forms 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -97,6 +97,10 @@ Other packages can add fields, with the same search, uploads, rows and saving as
97
97
  - **Give each action its own `authorize()`.** An action does nothing without one, and the form's `authorize()` doesn't count.
98
98
  - **Schedule `php artisan xelqun-forms:prune-uploads`.** Temporary uploads stay on disk until it runs.
99
99
 
100
+ ## Inspiration
101
+
102
+ Xelqun Forms is heavily inspired by [Filament](https://filamentphp.com). I'm a big fan of Filament and have built several packages for it, and this project brings its PHP-first way of building forms to Inertia and Vue.
103
+
100
104
  ## License
101
105
 
102
106
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xelqun/forms",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Vue renderer for Xelqun Forms: PHP-defined, schema-driven forms for Laravel + Inertia.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +1,18 @@
1
+ <script lang="ts">
2
+ import type { FormStore } from '../core/store';
3
+
4
+ /**
5
+ * What the button needs from whatever holds it. Outside a form, pass your own.
6
+ */
7
+ export type ActionRunner = Pick<FormStore, 'mountAction' | 'runAction' | 'processing' | 't'>;
8
+ </script>
9
+
1
10
  <script setup lang="ts">
11
+ import { Link } from '@inertiajs/vue3';
2
12
  import { LoaderCircle } from 'lucide-vue-next';
3
13
  import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
4
14
  import { useFormStore } from '../composables/context';
5
15
  import { createFormStore } from '../core/store';
6
- import type { FormStore } from '../core/store';
7
16
  import type { SchemaNode } from '../schema/types';
8
17
  import XqButton from '../ui/XqButton.vue';
9
18
  import XqConfirmDialog from '../ui/XqConfirmDialog.vue';
@@ -11,8 +20,8 @@ import XqDialog from '../ui/XqDialog.vue';
11
20
  import XqIcon from '../ui/XqIcon.vue';
12
21
  import XqForm from './XqForm.vue';
13
22
 
14
- const props = defineProps<{ node: SchemaNode }>();
15
- const store = useFormStore();
23
+ const props = defineProps<{ node: SchemaNode; runner?: ActionRunner }>();
24
+ const store = props.runner ?? useFormStore();
16
25
  const options = computed(() => props.node.props ?? {});
17
26
  const running = ref(false);
18
27
  const confirming = ref(false);
@@ -32,6 +41,19 @@ const variant = computed(() =>
32
41
  : (variants[options.value.color] ?? 'outline'),
33
42
  );
34
43
 
44
+ // Inertia can only visit its own app, and a new tab isn't a visit.
45
+ const isVisit = computed(() => {
46
+ if (options.value.openInNewTab) {
47
+ return false;
48
+ }
49
+
50
+ const url: string = options.value.url;
51
+
52
+ return typeof window === 'undefined'
53
+ ? url.startsWith('/')
54
+ : new URL(url, window.location.href).origin === window.location.origin;
55
+ });
56
+
35
57
  async function run(): Promise<void> {
36
58
  // Enter in a modal field reaches this without the button.
37
59
  if (running.value) {
@@ -87,6 +109,25 @@ onScopeDispose(closeModal);
87
109
  <template>
88
110
  <div class="inline-flex" data-xq-action>
89
111
  <XqButton
112
+ v-if="options.url"
113
+ as-child
114
+ :variant="variant"
115
+ :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
116
+ :title="options.tooltip"
117
+ :aria-label="options.style === 'icon' ? options.label : undefined"
118
+ >
119
+ <component
120
+ :is="isVisit ? Link : 'a'"
121
+ :href="options.url"
122
+ :target="options.openInNewTab ? '_blank' : undefined"
123
+ :rel="options.openInNewTab ? 'noopener noreferrer' : undefined"
124
+ >
125
+ <XqIcon v-if="options.icon" :name="options.icon" />
126
+ <template v-if="options.style !== 'icon'">{{ options.label }}</template>
127
+ </component>
128
+ </XqButton>
129
+ <XqButton
130
+ v-else
90
131
  type="button"
91
132
  :variant="variant"
92
133
  :size="options.style === 'icon' ? 'icon-sm' : 'sm'"
@@ -64,6 +64,27 @@ export interface ActionModal {
64
64
  liveError: Ref<string | null>;
65
65
  }
66
66
 
67
+ /**
68
+ * Show why a request failed: on the fields a validation error names, or as a
69
+ * message on the target.
70
+ */
71
+ export function reportRequestFailure(
72
+ error: unknown,
73
+ target: Omit<ActionModal, 'state' | 'settled' | 'validate'>,
74
+ t: (text: string) => string,
75
+ ): void {
76
+ if (error instanceof HttpError && error.status === 422) {
77
+ target.addErrors(error.validationErrors);
78
+ target.focusFirstError();
79
+ } else if (error instanceof HttpError && error.status === 419) {
80
+ target.fatalError.value = t('This form has expired. Reload the page to continue.');
81
+ } else {
82
+ target.liveError.value = t(
83
+ 'Some fields could not be updated. Check your connection and try again.',
84
+ );
85
+ }
86
+ }
87
+
67
88
  export interface FormStoreOptions {
68
89
  send?: (url: string, request: UpdateRequest, signal: AbortSignal) => Promise<UpdatePatch>;
69
90
  }
@@ -679,16 +700,7 @@ export function createFormStore(schema: FormSchema, options: FormStoreOptions =
679
700
  liveError,
680
701
  },
681
702
  ): void {
682
- if (error instanceof HttpError && error.status === 422) {
683
- target.addErrors(error.validationErrors);
684
- target.focusFirstError();
685
- } else if (error instanceof HttpError && error.status === 419) {
686
- target.fatalError.value = t('This form has expired. Reload the page to continue.');
687
- } else {
688
- target.liveError.value = t(
689
- 'Some fields could not be updated. Check your connection and try again.',
690
- );
691
- }
703
+ reportRequestFailure(error, target, t);
692
704
  }
693
705
 
694
706
  function actionBody(
@@ -9,9 +9,16 @@ export function translate(
9
9
  text: string,
10
10
  replacements: Replacements = {},
11
11
  ): string {
12
- return Object.entries(replacements).reduce(
13
- // A function, so a "$&" in a file name isn't read as a pattern.
14
- (line, [name, value]) => line.replaceAll(`:${name}`, () => String(value)),
15
- messages?.[text] ?? text,
12
+ const line = messages?.[text] ?? text;
13
+ const names = Object.keys(replacements).sort((first, second) => second.length - first.length);
14
+
15
+ if (names.length === 0) {
16
+ return line;
17
+ }
18
+
19
+ // One pass, longest name first, so :to can't match inside :total and a value
20
+ // holding ":name" isn't filled in again. A function, so "$&" isn't a pattern.
21
+ return line.replace(new RegExp(`:(${names.join('|')})`, 'g'), (_, name: string) =>
22
+ String(replacements[name]),
16
23
  );
17
24
  }
@@ -3,6 +3,8 @@ export type { XelqunFormsOptions, XelqunPlugin, XelqunPluginContext } from './pl
3
3
 
4
4
  export { default as XqForm } from './components/XqForm.vue';
5
5
  export { default as XqNode } from './components/XqNode.vue';
6
+ export { default as XqAction } from './components/XqAction.vue';
7
+ export type { ActionRunner } from './components/XqAction.vue';
6
8
  export { default as XqFieldWrapper } from './components/XqFieldWrapper.vue';
7
9
  export { createRegistry, defaultComponents } from './components/defaults';
8
10
 
@@ -10,12 +12,14 @@ export { useXqForm } from './composables/useXqForm';
10
12
  export { useField } from './composables/useField';
11
13
  export { useFormStore, useTranslator } from './composables/context';
12
14
  export { gridStyle, spanStyle } from './composables/layout';
15
+ export { useRegistry } from './composables/registry';
13
16
  export type { ComponentRegistry, ComponentResolver } from './composables/registry';
14
17
 
15
- export { createFormStore, defaultLiveConfig } from './core/store';
16
- export type { FormStore, FormStoreOptions, SubmitOptions } from './core/store';
18
+ export { createFormStore, defaultLiveConfig, reportRequestFailure } from './core/store';
19
+ export type { ActionModal, FormStore, FormStoreOptions, SubmitOptions } from './core/store';
17
20
  export type { RequestKind, RequestLogEntry } from './core/log';
18
- export { HttpError } from './core/http';
21
+ export { HttpError, postJson } from './core/http';
22
+ export { translate } from './core/translate';
19
23
  export { evaluateCondition } from './core/conditions';
20
24
  export { evaluateExpression } from './core/expressions';
21
25
  export { getPath, overlaps } from './core/paths';
@@ -1,28 +1,35 @@
1
1
  <script setup lang="ts">
2
- import { Check } from 'lucide-vue-next';
2
+ import { Check, Minus } from 'lucide-vue-next';
3
3
  import { CheckboxIndicator, CheckboxRoot } from 'reka-ui';
4
4
  import type { HTMLAttributes } from 'vue';
5
5
  import { cn } from '../lib/utils';
6
6
 
7
- const props = defineProps<{ id?: string; disabled?: boolean; class?: HTMLAttributes['class'] }>();
7
+ const props = defineProps<{
8
+ id?: string;
9
+ disabled?: boolean;
10
+ indeterminate?: boolean;
11
+ class?: HTMLAttributes['class'];
12
+ }>();
8
13
  const model = defineModel<boolean>({ default: false });
9
14
  </script>
10
15
 
11
16
  <template>
12
17
  <CheckboxRoot
13
18
  :id="id"
14
- v-model="model"
19
+ :model-value="indeterminate ? 'indeterminate' : model"
15
20
  data-slot="xq-checkbox"
16
21
  :disabled="disabled"
22
+ @update:model-value="model = $event === true"
17
23
  :class="
18
24
  cn(
19
- 'peer size-4 shadow-xs shrink-0 cursor-pointer rounded-[4px] border border-xq-input bg-xq-background transition-shadow outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-xq-destructive data-[state=checked]:border-xq-primary data-[state=checked]:bg-xq-primary data-[state=checked]:text-xq-primary-foreground',
25
+ 'peer size-4 shadow-xs shrink-0 cursor-pointer rounded-[4px] border border-xq-input bg-xq-background transition-shadow outline-none focus-visible:ring-[3px] focus-visible:ring-xq-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-xq-destructive data-[state=checked]:border-xq-primary data-[state=checked]:bg-xq-primary data-[state=checked]:text-xq-primary-foreground data-[state=indeterminate]:border-xq-primary data-[state=indeterminate]:bg-xq-primary data-[state=indeterminate]:text-xq-primary-foreground',
20
26
  props.class,
21
27
  )
22
28
  "
23
29
  >
24
30
  <CheckboxIndicator class="flex items-center justify-center text-current">
25
- <Check class="size-3.5" :stroke-width="3" />
31
+ <Minus v-if="indeterminate" class="size-3.5" :stroke-width="3" />
32
+ <Check v-else class="size-3.5" :stroke-width="3" />
26
33
  </CheckboxIndicator>
27
34
  </CheckboxRoot>
28
35
  </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
3
+ import type { HTMLAttributes } from 'vue';
4
+ import { cn } from '../lib/utils';
5
+ import { popoverClass } from './classes';
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ align?: 'start' | 'center' | 'end';
10
+ sideOffset?: number;
11
+ class?: HTMLAttributes['class'];
12
+ }>(),
13
+ { align: 'start', sideOffset: 4 },
14
+ );
15
+ const open = defineModel<boolean>('open', { default: false });
16
+ </script>
17
+
18
+ <template>
19
+ <PopoverRoot v-model:open="open">
20
+ <PopoverTrigger as-child>
21
+ <slot name="trigger" />
22
+ </PopoverTrigger>
23
+ <PopoverPortal>
24
+ <PopoverContent
25
+ data-slot="xq-popover"
26
+ :align="align"
27
+ :side-offset="sideOffset"
28
+ :class="cn(popoverClass, 'p-3', props.class)"
29
+ >
30
+ <slot />
31
+ </PopoverContent>
32
+ </PopoverPortal>
33
+ </PopoverRoot>
34
+ </template>
@@ -10,6 +10,7 @@ export { registerIcons, resolveIcon } from './icons';
10
10
  export { default as XqIcon } from './XqIcon.vue';
11
11
  export { default as XqInput } from './XqInput.vue';
12
12
  export { default as XqLabel } from './XqLabel.vue';
13
+ export { default as XqPopover } from './XqPopover.vue';
13
14
  export { default as XqRadioGroup } from './XqRadioGroup.vue';
14
15
  export { default as XqRadioGroupItem } from './XqRadioGroupItem.vue';
15
16
  export { default as XqSelect } from './XqSelect.vue';