ketekny-ui-kit 1.0.38 → 1.0.39

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.38",
4
+ "version": "1.0.39",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -40,6 +40,9 @@
40
40
  "dependencies": {
41
41
  "@primeuix/themes": "^1.1.1",
42
42
  "@vuepic/vue-datepicker": "^11.0.2",
43
+ "class-variance-authority": "^0.7.1",
44
+ "clsx": "^2.1.1",
45
+ "element-plus": "^2.13.5",
43
46
  "he-tree-vue": "^3.1.2",
44
47
  "json-editor-vue": "^0.18.1",
45
48
  "lucide-vue-next": "^0.511.0",
@@ -49,6 +52,7 @@
49
52
  "quill": "^2.0.3",
50
53
  "reka-ui": "^2.8.2",
51
54
  "simple-code-editor": "^2.0.9",
55
+ "tailwind-merge": "^3.5.0",
52
56
  "vue-router": "^4.6.4",
53
57
  "vue3-easy-data-table": "^1.5.47"
54
58
  }
@@ -0,0 +1,6 @@
1
+ import { clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,124 @@
1
+ <template>
2
+ <el-button
3
+ :type="resolvedVisualType"
4
+ :native-type="resolvedNativeType"
5
+ :size="resolvedSize"
6
+ :plain="resolvedPlain"
7
+ :round="round"
8
+ :circle="resolvedCircle"
9
+ :loading="loading"
10
+ :disabled="disabled"
11
+ :icon="iconComponent"
12
+ :autofocus="autofocus"
13
+ :title="tooltip"
14
+ :aria-label="resolvedAriaLabel"
15
+ :class="buttonClass"
16
+ @click="$emit('click', $event)"
17
+ >
18
+ <slot v-if="!resolvedIconOnly">{{ label }}</slot>
19
+ </el-button>
20
+ </template>
21
+
22
+ <script setup>
23
+ import { computed, getCurrentInstance } from 'vue'
24
+ import { ElButton } from 'element-plus'
25
+ import * as lucideIcons from 'lucide-vue-next'
26
+
27
+ const STYLE_TYPES = ['default', 'primary', 'success', 'warning', 'danger', 'info']
28
+ const NATIVE_TYPES = ['button', 'submit', 'reset']
29
+
30
+ const props = defineProps({
31
+ label: { type: String, default: '' },
32
+ type: {
33
+ type: String,
34
+ default: 'default',
35
+ validator: (val) => ['default', 'primary', 'success', 'warning', 'danger', 'info', 'button', 'submit', 'reset'].includes(val),
36
+ },
37
+ size: {
38
+ type: String,
39
+ default: 'default',
40
+ validator: (val) => ['large', 'default', 'small', 'normal'].includes(val),
41
+ },
42
+ plain: { type: Boolean, default: false },
43
+ round: { type: Boolean, default: false },
44
+ circle: { type: Boolean, default: false },
45
+ loading: { type: Boolean, default: false },
46
+ disabled: { type: Boolean, default: false },
47
+ icon: { type: [String, Object, Function], default: null },
48
+ autofocus: { type: Boolean, default: false },
49
+ primary: { type: Boolean, default: false },
50
+ secondary: { type: Boolean, default: false },
51
+ outlined: { type: Boolean, default: false },
52
+ danger: { type: Boolean, default: false },
53
+ warning: { type: Boolean, default: false },
54
+ success: { type: Boolean, default: false },
55
+ ariaLabel: { type: String, default: null },
56
+ tooltip: { type: String, default: null },
57
+ variant: {
58
+ type: String,
59
+ default: null,
60
+ validator: (val) => val == null || ['solid', 'soft', 'outlined', 'plain'].includes(val),
61
+ },
62
+ small: { type: Boolean, default: false },
63
+ fullWidth: { type: Boolean, default: false },
64
+ iconOnly: { type: Boolean, default: false },
65
+ truncate: { type: Boolean, default: false },
66
+ })
67
+
68
+ defineEmits(['click'])
69
+
70
+ const instance = getCurrentInstance()
71
+ const passedProps = new Set(Object.keys(instance?.vnode.props || {}))
72
+ const hasProp = (...names) => names.some((name) => passedProps.has(name))
73
+
74
+ const resolvedVisualType = computed(() => {
75
+ if (props.danger) return 'danger'
76
+ if (props.success) return 'success'
77
+ if (props.warning) return 'warning'
78
+ if (props.secondary) return 'info'
79
+ if (props.primary) return 'primary'
80
+ return STYLE_TYPES.includes(props.type) ? props.type : 'default'
81
+ })
82
+
83
+ const resolvedNativeType = computed(() => {
84
+ return NATIVE_TYPES.includes(props.type) ? props.type : 'button'
85
+ })
86
+
87
+ const resolvedSize = computed(() => {
88
+ if (props.small) return 'small'
89
+ if (props.size === 'normal') return 'default'
90
+ return ['large', 'default', 'small'].includes(props.size) ? props.size : 'default'
91
+ })
92
+
93
+ const resolvedPlain = computed(() => {
94
+ if (props.outlined) return true
95
+ if (hasProp('variant')) return props.variant === 'outlined' || props.variant === 'plain'
96
+ return props.plain
97
+ })
98
+
99
+ const resolvedCircle = computed(() => {
100
+ if (hasProp('iconOnly', 'icon-only')) return props.iconOnly
101
+ return props.circle
102
+ })
103
+
104
+ const resolvedIconOnly = computed(() => props.iconOnly)
105
+
106
+ const iconComponent = computed(() => {
107
+ if (typeof props.icon === 'string') return lucideIcons[props.icon] || null
108
+ return props.icon
109
+ })
110
+
111
+ const resolvedAriaLabel = computed(() => {
112
+ if (props.ariaLabel) return props.ariaLabel
113
+ if (props.label && !resolvedIconOnly.value) return props.label
114
+ if (typeof props.icon === 'string') return props.icon.replace(/([a-z])([A-Z])/g, '$1 $2')
115
+ return null
116
+ })
117
+
118
+ const buttonClass = computed(() => {
119
+ return [
120
+ props.fullWidth ? '!w-full justify-center' : '',
121
+ props.truncate ? 'max-w-full [&>span]:max-w-full [&>span]:truncate [&>span]:inline-block' : '',
122
+ ]
123
+ })
124
+ </script>
@@ -0,0 +1,147 @@
1
+ <template>
2
+ <div>
3
+ <label v-if="label" class="block mb-1 text-sm font-medium text-slate-700 dark:text-slate-300">{{ label }}</label>
4
+ <el-date-picker
5
+ :id="id"
6
+ :model-value="normalizedModelValue"
7
+ :type="pickerType"
8
+ :placeholder="placeholder"
9
+ :disabled="disabled"
10
+ :readonly="readonly"
11
+ :clearable="clearable"
12
+ :editable="editable"
13
+ :size="size"
14
+ :format="resolvedFormat"
15
+ :value-format="resolvedValueFormat"
16
+ :disabled-date="disabledDate"
17
+ :start-placeholder="startPlaceholder"
18
+ :end-placeholder="endPlaceholder"
19
+ :range-separator="rangeSeparator"
20
+ class="k-date-v2"
21
+ style="width: 100%"
22
+ @update:model-value="$emit('update:modelValue', $event)"
23
+ @change="$emit('change', $event)"
24
+ />
25
+ <p v-if="hasError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ typeof error === 'string' ? error : '' }}</p>
26
+ <p v-else-if="info" class="mt-1 text-xs text-slate-500 dark:text-slate-400">{{ info }}</p>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup>
31
+ import { computed } from 'vue'
32
+ import { ElDatePicker } from 'element-plus'
33
+
34
+ const props = defineProps({
35
+ modelValue: { type: [String, Date, Array], default: null },
36
+ id: {
37
+ type: String,
38
+ default: () => `datepicker-${Math.random().toString(36).slice(2, 11)}`,
39
+ },
40
+ label: { type: String, default: '' },
41
+ type: {
42
+ type: String,
43
+ default: 'date',
44
+ validator: (val) => ['year', 'month', 'date', 'dates', 'datetime', 'week', 'datetimerange', 'daterange', 'monthrange', 'yearMonth'].includes(val),
45
+ },
46
+ placeholder: { type: String, default: 'Select date' },
47
+ startPlaceholder: { type: String, default: 'Start date' },
48
+ endPlaceholder: { type: String, default: 'End date' },
49
+ rangeSeparator: { type: String, default: 'To' },
50
+ disabled: { type: Boolean, default: false },
51
+ readonly: { type: Boolean, default: false },
52
+ clearable: { type: Boolean, default: true },
53
+ editable: { type: Boolean, default: true },
54
+ size: {
55
+ type: String,
56
+ default: 'default',
57
+ validator: (val) => ['large', 'default', 'small'].includes(val),
58
+ },
59
+ format: { type: String, default: '' },
60
+ valueFormat: { type: String, default: '' },
61
+ disabledDate: { type: Function, default: undefined },
62
+ error: { type: [String, Boolean], default: null },
63
+ info: { type: String, default: '' },
64
+ })
65
+
66
+ defineEmits(['update:modelValue', 'change'])
67
+
68
+ const pickerType = computed(() => props.type === 'yearMonth' ? 'month' : props.type)
69
+ const hasError = computed(() => props.error != null && props.error !== false)
70
+
71
+ const defaultFormatByType = {
72
+ date: 'YYYY-MM-DD',
73
+ dates: 'YYYY-MM-DD',
74
+ week: 'gggg[w]ww',
75
+ year: 'YYYY',
76
+ month: 'YYYY-MM',
77
+ datetime: 'YYYY-MM-DD HH:mm:ss',
78
+ monthrange: 'YYYY-MM',
79
+ daterange: 'YYYY-MM-DD',
80
+ datetimerange: 'YYYY-MM-DD HH:mm:ss',
81
+ }
82
+
83
+ const resolvedFormat = computed(() => {
84
+ const custom = String(props.format || '').trim()
85
+ if (custom) return custom
86
+ return defaultFormatByType[pickerType.value] || 'YYYY-MM-DD'
87
+ })
88
+
89
+ const resolvedValueFormat = computed(() => {
90
+ const custom = String(props.valueFormat || '').trim()
91
+ if (custom) return custom
92
+ return defaultFormatByType[pickerType.value] || 'YYYY-MM-DD'
93
+ })
94
+
95
+ function normalizeIsoString(value, type) {
96
+ if (typeof value !== 'string' || !value.includes('T')) return value
97
+
98
+ if (type === 'year') return value.slice(0, 4)
99
+ if (type === 'month' || type === 'monthrange') return value.slice(0, 7)
100
+
101
+ if (type === 'datetime' || type === 'datetimerange') {
102
+ const normalized = value.replace('T', ' ').replace(/Z$/, '')
103
+ const fullMatch = normalized.match(/^(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}:\d{2})/)
104
+ if (fullMatch) return `${fullMatch[1]} ${fullMatch[2]}`
105
+ const shortMatch = normalized.match(/^(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2})/)
106
+ if (shortMatch) return `${shortMatch[1]} ${shortMatch[2]}:00`
107
+ return normalized
108
+ }
109
+
110
+ const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2})/)
111
+ return dateMatch ? dateMatch[1] : value
112
+ }
113
+
114
+ const normalizedModelValue = computed(() => {
115
+ if (Array.isArray(props.modelValue)) {
116
+ return props.modelValue.map((item) => normalizeIsoString(item, pickerType.value))
117
+ }
118
+ return normalizeIsoString(props.modelValue, pickerType.value)
119
+ })
120
+ </script>
121
+
122
+ <style>
123
+ /* Global because Element Plus picker panel is teleported to body. */
124
+ html.dark .el-date-picker,
125
+ html.dark .el-date-range-picker {
126
+ --el-datepicker-inrange-bg-color: rgba(3, 105, 161, 0.22);
127
+ --el-datepicker-inrange-hover-bg-color: rgba(3, 105, 161, 0.32);
128
+ }
129
+
130
+ html.dark .el-date-table td.in-range .el-date-table-cell,
131
+ html.dark .el-month-table td.in-range .el-date-table-cell,
132
+ html.dark .el-year-table td.in-range .el-date-table-cell {
133
+ background-color: rgba(3, 105, 161, 0.22);
134
+ }
135
+
136
+ html.dark .el-date-table td.in-range .el-date-table-cell:hover,
137
+ html.dark .el-month-table td.in-range .el-date-table-cell:hover,
138
+ html.dark .el-year-table td.in-range .el-date-table-cell:hover {
139
+ background-color: rgba(3, 105, 161, 0.32);
140
+ }
141
+
142
+ html.dark .el-month-table td.available:hover .el-date-table-cell,
143
+ html.dark .el-year-table td.available:hover .el-date-table-cell {
144
+ background-color: rgba(148, 163, 184, 0.16);
145
+ border-radius: 24px;
146
+ }
147
+ </style>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <el-dialog
3
+ :model-value="resolvedOpen"
4
+ :title="title"
5
+ :width="width"
6
+ :fullscreen="resolvedFullscreen"
7
+ :modal="modal"
8
+ :append-to-body="appendToBody"
9
+ :close-on-click-modal="resolvedCloseOnBackdrop"
10
+ :close-on-press-escape="resolvedCloseOnEsc"
11
+ :show-close="showClose"
12
+ :draggable="draggable"
13
+ :center="center"
14
+ :align-center="alignCenter"
15
+ :destroy-on-close="destroyOnClose"
16
+ @update:model-value="emitModelUpdate"
17
+ @open="$emit('open')"
18
+ @opened="$emit('opened')"
19
+ @close="$emit('close')"
20
+ @closed="$emit('closed')"
21
+ >
22
+ <template v-if="$slots.header" #header>
23
+ <slot name="header"></slot>
24
+ </template>
25
+ <slot></slot>
26
+ <template v-if="$slots.footer" #footer>
27
+ <slot name="footer"></slot>
28
+ </template>
29
+ </el-dialog>
30
+ </template>
31
+
32
+ <script setup>
33
+ import { computed, getCurrentInstance } from 'vue'
34
+ import { ElDialog } from 'element-plus'
35
+
36
+ const props = defineProps({
37
+ modelValue: { type: Boolean, default: false },
38
+ visible: { type: Boolean, default: undefined },
39
+ title: { type: String, default: '' },
40
+ width: { type: [String, Number], default: '50%' },
41
+ fullscreen: { type: Boolean, default: false },
42
+ maximized: { type: Boolean, default: false },
43
+ modal: { type: Boolean, default: true },
44
+ appendToBody: { type: Boolean, default: false },
45
+ closeOnClickModal: { type: Boolean, default: true },
46
+ closeOnBackdrop: { type: Boolean, default: true },
47
+ closeOnPressEscape: { type: Boolean, default: true },
48
+ closeOnEsc: { type: Boolean, default: true },
49
+ showClose: { type: Boolean, default: true },
50
+ draggable: { type: Boolean, default: false },
51
+ center: { type: Boolean, default: false },
52
+ alignCenter: { type: Boolean, default: false },
53
+ destroyOnClose: { type: Boolean, default: false },
54
+ })
55
+
56
+ const emit = defineEmits(['update:modelValue', 'update:visible', 'open', 'opened', 'close', 'closed'])
57
+
58
+ const instance = getCurrentInstance()
59
+ const passedProps = new Set(Object.keys(instance?.vnode.props || {}))
60
+ const hasProp = (...names) => names.some((name) => passedProps.has(name))
61
+
62
+ const resolvedOpen = computed(() => (hasProp('visible') ? props.visible : props.modelValue))
63
+ const resolvedCloseOnBackdrop = computed(() => (hasProp('closeOnBackdrop', 'close-on-backdrop') ? props.closeOnBackdrop : props.closeOnClickModal))
64
+ const resolvedCloseOnEsc = computed(() => (hasProp('closeOnEsc', 'close-on-esc') ? props.closeOnEsc : props.closeOnPressEscape))
65
+ const resolvedFullscreen = computed(() => (hasProp('maximized') ? props.maximized : props.fullscreen))
66
+
67
+ function emitModelUpdate(value) {
68
+ emit('update:modelValue', value)
69
+ emit('update:visible', value)
70
+ }
71
+ </script>
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <DialogRoot :open="resolvedOpen" @update:open="emitModelUpdate">
3
+ <DialogTrigger v-if="$slots.trigger" as-child>
4
+ <slot name="trigger"></slot>
5
+ </DialogTrigger>
6
+
7
+ <DialogPortal>
8
+ <DialogOverlay class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-overlayIn data-[state=closed]:animate-overlayOut" />
9
+
10
+ <DialogContent
11
+ class="fixed z-50 bg-white dark:bg-slate-900 shadow-lg rounded-xl overflow-hidden flex flex-col
12
+ top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
13
+ w-[calc(100vw-2rem)] max-h-[calc(100dvh-2rem)]
14
+ data-[state=open]:animate-dialogIn data-[state=closed]:animate-dialogOut"
15
+ :class="resolvedWidthClass"
16
+ @escape-key-down="resolvedCloseOnEscape ? undefined : $event.preventDefault()"
17
+ @interact-outside="closeOnBackdrop ? undefined : $event.preventDefault()"
18
+ >
19
+ <!-- Header -->
20
+ <div class="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
21
+ <DialogTitle class="text-lg font-semibold text-slate-900 dark:text-slate-100">
22
+ {{ title }}
23
+ </DialogTitle>
24
+ <DialogClose
25
+ class="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
26
+ aria-label="Close"
27
+ >
28
+ <X class="w-4 h-4" />
29
+ </DialogClose>
30
+ </div>
31
+
32
+ <!-- Description (optional) -->
33
+ <DialogDescription v-if="description" class="px-4 pt-3 text-sm text-slate-500 dark:text-slate-400">
34
+ {{ description }}
35
+ </DialogDescription>
36
+
37
+ <!-- Body -->
38
+ <div class="flex-1 p-4 overflow-auto">
39
+ <slot></slot>
40
+ </div>
41
+
42
+ <!-- Footer -->
43
+ <div v-if="$slots.footer" class="flex justify-end gap-2 p-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
44
+ <slot name="footer"></slot>
45
+ </div>
46
+ </DialogContent>
47
+ </DialogPortal>
48
+ </DialogRoot>
49
+ </template>
50
+
51
+ <script setup>
52
+ import { computed, getCurrentInstance } from 'vue'
53
+ import {
54
+ DialogRoot,
55
+ DialogTrigger,
56
+ DialogPortal,
57
+ DialogOverlay,
58
+ DialogContent,
59
+ DialogTitle,
60
+ DialogDescription,
61
+ DialogClose,
62
+ } from 'reka-ui'
63
+ import { X } from 'lucide-vue-next'
64
+
65
+ const props = defineProps({
66
+ modelValue: { type: Boolean, default: false },
67
+ visible: { type: Boolean, default: undefined },
68
+ title: { type: String, default: '' },
69
+ description: { type: String, default: '' },
70
+ width: { type: String, default: 'md' },
71
+ maximized: { type: Boolean, default: false },
72
+ closeOnBackdrop: { type: Boolean, default: true },
73
+ closeOnEscape: { type: Boolean, default: true },
74
+ closeOnEsc: { type: Boolean, default: true },
75
+ })
76
+
77
+ const emit = defineEmits(['update:modelValue', 'update:visible'])
78
+
79
+ const WIDTH_CLASS_MAP = {
80
+ sm: 'sm:max-w-sm',
81
+ md: 'sm:max-w-lg',
82
+ lg: 'sm:max-w-2xl',
83
+ xl: 'sm:max-w-4xl',
84
+ full: 'sm:max-w-[calc(100vw-4rem)]',
85
+ }
86
+
87
+ const instance = getCurrentInstance()
88
+ const passedProps = new Set(Object.keys(instance?.vnode.props || {}))
89
+ const hasProp = (...names) => names.some((name) => passedProps.has(name))
90
+
91
+ const resolvedOpen = computed(() => (hasProp('visible') ? props.visible : props.modelValue))
92
+ const resolvedCloseOnEscape = computed(() => (hasProp('closeOnEsc', 'close-on-esc') ? props.closeOnEsc : props.closeOnEscape))
93
+
94
+ const resolvedWidthClass = computed(() => {
95
+ if (hasProp('maximized') && props.maximized) return WIDTH_CLASS_MAP.full
96
+ const value = String(props.width || 'md')
97
+ return WIDTH_CLASS_MAP[value] || value
98
+ })
99
+
100
+ function emitModelUpdate(value) {
101
+ emit('update:modelValue', value)
102
+ emit('update:visible', value)
103
+ }
104
+ </script>
105
+
106
+ <style scoped>
107
+ @keyframes overlayIn {
108
+ from { opacity: 0; }
109
+ to { opacity: 1; }
110
+ }
111
+ @keyframes overlayOut {
112
+ from { opacity: 1; }
113
+ to { opacity: 0; }
114
+ }
115
+ @keyframes dialogIn {
116
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.96); }
117
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
118
+ }
119
+ @keyframes dialogOut {
120
+ from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
121
+ to { opacity: 0; transform: translate(-50%, -50%) scale(0.96); }
122
+ }
123
+
124
+ .animate-overlayIn { animation: overlayIn 200ms ease-out; }
125
+ .animate-overlayOut { animation: overlayOut 150ms ease-in; }
126
+ .animate-dialogIn { animation: dialogIn 200ms ease-out; }
127
+ .animate-dialogOut { animation: dialogOut 150ms ease-in; }
128
+ </style>
@@ -0,0 +1,140 @@
1
+ <template>
2
+ <DialogRoot :open="resolvedOpen" @update:open="emitModelUpdate">
3
+ <DialogTrigger v-if="$slots.trigger" as-child>
4
+ <slot name="trigger"></slot>
5
+ </DialogTrigger>
6
+
7
+ <DialogPortal>
8
+ <DialogOverlay
9
+ :class="cn(
10
+ 'fixed inset-0 z-50 bg-black/80',
11
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
12
+ )"
13
+ />
14
+
15
+ <DialogContent
16
+ :class="cn(
17
+ 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
18
+ 'w-[96vw] max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-2rem)]',
19
+ 'max-h-[calc(100dvh-1rem)] sm:max-h-[90vh]',
20
+ 'border border-slate-200 bg-white shadow-lg rounded-2xl overflow-hidden flex flex-col',
21
+ 'dark:border-slate-800 dark:bg-slate-950',
22
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
23
+ resolvedWidthClass,
24
+ )"
25
+ @escape-key-down="resolvedCloseOnEscape ? undefined : $event.preventDefault()"
26
+ @interact-outside="closeOnBackdrop ? undefined : $event.preventDefault()"
27
+ >
28
+ <!-- Header -->
29
+ <div class="flex flex-col p-6 pb-4 space-y-1.5 shrink-0">
30
+ <DialogTitle :class="cn('text-lg font-semibold leading-none tracking-tight text-slate-950 dark:text-slate-50')">
31
+ {{ title }}
32
+ </DialogTitle>
33
+ <DialogDescription v-if="description" :class="cn('text-sm text-slate-500 dark:text-slate-400')">
34
+ {{ description }}
35
+ </DialogDescription>
36
+ </div>
37
+
38
+ <!-- Body -->
39
+ <div class="flex-1 px-6 pb-6 overflow-auto">
40
+ <slot></slot>
41
+ </div>
42
+
43
+ <!-- Footer -->
44
+ <div
45
+ v-if="$slots.footer"
46
+ :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-6 py-4 border-t border-slate-200 dark:border-slate-800 shrink-0')"
47
+ >
48
+ <slot name="footer"></slot>
49
+ </div>
50
+
51
+ <!-- Close button -->
52
+ <DialogClose
53
+ :class="cn(
54
+ 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity',
55
+ 'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',
56
+ 'dark:ring-offset-slate-950',
57
+ 'text-slate-500 dark:text-slate-400',
58
+ )"
59
+ >
60
+ <X class="h-4 w-4" />
61
+ <span class="sr-only">Close</span>
62
+ </DialogClose>
63
+ </DialogContent>
64
+ </DialogPortal>
65
+ </DialogRoot>
66
+ </template>
67
+
68
+ <script setup>
69
+ import { computed, getCurrentInstance } from 'vue'
70
+ import { cn } from '../lib/utils'
71
+ import {
72
+ DialogRoot,
73
+ DialogTrigger,
74
+ DialogPortal,
75
+ DialogOverlay,
76
+ DialogContent,
77
+ DialogTitle,
78
+ DialogDescription,
79
+ DialogClose,
80
+ } from 'reka-ui'
81
+ import { X } from 'lucide-vue-next'
82
+
83
+ const props = defineProps({
84
+ modelValue: { type: Boolean, default: false },
85
+ visible: { type: Boolean, default: undefined },
86
+ title: { type: String, default: '' },
87
+ description: { type: String, default: '' },
88
+ width: { type: String, default: 'md' },
89
+ maximized: { type: Boolean, default: false },
90
+ closeOnBackdrop: { type: Boolean, default: true },
91
+ closeOnEscape: { type: Boolean, default: true },
92
+ closeOnEsc: { type: Boolean, default: true },
93
+ })
94
+
95
+ const emit = defineEmits(['update:modelValue', 'update:visible'])
96
+
97
+ const WIDTH_CLASS_MAP = {
98
+ sm: 'sm:w-[500px]',
99
+ md: 'sm:w-[600px]',
100
+ lg: 'sm:w-[800px]',
101
+ xl: 'sm:w-[1000px]',
102
+ full: 'w-[calc(100vw-1rem)] sm:w-[calc(100vw-2rem)] max-w-none',
103
+ }
104
+
105
+ const instance = getCurrentInstance()
106
+ const passedProps = new Set(Object.keys(instance?.vnode.props || {}))
107
+ const hasProp = (...names) => names.some((name) => passedProps.has(name))
108
+
109
+ const resolvedOpen = computed(() => (hasProp('visible') ? props.visible : props.modelValue))
110
+ const resolvedCloseOnEscape = computed(() => (hasProp('closeOnEsc', 'close-on-esc') ? props.closeOnEsc : props.closeOnEscape))
111
+
112
+ const resolvedWidthClass = computed(() => {
113
+ if (hasProp('maximized') && props.maximized) return WIDTH_CLASS_MAP.full
114
+ const value = String(props.width || 'md')
115
+ return WIDTH_CLASS_MAP[value] || value
116
+ })
117
+
118
+ function emitModelUpdate(value) {
119
+ emit('update:modelValue', value)
120
+ emit('update:visible', value)
121
+ }
122
+ </script>
123
+
124
+ <style scoped>
125
+ .animate-in {
126
+ animation: fadeIn 200ms ease-out;
127
+ }
128
+ .animate-out {
129
+ animation: fadeOut 150ms ease-in;
130
+ }
131
+
132
+ @keyframes fadeIn {
133
+ from { opacity: 0; }
134
+ to { opacity: 1; }
135
+ }
136
+ @keyframes fadeOut {
137
+ from { opacity: 1; }
138
+ to { opacity: 0; }
139
+ }
140
+ </style>