@falcondev-oss/nuxt-layers-base 0.40.3 → 0.41.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 (62) hide show
  1. package/app/app.tsx +14 -0
  2. package/app/assets/css/base.css +6 -0
  3. package/app/components/layout/LayoutAuth.tsx +22 -0
  4. package/app/components/layout/LayoutNavbar.tsx +193 -0
  5. package/app/components/layout/LayoutPage.tsx +183 -0
  6. package/app/components/layout/LayoutSidebar.tsx +167 -0
  7. package/app/components/modals/ConfirmModal.tsx +80 -0
  8. package/app/components/modals/EntityCrudModal.tsx +124 -0
  9. package/app/components/overlay/modal/ModalActions.tsx +36 -0
  10. package/app/components/overlay/modal/ModalConfirm.tsx +46 -0
  11. package/app/components/overlay/modal/ModalRadioGroup.tsx +62 -0
  12. package/app/components/u/UActions.tsx +30 -0
  13. package/app/components/u/UAuthForm.tsx +30 -0
  14. package/app/components/u/UCustomApp.tsx +33 -0
  15. package/app/components/u/UField.tsx +211 -0
  16. package/app/components/u/UForm.tsx +133 -0
  17. package/app/components/u/UImageWithFallback.tsx +47 -0
  18. package/app/components/u/UInputDatePicker.tsx +94 -0
  19. package/app/components/u/UInputDateRangePicker.tsx +143 -0
  20. package/app/components/u/UInputDurationMinutes.tsx +106 -0
  21. package/app/components/u/UInputFile.tsx +132 -0
  22. package/app/components/u/URibbonCard.tsx +54 -0
  23. package/app/components/u/URibbonSection.tsx +36 -0
  24. package/app/components/u/USwitchCard.tsx +64 -0
  25. package/app/components/u/UToggleButton.tsx +46 -0
  26. package/app/components/util/Define.tsx +21 -0
  27. package/app/components/util/SlotRef.tsx +21 -0
  28. package/app/components/util/Sync.tsx +37 -0
  29. package/app/components/util/TemplateText.tsx +31 -0
  30. package/app/composables/confirm.ts +1 -1
  31. package/app/error.tsx +24 -0
  32. package/app/utils/define-setup-component.ts +39 -5
  33. package/nuxt.config.ts +8 -0
  34. package/package.json +4 -3
  35. package/app/app.vue +0 -3
  36. package/app/components/layout/LayoutAuth.vue +0 -13
  37. package/app/components/layout/LayoutNavbar.vue +0 -140
  38. package/app/components/layout/LayoutPage.vue +0 -174
  39. package/app/components/layout/LayoutSidebar.vue +0 -117
  40. package/app/components/modals/ConfirmModal.vue +0 -65
  41. package/app/components/modals/EntityCrudModal.vue +0 -109
  42. package/app/components/overlay/modal/ModalActions.vue +0 -25
  43. package/app/components/overlay/modal/ModalConfirm.vue +0 -25
  44. package/app/components/overlay/modal/ModalRadioGroup.vue +0 -37
  45. package/app/components/u/UActions.vue +0 -23
  46. package/app/components/u/UAuthForm.vue +0 -36
  47. package/app/components/u/UCustomApp.vue +0 -23
  48. package/app/components/u/UField.vue +0 -191
  49. package/app/components/u/UForm.vue +0 -96
  50. package/app/components/u/UImageWithFallback.vue +0 -35
  51. package/app/components/u/UInputDatePicker.vue +0 -79
  52. package/app/components/u/UInputDateRangePicker.vue +0 -100
  53. package/app/components/u/UInputDurationMinutes.vue +0 -90
  54. package/app/components/u/UInputFile.vue +0 -127
  55. package/app/components/u/URibbonCard.vue +0 -43
  56. package/app/components/u/URibbonSection.vue +0 -29
  57. package/app/components/u/USwitchCard.vue +0 -51
  58. package/app/components/util/Define.vue +0 -13
  59. package/app/components/util/SlotRef.vue +0 -17
  60. package/app/components/util/Sync.vue +0 -24
  61. package/app/components/util/TemplateText.vue +0 -20
  62. package/app/error.vue +0 -15
@@ -0,0 +1,124 @@
1
+ import type { FormSchema, FormSourceValues, FormSubmitValues } from '@falcondev-oss/form-core'
2
+ import type { DefaultError, UseQueryOptions } from '@tanstack/vue-query'
3
+ import type { If, IsNever } from 'type-fest'
4
+ import type { VNode } from 'vue'
5
+ import { useForm } from '@falcondev-oss/form-vue'
6
+ import { useQuery } from '@tanstack/vue-query'
7
+ import modalTheme from '#build/ui/modal'
8
+ import { Sync, UButton, UForm, UModal } from '#components'
9
+
10
+ export default defineSetupComponent(
11
+ <
12
+ Schema extends FormSchema,
13
+ QueryData extends NonNullable<FormSourceValues<Schema>>,
14
+ Select extends QueryData = QueryData,
15
+ Id extends string | null = string | null,
16
+ >(_: {
17
+ props: {
18
+ id: Id
19
+ name: string
20
+ schema: Schema
21
+ getQueryOptions: (
22
+ id: Id,
23
+ ) => UseQueryOptions<If<IsNever<Select>, QueryData, any>, DefaultError, Select>
24
+ newValues: NonNullable<FormSourceValues<Schema>>
25
+ mutate: (values: FormSubmitValues<Schema> & { id: Id }) => Promise<unknown>
26
+ }
27
+ emits: {
28
+ close: () => void
29
+ }
30
+ slots: {
31
+ default: (props: {
32
+ form: ReturnType<typeof useForm<Schema>>
33
+ data: Select | undefined
34
+ }) => VNode[]
35
+ }
36
+ }) =>
37
+ options(_, {
38
+ name: 'EntityCrudModal',
39
+ props: ['id', 'name', 'schema', 'getQueryOptions', 'newValues', 'mutate'],
40
+ emits: ['close'],
41
+ setup: (props, { emit, slots }) => {
42
+ const toast = useToast()
43
+ const appConfig = useAppConfig()
44
+
45
+ const { data, isLoading } = useQuery(computed(() => props.getQueryOptions(props.id)))
46
+
47
+ const form = useForm({
48
+ schema: props.schema,
49
+ sourceValues: () => {
50
+ if (isLoading.value) return
51
+
52
+ return data.value ?? props.newValues
53
+ },
54
+ async submit({ values }) {
55
+ await props.mutate({
56
+ ...values,
57
+ id: props.id,
58
+ })
59
+
60
+ emit('close')
61
+ toast.add({
62
+ title: `${props.name} ${props.id ? 'aktualisiert' : 'erstellt'}`,
63
+ color: 'success',
64
+ icon: 'lucide:check',
65
+ })
66
+ },
67
+ })
68
+
69
+ const formActionsTeleportId = useId()
70
+ const closeHandle = ref<(() => void) | null>(null)
71
+
72
+ return () => (
73
+ <UModal
74
+ close={false}
75
+ title={`${props.name} ${props.id ? 'bearbeiten' : 'erstellen'}`}
76
+ dismissible={false}
77
+ v-slots={vSlots(UModal, {
78
+ actions: () => [
79
+ <UButton
80
+ class={modalTheme.slots.close}
81
+ icon={appConfig.ui.icons.close}
82
+ color="neutral"
83
+ variant="ghost"
84
+ aria-label="Schließen"
85
+ loadingAuto
86
+ onClick={() => {
87
+ closeHandle.value?.()
88
+ }}
89
+ />,
90
+ ],
91
+ body: ({ close }) => [
92
+ <Sync
93
+ input={close}
94
+ output={closeHandle.value}
95
+ onUpdate:output={(value: (() => void) | null) => {
96
+ closeHandle.value = value
97
+ }}
98
+ />,
99
+ <UForm
100
+ class="flex flex-col gap-2"
101
+ form={form}
102
+ submitLabel={props.id ? 'Speichern' : 'Erstellen'}
103
+ actionsTeleportTo={`#${formActionsTeleportId}`}
104
+ v-slots={{
105
+ default: () => slots.default?.({ form, data: data.value }) ?? [],
106
+ }}
107
+ />,
108
+ ],
109
+ footer: () => [
110
+ <div id={formActionsTeleportId} class="flex w-full justify-end gap-2">
111
+ <UButton
112
+ color="neutral"
113
+ variant="soft"
114
+ label="Abbrechen"
115
+ onClick={() => emit('close')}
116
+ />
117
+ </div>,
118
+ ],
119
+ })}
120
+ />
121
+ )
122
+ },
123
+ }),
124
+ )
@@ -0,0 +1,36 @@
1
+ import type { ButtonProps } from '@nuxt/ui'
2
+ import { UButton, UModal } from '#components'
3
+
4
+ export default defineSetupComponent(
5
+ (_: {
6
+ props: {
7
+ actions: ButtonProps[]
8
+ title: string
9
+ description?: string
10
+ }
11
+ }) =>
12
+ options(_, {
13
+ name: 'ModalActions',
14
+ props: ['actions', 'title', 'description'],
15
+ emits: [],
16
+ setup: (props) => () => (
17
+ <UModal
18
+ title={props.title}
19
+ description={props.description}
20
+ ui={{ content: 'max-w-fit', footer: 'flex flex-col gap-4' }}
21
+ v-slots={vSlots(UModal, {
22
+ footer: () =>
23
+ props.actions.map((action) => (
24
+ <UButton
25
+ key={action.label}
26
+ variant="subtle"
27
+ class="w-full justify-center"
28
+ size="xl"
29
+ {...action}
30
+ />
31
+ )),
32
+ })}
33
+ />
34
+ ),
35
+ }),
36
+ )
@@ -0,0 +1,46 @@
1
+ import { UButton, UModal } from '#components'
2
+
3
+ export default defineSetupComponent(
4
+ (_: {
5
+ props: {
6
+ title: string
7
+ description: string
8
+ submitLabel: string
9
+ }
10
+ emits: {
11
+ close: (confirmed: boolean) => void
12
+ }
13
+ }) =>
14
+ options(_, {
15
+ name: 'ModalConfirm',
16
+ props: ['title', 'description', 'submitLabel'],
17
+ emits: ['close'],
18
+ setup:
19
+ (props, { emit }) =>
20
+ () => (
21
+ <UModal
22
+ close={{ onClick: () => emit('close', false) }}
23
+ title={props.title}
24
+ description={props.description}
25
+ class="max-w-sm"
26
+ v-slots={vSlots(UModal, {
27
+ footer: () => [
28
+ <div class="flex w-full justify-end gap-2">
29
+ <UButton
30
+ variant="ghost"
31
+ color="neutral"
32
+ label="Abbrechen"
33
+ onClick={() => emit('close', false)}
34
+ />
35
+ <UButton
36
+ label={props.submitLabel}
37
+ color="error"
38
+ onClick={() => emit('close', true)}
39
+ />
40
+ </div>,
41
+ ],
42
+ })}
43
+ />
44
+ ),
45
+ }),
46
+ )
@@ -0,0 +1,62 @@
1
+ import type { GetItemValue, NestedItem, RadioGroupItem } from '#ui/types'
2
+ import { UButton, UModal, URadioGroup } from '#components'
3
+
4
+ export default defineSetupComponent(
5
+ <T extends Extract<RadioGroupItem, object>>(_: {
6
+ props: {
7
+ items: T[]
8
+ defaultValue: GetItemValue<T[], 'value', NestedItem<T>>
9
+ title: string
10
+ submitButtonLabel: string
11
+ description?: string
12
+ }
13
+ emits: {
14
+ close: (value: T['value'] | undefined) => void
15
+ }
16
+ }) =>
17
+ options(_, {
18
+ name: 'ModalRadioGroup',
19
+ props: ['items', 'defaultValue', 'title', 'submitButtonLabel', 'description'],
20
+ emits: ['close'],
21
+ setup: (props, { emit }) => {
22
+ // `RadioGroupItem['value']` is `any` in `@nuxt/ui`, so neither the picked value nor what
23
+ // the radio group reports back can be typed any tighter — hence the disables below
24
+ const selectedValue = ref<T['value'] | undefined>(props.defaultValue)
25
+
26
+ return () => (
27
+ <UModal
28
+ title={props.title}
29
+ description={props.description}
30
+ v-slots={vSlots(UModal, {
31
+ body: () => [
32
+ <div class="flex flex-col gap-4">
33
+ <URadioGroup
34
+ // eslint-disable-next-line ts/no-unsafe-assignment
35
+ modelValue={selectedValue.value}
36
+ onUpdate:modelValue={(value) => {
37
+ selectedValue.value = value
38
+ }}
39
+ variant="table"
40
+ items={props.items}
41
+ />
42
+ <div class="flex w-full justify-end gap-4">
43
+ <UButton
44
+ label="Abbrechen"
45
+ variant="ghost"
46
+ color="neutral"
47
+ onClick={() => emit('close', undefined)}
48
+ />
49
+ <UButton
50
+ label={props.submitButtonLabel}
51
+ // eslint-disable-next-line ts/no-unsafe-argument
52
+ onClick={() => emit('close', selectedValue.value)}
53
+ />
54
+ </div>
55
+ </div>,
56
+ ],
57
+ })}
58
+ />
59
+ )
60
+ },
61
+ }),
62
+ )
@@ -0,0 +1,30 @@
1
+ import type { ButtonProps } from '@nuxt/ui'
2
+ import { UButton } from '#components'
3
+
4
+ function getActionKey(action: ButtonProps): string | undefined {
5
+ return action.label || (action.icon as string | undefined)
6
+ }
7
+
8
+ export default defineSetupComponent(
9
+ (_: {
10
+ props: {
11
+ actions?: ButtonProps[]
12
+ defaults?: Partial<ButtonProps>
13
+ }
14
+ }) =>
15
+ options(_, {
16
+ name: 'UActions',
17
+ props: ['actions', 'defaults'],
18
+ emits: [],
19
+ setup: (props) => () =>
20
+ props.actions ? (
21
+ <div class="flex gap-4">
22
+ {props.actions.map((action) => (
23
+ <UButton key={getActionKey(action)} {...{ ...props.defaults, ...action }} />
24
+ ))}
25
+ </div>
26
+ ) : (
27
+ <div />
28
+ ),
29
+ }),
30
+ )
@@ -0,0 +1,30 @@
1
+ import type {
2
+ AuthFormField,
3
+ AuthFormProps,
4
+ AuthFormSlots,
5
+ FormSchema,
6
+ FormSubmitEvent,
7
+ InferInput,
8
+ InferOutput,
9
+ } from '@nuxt/ui'
10
+ import type { Reactive } from 'vue'
11
+ import NuxtUIAuthForm from '@nuxt/ui/components/AuthForm.vue'
12
+
13
+ export default defineSetupComponent(
14
+ <T extends FormSchema = FormSchema<object>, F extends AuthFormField = AuthFormField>(_: {
15
+ props: Omit<AuthFormProps<T, F>, 'onSubmit'> & {
16
+ onSubmit?: (event: FormSubmitEvent<InferOutput<T>>) => void | Promise<void>
17
+ }
18
+ // nothing is read here, so every prop reaches `NuxtUIAuthForm` as an inherited attribute
19
+ propKeys: never
20
+ slots: AuthFormSlots<Reactive<InferInput<T>>, F>
21
+ }) =>
22
+ options(_, {
23
+ name: 'UAuthForm',
24
+ props: [],
25
+ emits: [],
26
+ setup:
27
+ (_props, { slots }) =>
28
+ () => <NuxtUIAuthForm v-slots={slots} />,
29
+ }),
30
+ )
@@ -0,0 +1,33 @@
1
+ import type { AppProps } from '@nuxt/ui'
2
+ import { de } from '@nuxt/ui/locale'
3
+ import { useForwardProps } from 'reka-ui'
4
+ import { Suspense } from 'vue'
5
+ import { NuxtLayout, NuxtLoadingIndicator, NuxtPage, UApp } from '#components'
6
+
7
+ export default defineSetupComponent(
8
+ (_: {
9
+ props: {
10
+ app?: AppProps
11
+ }
12
+ }) =>
13
+ options(_, {
14
+ name: 'UCustomApp',
15
+ props: ['app'],
16
+ emits: [],
17
+ setup: (props) => {
18
+ const forwarded = useForwardProps(props)
19
+ const config = useRuntimeConfig()
20
+
21
+ return () => (
22
+ <UApp locale={de} {...forwarded.value.app} nonce={config.public.projectId}>
23
+ <NuxtLoadingIndicator color="var(--color-brand-secondary, var(--color-brand-primary))" />
24
+ <Suspense>
25
+ <NuxtLayout>
26
+ <NuxtPage />
27
+ </NuxtLayout>
28
+ </Suspense>
29
+ </UApp>
30
+ )
31
+ },
32
+ }),
33
+ )
@@ -0,0 +1,211 @@
1
+ import type { FormField } from '@falcondev-oss/form-core'
2
+ import type { FormFieldProps, FormFieldSlots } from '@nuxt/ui'
3
+ import type { VNode, WritableComputedRef } from 'vue'
4
+ import { useForwardProps } from 'reka-ui'
5
+ import * as R from 'remeda'
6
+ import { DevOnly, UFormField, UIcon, UPopover } from '#components'
7
+ import { mergeSlotClass } from '../../utils/ui'
8
+
9
+ type InputProps<T> = {
10
+ 'modelValue': T
11
+ 'onUpdate:modelValue': (value: T) => void
12
+ 'onBlur': () => void
13
+ 'disabled': boolean
14
+ 'loading': boolean
15
+ 'modelModifiers': { nullable: true }
16
+ 'placeholder'?: string
17
+ }
18
+
19
+ function renderErrors(errors: string[]) {
20
+ if (errors.length === 1) return <p>{errors.join('\n')}</p>
21
+
22
+ return (
23
+ <ul>
24
+ {errors.map((error, index) => (
25
+ <li key={error + index} class="list-inside">
26
+ {error}
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ )
31
+ }
32
+
33
+ export default defineSetupComponent(
34
+ <T,>(_: {
35
+ props: FormFieldProps & {
36
+ field: FormField<T>
37
+ errorInline?: boolean
38
+ errorPreferPlaceholder?: boolean
39
+ }
40
+ slots: {
41
+ default: (slot: {
42
+ bind: InputProps<T>
43
+ model: WritableComputedRef<T>
44
+ field: FormField<T>
45
+ }) => VNode[]
46
+ } & Omit<FormFieldSlots, 'default'>
47
+ }) =>
48
+ options(_, {
49
+ name: 'UField',
50
+ props: [
51
+ 'as',
52
+ 'name',
53
+ 'errorPattern',
54
+ 'label',
55
+ 'description',
56
+ 'help',
57
+ 'error',
58
+ 'hint',
59
+ 'size',
60
+ 'required',
61
+ 'eagerValidation',
62
+ 'validateOnInputDelay',
63
+ 'orientation',
64
+ 'class',
65
+ 'ui',
66
+ 'field',
67
+ 'errorInline',
68
+ 'errorPreferPlaceholder',
69
+ ],
70
+ emits: [],
71
+ setup: (props, { slots }) => {
72
+ const forwardedProps = useForwardProps(props)
73
+
74
+ const isOverMaxLength = computed(() => {
75
+ const field = forwardedProps.value.field
76
+
77
+ return field.schema.maxLength === undefined || field.value == null
78
+ ? false
79
+ : (field.value as string | number)?.toString().length > field.schema.maxLength
80
+ })
81
+
82
+ const formFieldProps = computed<FormFieldProps>(() => {
83
+ const { field, ...rest } = forwardedProps.value
84
+
85
+ const hint =
86
+ field.schema.maxLength === undefined ||
87
+ field.schema.maxLength === field.schema.minLength
88
+ ? undefined
89
+ : `${(field.value as string | number | null)?.toString().length ?? 0}/${field.schema.maxLength}`
90
+
91
+ return {
92
+ required: field.schema.required,
93
+ label: field.schema.title,
94
+ description: field.schema.description,
95
+ hint,
96
+ ...R.omitBy(rest, (v) => v === undefined),
97
+ }
98
+ })
99
+
100
+ const bind = computed(() => {
101
+ const field = forwardedProps.value.field
102
+
103
+ const placeholder = (field.errors && field.errors[0]) || field.schema.default?.toString()
104
+
105
+ return {
106
+ 'modelValue': field.value,
107
+ 'onUpdate:modelValue': (value) => field.handleChange(value),
108
+ 'onBlur': () => field.handleBlur(),
109
+ 'disabled': field.disabled,
110
+ 'loading': field.isPending,
111
+ placeholder,
112
+ 'modelModifiers': { nullable: true },
113
+ } satisfies InputProps<T>
114
+ })
115
+
116
+ const model = computed({
117
+ get() {
118
+ return forwardedProps.value.field.value
119
+ },
120
+ set(value: T) {
121
+ forwardedProps.value.field.handleChange(value)
122
+ },
123
+ })
124
+
125
+ return () => {
126
+ const field = props.field
127
+ /** the error is only worth showing where it can't be read off the placeholder */
128
+ const showErrors =
129
+ !!field.errors && (props.errorPreferPlaceholder ? String(field.value).length > 0 : true)
130
+
131
+ return (
132
+ <UFormField
133
+ {...formFieldProps.value}
134
+ ui={{
135
+ ...formFieldProps.value.ui,
136
+ hint: mergeSlotClass(
137
+ formFieldProps.value.ui?.hint,
138
+ isOverMaxLength.value ? 'text-error' : '',
139
+ ),
140
+ error: mergeSlotClass(formFieldProps.value.ui?.error, 'mt-1!'),
141
+ }}
142
+ error={!!field.errors}
143
+ class={['u-field', props.class]}
144
+ v-slots={vSlots(UFormField, {
145
+ default: () =>
146
+ slots.default?.({
147
+ bind: bind.value,
148
+ model,
149
+ field: forwardedProps.value.field,
150
+ }) ?? [
151
+ <DevOnly>
152
+ <p class="font-black text-red-500">UField missing slot</p>
153
+ </DevOnly>,
154
+ ],
155
+
156
+ ...(props.errorInline && showErrors
157
+ ? {
158
+ error: () => [
159
+ renderErrors(field.errors!),
160
+ ...(typeof props.error === 'string' ? [<>{props.error}</>] : []),
161
+ ],
162
+ }
163
+ : {
164
+ hint: ({ hint }) => [
165
+ <span class="flex items-center gap-1.5">
166
+ {showErrors ? (
167
+ <UPopover
168
+ mode="hover"
169
+ openDelay={0}
170
+ ui={{
171
+ content: 'bg-error-50 ring-error-200! rounded py-1 px-2',
172
+ }}
173
+ v-slots={vSlots(UPopover, {
174
+ content: () => [
175
+ <div class="max-w-sm text-xs text-(--ui-color-neutral-800)">
176
+ {renderErrors(field.errors!)}
177
+ </div>,
178
+ ],
179
+ })}
180
+ >
181
+ <UIcon name="lucide:circle-alert" class="text-error" />
182
+ </UPopover>
183
+ ) : null}
184
+
185
+ {hint}
186
+ </span>,
187
+ ],
188
+ }),
189
+
190
+ ...(field.schema.examples && {
191
+ help: () => [
192
+ Array.isArray(field.schema.examples) ? (
193
+ <ul>
194
+ {field.schema.examples.map((example, index) => (
195
+ <li key={index}>{example}</li>
196
+ ))}
197
+ </ul>
198
+ ) : (
199
+ <p>{field.schema.examples}</p>
200
+ ),
201
+ ],
202
+ }),
203
+
204
+ ...R.omit(slots, ['default']),
205
+ })}
206
+ />
207
+ )
208
+ }
209
+ },
210
+ }),
211
+ )