@afurgeri/crud-vue 0.1.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 (95) hide show
  1. package/package.json +46 -0
  2. package/src/__test__/CrudCards.test.ts +118 -0
  3. package/src/__test__/CrudEmptyState.test.ts +82 -0
  4. package/src/__test__/CrudForm.test.ts +101 -0
  5. package/src/__test__/CrudShow.test.ts +135 -0
  6. package/src/__test__/CrudTable.test.ts +111 -0
  7. package/src/__test__/CrudToolbar.test.ts +102 -0
  8. package/src/__test__/setup.ts +43 -0
  9. package/src/__test__/useCrud.test.ts +349 -0
  10. package/src/components/CrudCards.vue +105 -0
  11. package/src/components/CrudDeleteDialog.vue +80 -0
  12. package/src/components/CrudEmptyState.vue +58 -0
  13. package/src/components/CrudFilters.vue +194 -0
  14. package/src/components/CrudForm.vue +232 -0
  15. package/src/components/CrudPage.vue +206 -0
  16. package/src/components/CrudPagination.vue +130 -0
  17. package/src/components/CrudSearch.vue +42 -0
  18. package/src/components/CrudShow.vue +216 -0
  19. package/src/components/CrudTable.vue +146 -0
  20. package/src/components/CrudToolbar.vue +86 -0
  21. package/src/components/InputError.vue +13 -0
  22. package/src/components/ui/button/Button.vue +27 -0
  23. package/src/components/ui/button/index.ts +36 -0
  24. package/src/components/ui/card/Card.vue +22 -0
  25. package/src/components/ui/card/CardAction.vue +17 -0
  26. package/src/components/ui/card/CardContent.vue +17 -0
  27. package/src/components/ui/card/CardDescription.vue +17 -0
  28. package/src/components/ui/card/CardFooter.vue +17 -0
  29. package/src/components/ui/card/CardHeader.vue +17 -0
  30. package/src/components/ui/card/CardTitle.vue +17 -0
  31. package/src/components/ui/card/index.ts +7 -0
  32. package/src/components/ui/checkbox/Checkbox.vue +37 -0
  33. package/src/components/ui/checkbox/index.ts +1 -0
  34. package/src/components/ui/combobox/ComboboxInput.vue +83 -0
  35. package/src/components/ui/combobox/index.ts +1 -0
  36. package/src/components/ui/dialog/Dialog.vue +17 -0
  37. package/src/components/ui/dialog/DialogClose.vue +14 -0
  38. package/src/components/ui/dialog/DialogContent.vue +49 -0
  39. package/src/components/ui/dialog/DialogDescription.vue +25 -0
  40. package/src/components/ui/dialog/DialogFooter.vue +15 -0
  41. package/src/components/ui/dialog/DialogHeader.vue +17 -0
  42. package/src/components/ui/dialog/DialogOverlay.vue +23 -0
  43. package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
  44. package/src/components/ui/dialog/DialogTitle.vue +25 -0
  45. package/src/components/ui/dialog/DialogTrigger.vue +14 -0
  46. package/src/components/ui/dialog/index.ts +10 -0
  47. package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
  48. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
  49. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  50. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
  51. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
  52. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
  53. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
  54. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
  55. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
  56. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  57. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
  58. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
  59. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
  60. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
  61. package/src/components/ui/dropdown-menu/index.ts +16 -0
  62. package/src/components/ui/input/Input.vue +33 -0
  63. package/src/components/ui/input/index.ts +1 -0
  64. package/src/components/ui/label/Label.vue +28 -0
  65. package/src/components/ui/label/index.ts +1 -0
  66. package/src/components/ui/select/Select.vue +15 -0
  67. package/src/components/ui/select/SelectContent.vue +49 -0
  68. package/src/components/ui/select/SelectGroup.vue +17 -0
  69. package/src/components/ui/select/SelectItem.vue +41 -0
  70. package/src/components/ui/select/SelectItemText.vue +12 -0
  71. package/src/components/ui/select/SelectLabel.vue +14 -0
  72. package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
  73. package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
  74. package/src/components/ui/select/SelectSeparator.vue +15 -0
  75. package/src/components/ui/select/SelectTrigger.vue +29 -0
  76. package/src/components/ui/select/SelectValue.vue +12 -0
  77. package/src/components/ui/select/index.ts +11 -0
  78. package/src/components/ui/separator/Separator.vue +28 -0
  79. package/src/components/ui/separator/index.ts +1 -0
  80. package/src/components/ui/spinner/Spinner.vue +17 -0
  81. package/src/components/ui/spinner/index.ts +1 -0
  82. package/src/components/ui/table/Table.vue +16 -0
  83. package/src/components/ui/table/TableBody.vue +14 -0
  84. package/src/components/ui/table/TableCaption.vue +14 -0
  85. package/src/components/ui/table/TableCell.vue +21 -0
  86. package/src/components/ui/table/TableEmpty.vue +34 -0
  87. package/src/components/ui/table/TableFooter.vue +14 -0
  88. package/src/components/ui/table/TableHead.vue +14 -0
  89. package/src/components/ui/table/TableHeader.vue +14 -0
  90. package/src/components/ui/table/TableRow.vue +14 -0
  91. package/src/components/ui/table/index.ts +9 -0
  92. package/src/composables/useCrud.ts +328 -0
  93. package/src/index.ts +33 -0
  94. package/src/lib/utils.ts +18 -0
  95. package/src/types/crud.ts +133 -0
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { Button } from './ui/button';
3
+ import { FileX, Plus, SearchX } from 'lucide-vue-next';
4
+ import { router } from '@inertiajs/vue3';
5
+ import type { CrudFeatureCreate } from '../types/crud';
6
+
7
+ defineProps<{
8
+ hasFilters: boolean;
9
+ create?: boolean | CrudFeatureCreate;
10
+ }>();
11
+
12
+ defineEmits<{
13
+ clearFilters: [];
14
+ }>();
15
+ </script>
16
+
17
+ <template>
18
+ <div class="flex flex-col items-center justify-center px-6 py-16 text-center">
19
+ <slot name="icon">
20
+ <div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
21
+ <FileX v-if="!hasFilters" class="h-8 w-8 text-muted-foreground" />
22
+ <SearchX v-else class="h-8 w-8 text-muted-foreground" />
23
+ </div>
24
+ </slot>
25
+
26
+ <slot name="message">
27
+ <h3 v-if="!hasFilters" class="text-lg font-semibold text-foreground">No records yet</h3>
28
+ <h3 v-else class="text-lg font-semibold text-foreground">No results found</h3>
29
+
30
+ <p v-if="!hasFilters" class="mt-2 max-w-sm text-sm text-muted-foreground">
31
+ Get started by creating your first record.
32
+ </p>
33
+ <p v-else class="mt-2 max-w-sm text-sm text-muted-foreground">
34
+ Try adjusting your search or filter criteria to find what you're looking for.
35
+ </p>
36
+ </slot>
37
+
38
+ <slot name="actions">
39
+ <div class="mt-6 flex gap-3">
40
+ <Button
41
+ v-if="hasFilters"
42
+ variant="outline"
43
+ @click="$emit('clearFilters')"
44
+ >
45
+ Clear Filters
46
+ </Button>
47
+
48
+ <Button
49
+ v-if="!hasFilters && create && typeof create !== 'boolean'"
50
+ @click="router.get(create.url)"
51
+ >
52
+ <Plus class="mr-2 h-4 w-4" />
53
+ {{ create.label ?? 'Create' }}
54
+ </Button>
55
+ </div>
56
+ </slot>
57
+ </div>
58
+ </template>
@@ -0,0 +1,194 @@
1
+ <script setup lang="ts">
2
+ import { Button } from './ui/button';
3
+ import { Input } from './ui/input';
4
+ import { Label } from './ui/label';
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from './ui/select';
12
+ import { Separator } from './ui/separator';
13
+ import type { CrudFilter } from '../types/crud';
14
+ import { RotateCw, X } from 'lucide-vue-next';
15
+ import { ref } from 'vue';
16
+
17
+ const props = defineProps<{
18
+ filters: CrudFilter[];
19
+ modelValue: Record<string, unknown>;
20
+ visible: boolean;
21
+ }>();
22
+
23
+ const emit = defineEmits<{
24
+ 'update:modelValue': [value: Record<string, unknown>];
25
+ apply: [];
26
+ reset: [];
27
+ }>();
28
+
29
+ // Local copy for editing
30
+ const localFilters = ref<Record<string, unknown>>({ ...props.modelValue });
31
+
32
+ function getFieldState(field: string): unknown {
33
+ return localFilters.value[field];
34
+ }
35
+
36
+ function setFieldState(field: string, value: unknown) {
37
+ localFilters.value[field] = value;
38
+ }
39
+
40
+ function setFieldSubState(field: string, subField: string, value: unknown) {
41
+ if (!localFilters.value[field] || typeof localFilters.value[field] !== 'object') {
42
+ localFilters.value[field] = {};
43
+ }
44
+ (localFilters.value[field] as Record<string, unknown>)[subField] = value;
45
+ }
46
+
47
+ function getFieldSubState(field: string, subField: string): unknown {
48
+ const parent = localFilters.value[field];
49
+ if (!parent || typeof parent !== 'object') return '';
50
+ return (parent as Record<string, unknown>)[subField] ?? '';
51
+ }
52
+
53
+ function onApply() {
54
+ emit('update:modelValue', { ...localFilters.value });
55
+ emit('apply');
56
+ }
57
+
58
+ function onReset() {
59
+ localFilters.value = {};
60
+ emit('update:modelValue', {});
61
+ emit('reset');
62
+ }
63
+
64
+ function getNumberValue(field: string, sub: 'min' | 'max'): string {
65
+ const val = getFieldSubState(field, sub);
66
+ return val !== undefined && val !== null && val !== '' ? String(val) : '';
67
+ }
68
+
69
+ function getDateValue(field: string, sub: 'from' | 'to'): string {
70
+ const val = getFieldSubState(field, sub);
71
+ return val !== undefined && val !== null && val !== '' ? String(val) : '';
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <Transition
77
+ enter-active-class="transition-all duration-200 ease-out"
78
+ enter-from-class="max-h-0 opacity-0"
79
+ enter-to-class="max-h-96 opacity-100"
80
+ leave-active-class="transition-all duration-150 ease-in"
81
+ leave-from-class="max-h-96 opacity-100"
82
+ leave-to-class="max-h-0 opacity-0"
83
+ >
84
+ <div v-if="visible" class="overflow-hidden border-b">
85
+ <div class="space-y-4 px-6 py-4">
86
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
87
+ <template v-for="filter in filters" :key="filter.field">
88
+ <!-- Text filter -->
89
+ <div v-if="filter.type === 'text'" class="space-y-1.5">
90
+ <Label class="text-xs font-medium text-muted-foreground">
91
+ {{ filter.label }}
92
+ </Label>
93
+ <Input
94
+ :model-value="getFieldState(filter.field) ?? ''"
95
+ :placeholder="filter.placeholder ?? `Filter by ${filter.label.toLowerCase()}...`"
96
+ class="h-9 text-sm"
97
+ @update:model-value="setFieldState(filter.field, $event)"
98
+ />
99
+ </div>
100
+
101
+ <!-- Select filter -->
102
+ <div v-else-if="filter.type === 'select'" class="space-y-1.5">
103
+ <Label class="text-xs font-medium text-muted-foreground">
104
+ {{ filter.label }}
105
+ </Label>
106
+ <div class="relative">
107
+ <Select
108
+ :model-value="(getFieldState(filter.field) as string) ?? ''"
109
+ @update:model-value="setFieldState(filter.field, $event)"
110
+ >
111
+ <SelectTrigger class="h-9 pr-8 text-sm">
112
+ <SelectValue :placeholder="filter.placeholder ?? 'All'" />
113
+ </SelectTrigger>
114
+ <SelectContent>
115
+ <SelectItem
116
+ v-for="opt in filter.options"
117
+ :key="String(opt.value)"
118
+ :value="String(opt.value)"
119
+ >
120
+ {{ opt.label }}
121
+ </SelectItem>
122
+ </SelectContent>
123
+ </Select>
124
+ <button
125
+ v-if="getFieldState(filter.field)"
126
+ class="absolute right-8 top-1/2 z-10 -translate-y-1/2 text-muted-foreground hover:text-foreground"
127
+ @click="setFieldState(filter.field, '')"
128
+ >
129
+ <X class="h-3.5 w-3.5" />
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Number range filter -->
135
+ <div v-else-if="filter.type === 'number'" class="space-y-1.5">
136
+ <Label class="text-xs font-medium text-muted-foreground">
137
+ {{ filter.label }}
138
+ </Label>
139
+ <div class="flex items-center gap-2">
140
+ <Input
141
+ type="number"
142
+ :model-value="getNumberValue(filter.field, 'min')"
143
+ :placeholder="'Min'"
144
+ class="h-9 text-sm"
145
+ @update:model-value="setFieldSubState(filter.field, 'min', $event)"
146
+ />
147
+ <span class="text-xs text-muted-foreground">–</span>
148
+ <Input
149
+ type="number"
150
+ :model-value="getNumberValue(filter.field, 'max')"
151
+ :placeholder="'Max'"
152
+ class="h-9 text-sm"
153
+ @update:model-value="setFieldSubState(filter.field, 'max', $event)"
154
+ />
155
+ </div>
156
+ </div>
157
+
158
+ <!-- Date range filter -->
159
+ <div v-else-if="filter.type === 'date'" class="space-y-1.5">
160
+ <Label class="text-xs font-medium text-muted-foreground">
161
+ {{ filter.label }}
162
+ </Label>
163
+ <div class="flex items-center gap-2">
164
+ <Input
165
+ type="date"
166
+ :model-value="getDateValue(filter.field, 'from')"
167
+ class="h-9 text-sm"
168
+ @update:model-value="setFieldSubState(filter.field, 'from', $event)"
169
+ />
170
+ <span class="text-xs text-muted-foreground">–</span>
171
+ <Input
172
+ type="date"
173
+ :model-value="getDateValue(filter.field, 'to')"
174
+ class="h-9 text-sm"
175
+ @update:model-value="setFieldSubState(filter.field, 'to', $event)"
176
+ />
177
+ </div>
178
+ </div>
179
+ </template>
180
+ </div>
181
+
182
+ <Separator />
183
+
184
+ <div class="flex items-center justify-end gap-2">
185
+ <Button variant="outline" size="sm" @click="onReset">
186
+ <RotateCw class="mr-1.5 h-3.5 w-3.5" />
187
+ Reset
188
+ </Button>
189
+ <Button size="sm" @click="onApply"> Apply Filters </Button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </Transition>
194
+ </template>
@@ -0,0 +1,232 @@
1
+ <script setup lang="ts">
2
+ import InputError from './InputError.vue';
3
+ import { Button } from './ui/button';
4
+ import { Card, CardContent, CardHeader, CardDescription, CardTitle } from './ui/card';
5
+ import { Checkbox } from './ui/checkbox';
6
+ import { Input } from './ui/input';
7
+ import { Label } from './ui/label';
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from './ui/select';
15
+ import { Spinner } from './ui/spinner';
16
+ import { ComboboxInput } from './ui/combobox';
17
+ import type { CrudFormField } from '../types/crud';
18
+ import { Link, useForm, router } from '@inertiajs/vue3';
19
+ import { ArrowLeft, Save } from 'lucide-vue-next';
20
+ import { computed } from 'vue';
21
+
22
+ const props = defineProps<{
23
+ title: string;
24
+ description?: string;
25
+ fields: CrudFormField[];
26
+ action: string;
27
+ method: 'post' | 'put' | 'patch';
28
+ initialData: Record<string, unknown>;
29
+ backRoute: string;
30
+ submitLabel?: string;
31
+ }>();
32
+
33
+ const form = useForm(props.initialData);
34
+
35
+ function submit() {
36
+ const methodFn = props.method === 'put' ? form.put : props.method === 'patch' ? form.patch : form.post;
37
+ methodFn.call(form, props.action);
38
+ }
39
+
40
+ const gridCols = computed(() =>
41
+ props.fields.some((f) => f.span === 2) ? 'lg:grid-cols-2' : 'lg:grid-cols-2',
42
+ );
43
+
44
+ function hasValue(name: string): boolean {
45
+ const val = form[name as keyof typeof form];
46
+ return val !== undefined && val !== null && val !== '';
47
+ }
48
+
49
+ function getFieldValue(name: string): unknown {
50
+ return form[name as keyof typeof form];
51
+ }
52
+
53
+ function setFieldValue(name: string, value: unknown) {
54
+ (form as Record<string, unknown>)[name] = value;
55
+ }
56
+
57
+ function getSelectedOption(name: string, options?: { label: string; value: string | number }[]): { label: string; value: string | number } | undefined {
58
+ const val = getFieldValue(name);
59
+ if (val === undefined || val === null || val === '') return undefined;
60
+ return options?.find((opt) => String(opt.value) === String(val));
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <div class="mx-auto w-full max-w-4xl px-4 py-6 sm:px-6 lg:px-8">
66
+ <Card class="overflow-hidden border shadow-sm">
67
+ <CardHeader class="border-b px-6 py-5">
68
+ <div class="flex items-center justify-between">
69
+ <div>
70
+ <CardTitle class="text-xl font-bold">{{ title }}</CardTitle>
71
+ <CardDescription v-if="description" class="mt-1">
72
+ {{ description }}
73
+ </CardDescription>
74
+ </div>
75
+ <Button variant="outline" size="sm" @click="router.get(backRoute)">
76
+ <ArrowLeft class="mr-1.5 h-4 w-4" />
77
+ Back
78
+ </Button>
79
+ </div>
80
+ </CardHeader>
81
+
82
+ <CardContent class="px-6 py-6">
83
+ <form @submit.prevent="submit">
84
+ <div class="grid grid-cols-1 gap-6" :class="gridCols">
85
+ <template v-for="(field, idx) in fields" :key="field.name">
86
+ <slot :name="`field-${field.name}`" :field="field" :form="form">
87
+ <!-- Text / Email / Tel / Number / Password / Date -->
88
+ <div
89
+ v-if="['text', 'email', 'tel', 'number', 'password', 'date'].includes(field.type)"
90
+ class="grid gap-2"
91
+ :class="field.span === 2 ? 'lg:col-span-2' : ''"
92
+ >
93
+ <Label :for="field.name" class="text-sm font-medium">
94
+ {{ field.label }}
95
+ <span v-if="field.required" class="text-destructive">*</span>
96
+ </Label>
97
+ <Input
98
+ :id="field.name"
99
+ :type="field.type"
100
+ :model-value="getFieldValue(field.name)"
101
+ :placeholder="field.placeholder"
102
+ :required="field.required"
103
+ :disabled="field.disabled || form.processing"
104
+ :tabindex="field.tabindex ?? idx + 1"
105
+ class="h-10"
106
+ @update:model-value="setFieldValue(field.name, $event)"
107
+ />
108
+ <InputError :message="(form.errors as Record<string, string>)[field.name]" />
109
+ </div>
110
+
111
+ <!-- Select -->
112
+ <div
113
+ v-else-if="field.type === 'select'"
114
+ class="grid gap-2"
115
+ :class="field.span === 2 ? 'lg:col-span-2' : ''"
116
+ >
117
+ <Label :for="field.name" class="text-sm font-medium">
118
+ {{ field.label }}
119
+ <span v-if="field.required" class="text-destructive">*</span>
120
+ </Label>
121
+ <Select
122
+ :model-value="getFieldValue(field.name)"
123
+ :disabled="field.disabled || form.processing"
124
+ @update:model-value="setFieldValue(field.name, $event)"
125
+ >
126
+ <SelectTrigger :id="field.name" class="h-10">
127
+ <SelectValue :placeholder="field.placeholder ?? 'Select...'" />
128
+ </SelectTrigger>
129
+ <SelectContent>
130
+ <SelectItem
131
+ v-for="opt in field.options"
132
+ :key="String(opt.value)"
133
+ :value="opt.value"
134
+ >
135
+ {{ opt.label }}
136
+ </SelectItem>
137
+ </SelectContent>
138
+ </Select>
139
+ <InputError :message="(form.errors as Record<string, string>)[field.name]" />
140
+ </div>
141
+
142
+ <!-- Combobox (searchable select) -->
143
+ <div
144
+ v-else-if="field.type === 'combobox'"
145
+ class="grid gap-2"
146
+ :class="field.span === 2 ? 'lg:col-span-2' : ''"
147
+ >
148
+ <Label :for="field.name" class="text-sm font-medium">
149
+ {{ field.label }}
150
+ <span v-if="field.required" class="text-destructive">*</span>
151
+ </Label>
152
+ <ComboboxInput
153
+ :model-value="getSelectedOption(field.name, field.options)"
154
+ :options="field.options ?? []"
155
+ :placeholder="field.placeholder ?? 'Search...'"
156
+ :disabled="field.disabled || form.processing"
157
+ @update:model-value="(val: unknown) => setFieldValue(field.name, (val as any)?.value ?? '')"
158
+ />
159
+ <InputError :message="(form.errors as Record<string, string>)[field.name]" />
160
+ </div>
161
+
162
+ <!-- Textarea -->
163
+ <div
164
+ v-else-if="field.type === 'textarea'"
165
+ class="grid gap-2"
166
+ :class="field.span === 2 ? 'lg:col-span-2' : ''"
167
+ >
168
+ <Label :for="field.name" class="text-sm font-medium">
169
+ {{ field.label }}
170
+ <span v-if="field.required" class="text-destructive">*</span>
171
+ </Label>
172
+ <textarea
173
+ :id="field.name"
174
+ :value="getFieldValue(field.name)"
175
+ :placeholder="field.placeholder"
176
+ :required="field.required"
177
+ :disabled="field.disabled || form.processing"
178
+ :tabindex="field.tabindex ?? idx + 1"
179
+ rows="3"
180
+ class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex min-h-[80px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
181
+ @input="setFieldValue(field.name, ($event.target as HTMLTextAreaElement).value)"
182
+ />
183
+ <InputError :message="(form.errors as Record<string, string>)[field.name]" />
184
+ </div>
185
+
186
+ <!-- Switch / Boolean -->
187
+ <div
188
+ v-else-if="field.type === 'switch'"
189
+ class="flex items-center gap-3"
190
+ :class="field.span === 2 ? 'lg:col-span-2' : ''"
191
+ >
192
+ <Checkbox
193
+ :id="field.name"
194
+ :model-value="!!getFieldValue(field.name)"
195
+ :disabled="field.disabled || form.processing"
196
+ @update:model-value="setFieldValue(field.name, $event)"
197
+ />
198
+ <Label :for="field.name" class="text-sm font-medium cursor-pointer">
199
+ {{ field.label }}
200
+ </Label>
201
+ <InputError :message="(form.errors as Record<string, string>)[field.name]" />
202
+ </div>
203
+ </slot>
204
+ </template>
205
+ </div>
206
+
207
+ <!-- Submit buttons -->
208
+ <div class="mt-8 flex flex-row-reverse flex-wrap gap-3">
209
+ <Button
210
+ type="submit"
211
+ class="h-11 flex-1 sm:flex-none sm:min-w-[160px]"
212
+ :disabled="form.processing"
213
+ >
214
+ <Spinner v-if="form.processing" class="mr-2" />
215
+ <Save v-else class="mr-1.5 h-4 w-4" />
216
+ {{ submitLabel ?? 'Save' }}
217
+ </Button>
218
+
219
+ <Button
220
+ variant="outline"
221
+ class="h-11 flex-1 sm:flex-none"
222
+ :disabled="form.processing"
223
+ @click="router.get(backRoute)"
224
+ >
225
+ Cancel
226
+ </Button>
227
+ </div>
228
+ </form>
229
+ </CardContent>
230
+ </Card>
231
+ </div>
232
+ </template>