@falcondev-oss/nuxt-layers-base 0.38.1 → 0.39.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.
@@ -1,3 +1,7 @@
1
1
  @utility z-full {
2
2
  z-index: 2147483647;
3
3
  }
4
+
5
+ @utility no-scrollbar {
6
+ @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden;
7
+ }
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts" generic="const O extends RadioGroupItem[], R extends ConfirmModalResult<O>">
2
+ import type { ButtonProps, RadioGroupItem, RadioGroupProps } from '@nuxt/ui'
3
+ import type { ComponentPublicInstance } from 'vue'
4
+ import type { ConfirmModalResult } from '~/composables/confirm'
5
+ import type { templateParts } from '~/utils/display'
6
+
7
+ export type ConfirmModalProps<O extends RadioGroupItem[]> = {
8
+ title: string
9
+ confirmLabel: string
10
+ confirmDisabled?: () => boolean
11
+ text?: string | ReturnType<typeof templateParts>
12
+ slotRef?: ComponentPublicInstance
13
+ options?: O
14
+ color?: ButtonProps['color']
15
+ }
16
+
17
+ defineProps<ConfirmModalProps<O>>()
18
+
19
+ const emit = defineEmits<{
20
+ close: [result: R]
21
+ }>()
22
+
23
+ const selectedOption = ref<RadioGroupProps<O>['modelValue']>()
24
+ </script>
25
+
26
+ <template>
27
+ <UModal
28
+ :title
29
+ :close="{ onClick: () => emit('close', false as R) }"
30
+ :dismissible="false"
31
+ :ui="{ header: 'border-b-0!', body: 'pt-0!' }"
32
+ >
33
+ <template #body>
34
+ <div class="flex flex-col gap-4">
35
+ <p v-if="text && typeof text === 'string'">{{ text }}</p>
36
+ <TemplateText v-else-if="text && typeof text === 'object'" :text />
37
+
38
+ <SlotRef :slot-ref />
39
+
40
+ <URadioGroup
41
+ v-model="selectedOption"
42
+ :items="options"
43
+ variant="table"
44
+ :color="color ?? 'error'"
45
+ />
46
+ </div>
47
+ </template>
48
+ <template #footer>
49
+ <div class="flex w-full justify-end gap-2">
50
+ <UButton
51
+ color="neutral"
52
+ variant="soft"
53
+ label="Abbrechen"
54
+ @click="() => emit('close', false as R)"
55
+ />
56
+ <UButton
57
+ :color="color ?? 'error'"
58
+ :label="confirmLabel"
59
+ :disabled="(!!options?.length && selectedOption === undefined) || confirmDisabled?.()"
60
+ @click="() => emit('close', (selectedOption ?? true) as R)"
61
+ />
62
+ </div>
63
+ </template>
64
+ </UModal>
65
+ </template>
@@ -0,0 +1,109 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ Schema extends FormSchema,
6
+ QueryData extends NonNullable<FormSourceValues<Schema>>,
7
+ Select extends QueryData = QueryData,
8
+ Id extends string | null = string | null
9
+ "
10
+ >
11
+ import type { FormSchema, FormSourceValues, FormSubmitValues } from '@falcondev-oss/form-core'
12
+ import type { DefaultError, UseQueryOptions } from '@tanstack/vue-query'
13
+ import type { If, IsNever } from 'type-fest'
14
+ import { useForm } from '@falcondev-oss/form-vue'
15
+ import { useQuery } from '@tanstack/vue-query'
16
+ import modalTheme from '#build/ui/modal'
17
+
18
+ const props = defineProps<{
19
+ id: Id
20
+ name: string
21
+ schema: Schema
22
+ getQueryOptions: (
23
+ id: Id,
24
+ ) => UseQueryOptions<If<IsNever<Select>, QueryData, any>, DefaultError, Select>
25
+ newValues: NonNullable<FormSourceValues<Schema>>
26
+ mutate: (values: FormSubmitValues<Schema> & { id: Id }) => Promise<unknown>
27
+ }>()
28
+
29
+ const emit = defineEmits<{
30
+ close: []
31
+ }>()
32
+
33
+ defineSlots<{
34
+ default: (props: { form: ReturnType<typeof useForm<Schema>>; data: typeof data.value }) => any
35
+ }>()
36
+
37
+ const toast = useToast()
38
+ const appConfig = useAppConfig()
39
+
40
+ const { data, isLoading } = useQuery(computed(() => props.getQueryOptions(props.id)))
41
+
42
+ const form = useForm({
43
+ schema: props.schema,
44
+ sourceValues: () => {
45
+ if (isLoading.value) return
46
+
47
+ return data.value ?? props.newValues
48
+ },
49
+ async submit({ values }) {
50
+ await props.mutate({
51
+ ...values,
52
+ id: props.id,
53
+ })
54
+
55
+ emit('close')
56
+ toast.add({
57
+ title: `${props.name} ${props.id ? 'aktualisiert' : 'erstellt'}`,
58
+ color: 'success',
59
+ icon: 'lucide:check',
60
+ })
61
+ },
62
+ })
63
+
64
+ const formActionsTeleportId = useId()
65
+ const closeHandle = ref<(() => void) | null>(null)
66
+
67
+ // const { tryLeave, onLeave } = usePreventLeave()
68
+ // onLeave(() => {
69
+ // console.warn('leave')
70
+ // closeHandle.value?.()
71
+ // })
72
+ </script>
73
+
74
+ <template>
75
+ <UModal :close="false" :title="`${name} ${id ? 'bearbeiten' : 'erstellen'}`" :dismissible="false">
76
+ <template #actions>
77
+ <UButton
78
+ :class="modalTheme.slots.close"
79
+ :icon="appConfig.ui.icons.close"
80
+ color="neutral"
81
+ variant="ghost"
82
+ aria-label="Schließen"
83
+ loading-auto
84
+ @click="
85
+ async () => {
86
+ // if (!(await tryLeave())) return
87
+ closeHandle?.()
88
+ }
89
+ "
90
+ />
91
+ </template>
92
+ <template #body="{ close }">
93
+ <Sync v-model:output="closeHandle" :input="close" />
94
+ <UForm
95
+ class="flex flex-col gap-2"
96
+ :form
97
+ :submit-label="id ? 'Speichern' : 'Erstellen'"
98
+ :actions-teleport-to="`#${formActionsTeleportId}`"
99
+ >
100
+ <slot :form :data />
101
+ </UForm>
102
+ </template>
103
+ <template #footer>
104
+ <div :id="formActionsTeleportId" class="flex w-full justify-end gap-2">
105
+ <UButton color="neutral" variant="soft" label="Abbrechen" @click="() => emit('close')" />
106
+ </div>
107
+ </template>
108
+ </UModal>
109
+ </template>
@@ -0,0 +1,51 @@
1
+ <script lang="ts" setup>
2
+ const props = defineProps<{
3
+ title: string
4
+ icon?: string
5
+ loading?: boolean
6
+ alwaysExpanded?: boolean
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ toggle: [value: boolean, controller: AbortController]
11
+ }>()
12
+
13
+ defineSlots<{
14
+ default: () => any
15
+ }>()
16
+
17
+ const model = defineModel<boolean>({ required: true })
18
+
19
+ const expanded = computed(() => props.alwaysExpanded || model.value)
20
+
21
+ function onToggle(value: boolean) {
22
+ const controller = new AbortController()
23
+ emit('toggle', value, controller)
24
+ if (controller.signal.aborted) return
25
+
26
+ model.value = value
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <UCard
32
+ class="m-px"
33
+ variant="subtle"
34
+ :ui="{
35
+ header: `text-sm font-semibold py-2 px-3! ${expanded ? '' : 'border-b-0'}`,
36
+ body: `p-3! ${expanded ? '' : 'hidden'}`,
37
+ }"
38
+ >
39
+ <template #header>
40
+ <div class="flex items-center justify-between gap-8">
41
+ <span class="flex items-center gap-2">
42
+ <UIcon v-if="icon" :name="icon" class="text-muted size-5" />
43
+ {{ title }}
44
+ </span>
45
+ <USwitch :model-value="model" :loading @update:model-value="(value) => onToggle(value)" />
46
+ </div>
47
+ </template>
48
+
49
+ <slot />
50
+ </UCard>
51
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+
4
+ defineProps<{
5
+ slotRef?: ComponentPublicInstance
6
+ }>()
7
+
8
+ defineSlots<{
9
+ default: () => any
10
+ }>()
11
+ </script>
12
+
13
+ <template>
14
+ <component :is="slotRef.$slots.default" v-if="slotRef" />
15
+ <!-- eslint-disable-next-line vue/no-lone-template -->
16
+ <template v-else />
17
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import type { templateParts } from '~/utils/display'
3
+
4
+ defineProps<{
5
+ text: ReturnType<typeof templateParts>
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <p>
11
+ <span v-for="(string, index) in text.strings" :key="index">
12
+ <span>{{ string }}</span>
13
+ <strong v-if="text.values[index]" class="font-semibold">
14
+ <UBadge variant="soft" color="neutral" size="lg" class="h-6! p-1! font-semibold">
15
+ {{ text.values[index] }}
16
+ </UBadge>
17
+ </strong>
18
+ </span>
19
+ </p>
20
+ </template>
@@ -0,0 +1,22 @@
1
+ import type { RadioGroupItem } from '@nuxt/ui'
2
+ import type { ConfirmModalProps } from '~/components/modals/ConfirmModal.vue'
3
+ import { LazyConfirmModal } from '#components'
4
+
5
+ export type ConfirmModalSuccess<O extends RadioGroupItem[]> = 0 extends O['length']
6
+ ? true
7
+ : {
8
+ [K in keyof O]: O[K] extends { value: infer V } ? V : never
9
+ }[number]
10
+
11
+ export type ConfirmModalResult<O extends RadioGroupItem[]> = false | ConfirmModalSuccess<O>
12
+
13
+ export const useConfirm = createGlobalState(() => {
14
+ const overlay = useOverlay()
15
+
16
+ return async <const O extends RadioGroupItem[]>(props: ConfirmModalProps<O>) =>
17
+ overlay
18
+ .create(LazyConfirmModal, {
19
+ destroyOnClose: true,
20
+ })
21
+ .open(props).result as Promise<ConfirmModalResult<O>>
22
+ })
@@ -1,5 +1,5 @@
1
1
  import { onBeforeRouteLeave } from '#app'
2
- import { useConfirm } from './useConfirm'
2
+ import { useConfirm } from './confirm'
3
3
 
4
4
  export function usePreventPageLeave(
5
5
  preventPageLeave: MaybeRefOrGetter<boolean> = true,
@@ -10,6 +10,7 @@ export function usePreventPageLeave(
10
10
  ) {
11
11
  useEventListener('beforeunload', (event) => {
12
12
  if (!toValue(preventPageLeave)) return
13
+ // eslint-disable-next-line ts/no-unsafe-member-access
13
14
  event.returnValue = opts?.leaveDescription
14
15
  return opts?.leaveDescription
15
16
  })
@@ -19,11 +20,11 @@ export function usePreventPageLeave(
19
20
  onBeforeRouteLeave(async (_, __, next) => {
20
21
  if (!toValue(preventPageLeave)) return next()
21
22
 
22
- const allowLeave = await confirm.confirmDestructive({
23
+ const allowLeave = await confirm({
23
24
  title: opts?.leaveTitle || 'Ungespeicherte Änderungen',
24
- description:
25
+ text:
25
26
  opts?.leaveDescription || 'Es gibt ungespeicherte Änderungen. Seite trotzdem verlassen?',
26
- submitLabel: 'Verlassen',
27
+ confirmLabel: 'Verlassen',
27
28
  })
28
29
  if (allowLeave) return next()
29
30
  })
package/app/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module 'vue' {
2
+ interface ComponentCustomProps {
3
+ 'aria-label'?: string
4
+ }
5
+ }
6
+
7
+ export {}
@@ -1,6 +1,20 @@
1
- export function formatEurosAmount(amount: number) {
1
+ export function formatEuros(amount: number) {
2
2
  return new Intl.NumberFormat('de-DE', {
3
3
  style: 'currency',
4
4
  currency: 'EUR',
5
5
  }).format(amount)
6
6
  }
7
+
8
+ export function pluralize(count: number, singular: string, plural: string) {
9
+ return count === 1 ? singular : plural
10
+ }
11
+
12
+ export function templateParts(
13
+ strings: TemplateStringsArray,
14
+ ...values: unknown[]
15
+ ): { strings: string[]; values: string[] } {
16
+ return {
17
+ strings: [...strings],
18
+ values: values.map(String),
19
+ }
20
+ }
package/nuxt.config.ts CHANGED
@@ -43,6 +43,13 @@ export default defineNuxtConfig({
43
43
  strict: true,
44
44
  },
45
45
  },
46
+ components: [
47
+ {
48
+ // layer configs resolve `~` against the consuming project, so use absolute paths
49
+ path: path.join(currentDir, './app/components'),
50
+ pathPrefix: false,
51
+ },
52
+ ],
46
53
  imports: {
47
54
  dirs: [
48
55
  // layer configs resolve `~` against the consuming project, so use absolute paths
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falcondev-oss/nuxt-layers-base",
3
3
  "type": "module",
4
- "version": "0.38.1",
4
+ "version": "0.39.0",
5
5
  "description": "Nuxt layer with lots of useful helpers and @nuxt/ui components",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,16 +0,0 @@
1
- import { OverlayModalConfirm } from '#components'
2
-
3
- export const useConfirm = createGlobalState(() => {
4
- const overlay = useOverlay()
5
-
6
- return {
7
- confirmDestructive: async (props: {
8
- description: string
9
- submitLabel: string
10
- title: string
11
- }) => {
12
- const modal = overlay.create(OverlayModalConfirm)
13
- return modal.open(props).result as Promise<boolean>
14
- },
15
- }
16
- })
File without changes
File without changes