@iankibetsh/sh-tailwind 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 (40) hide show
  1. package/README.md +177 -0
  2. package/dist/sh-tailwind.cjs.js +1 -0
  3. package/dist/sh-tailwind.es.js +3695 -0
  4. package/package.json +56 -0
  5. package/src/components/actions/ShConfirmAction.vue +78 -0
  6. package/src/components/actions/ShSilentAction.vue +66 -0
  7. package/src/components/actions/ShSpinner.vue +6 -0
  8. package/src/components/form/ShForm.vue +272 -0
  9. package/src/components/form/ShFormSteps.vue +30 -0
  10. package/src/components/form/inputs/DateInput.vue +29 -0
  11. package/src/components/form/inputs/EmailInput.vue +27 -0
  12. package/src/components/form/inputs/NumberInput.vue +32 -0
  13. package/src/components/form/inputs/PasswordInput.vue +47 -0
  14. package/src/components/form/inputs/PhoneInput.vue +190 -0
  15. package/src/components/form/inputs/SelectInput.vue +50 -0
  16. package/src/components/form/inputs/ShSuggest.vue +198 -0
  17. package/src/components/form/inputs/TextAreaInput.vue +27 -0
  18. package/src/components/form/inputs/TextInput.vue +26 -0
  19. package/src/components/overlay/ShDialog.vue +143 -0
  20. package/src/components/overlay/ShDialogBtn.vue +41 -0
  21. package/src/components/overlay/ShDialogForm.vue +80 -0
  22. package/src/components/overlay/ShDrawer.vue +129 -0
  23. package/src/components/overlay/ShDrawerBtn.vue +40 -0
  24. package/src/components/table/ShTable.vue +472 -0
  25. package/src/components/table/ShTablePagination.vue +96 -0
  26. package/src/composables/useDialog.js +68 -0
  27. package/src/composables/useScrollLock.js +19 -0
  28. package/src/data/countries.js +1474 -0
  29. package/src/index.js +45 -0
  30. package/src/plugin/ShTailwind.js +36 -0
  31. package/src/table/localQuery.js +60 -0
  32. package/src/table/tableCache.js +116 -0
  33. package/src/table/useTableData.js +125 -0
  34. package/src/theme/defaultTheme.js +148 -0
  35. package/src/theme/keys.js +3 -0
  36. package/src/theme/useTheme.js +11 -0
  37. package/src/utils/deepMerge.js +19 -0
  38. package/src/utils/normalizeField.js +61 -0
  39. package/src/utils/normalizeOptions.js +18 -0
  40. package/src/utils/strings.js +11 -0
@@ -0,0 +1,190 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { shApis } from '@iankibetsh/sh-core'
4
+ import countries from '../../../data/countries.js'
5
+ import { useTheme } from '../../../theme/useTheme.js'
6
+
7
+ defineOptions({ inheritAttrs: false })
8
+
9
+ const props = defineProps({
10
+ modelValue: String,
11
+ placeholder: { type: String, default: '712345678' },
12
+ isInvalid: Boolean,
13
+ disabled: Boolean,
14
+ countryCode: { type: String, default: 'KE' },
15
+ // opt-in backend lookup of the visitor country (GET sh-country-code)
16
+ detectCountry: Boolean
17
+ })
18
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
19
+
20
+ const t = useTheme('inputs')
21
+ const root = ref(null)
22
+ const searchEl = ref(null)
23
+ const input = ref('')
24
+ const open = ref(false)
25
+ const countrySearch = ref('')
26
+ const highlighted = ref(-1)
27
+ const selectedCountry = ref(
28
+ countries.find(c => c.isoCode === props.countryCode.toUpperCase()) ?? countries[0]
29
+ )
30
+
31
+ // Emoji flags are computed from the ISO code - fully offline, no assets
32
+ const flagFor = (isoCode) =>
33
+ String(isoCode ?? '')
34
+ .toUpperCase()
35
+ .split('')
36
+ .map(char => String.fromCodePoint(0x1F1E6 + char.charCodeAt(0) - 65))
37
+ .join('')
38
+
39
+ const filteredCountries = computed(() => {
40
+ const term = countrySearch.value.trim().toLowerCase()
41
+ if (!term) {
42
+ return countries
43
+ }
44
+ return countries.filter(c =>
45
+ c.name.toLowerCase().includes(term) ||
46
+ c.dialCode.includes(term) ||
47
+ c.isoCode.toLowerCase().includes(term)
48
+ )
49
+ })
50
+
51
+ const toggleDropdown = async () => {
52
+ if (props.disabled) {
53
+ return
54
+ }
55
+ open.value = !open.value
56
+ if (open.value) {
57
+ countrySearch.value = ''
58
+ highlighted.value = -1
59
+ await nextTick()
60
+ searchEl.value?.focus()
61
+ }
62
+ }
63
+
64
+ const pickCountry = (country) => {
65
+ selectedCountry.value = country
66
+ open.value = false
67
+ updateValue()
68
+ }
69
+
70
+ const onSearchKeydown = (e) => {
71
+ if (e.key === 'ArrowDown') {
72
+ e.preventDefault()
73
+ highlighted.value = Math.min(highlighted.value + 1, filteredCountries.value.length - 1)
74
+ } else if (e.key === 'ArrowUp') {
75
+ e.preventDefault()
76
+ highlighted.value = Math.max(highlighted.value - 1, 0)
77
+ } else if (e.key === 'Enter') {
78
+ e.preventDefault()
79
+ const country = filteredCountries.value[Math.max(highlighted.value, 0)]
80
+ if (country) {
81
+ pickCountry(country)
82
+ }
83
+ } else if (e.key === 'Escape') {
84
+ open.value = false
85
+ }
86
+ }
87
+
88
+ const updateValue = () => {
89
+ const phone = (input.value || '').replace(/^0/, '')
90
+ emit('update:modelValue', phone ? selectedCountry.value.dialCode + phone : '')
91
+ }
92
+
93
+ const syncFromModel = (value) => {
94
+ if (!value) {
95
+ input.value = ''
96
+ return
97
+ }
98
+ const country = countries.find(c => value.startsWith(c.dialCode))
99
+ if (country) {
100
+ selectedCountry.value = country
101
+ input.value = value.replace(country.dialCode, '')
102
+ } else {
103
+ input.value = value
104
+ }
105
+ }
106
+
107
+ watch(() => props.modelValue, syncFromModel)
108
+
109
+ const onOutsidePointer = (e) => {
110
+ if (root.value && !root.value.contains(e.target)) {
111
+ open.value = false
112
+ }
113
+ }
114
+
115
+ onMounted(() => {
116
+ document.addEventListener('pointerdown', onOutsidePointer)
117
+ syncFromModel(props.modelValue)
118
+ if (props.detectCountry && !props.modelValue) {
119
+ shApis.doGet('sh-country-code').then(res => {
120
+ const iso = res.data?.countryCode
121
+ const country = iso && countries.find(c => c.isoCode === iso.toUpperCase())
122
+ if (country) {
123
+ selectedCountry.value = country
124
+ }
125
+ }).catch(() => {})
126
+ }
127
+ })
128
+
129
+ onBeforeUnmount(() => {
130
+ document.removeEventListener('pointerdown', onOutsidePointer)
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <div ref="root" :class="[t.phone.wrapper, isInvalid ? 'border-red-500 focus-within:ring-red-500/30' : '']">
136
+ <button
137
+ type="button"
138
+ :class="t.phone.trigger"
139
+ :disabled="disabled"
140
+ aria-haspopup="listbox"
141
+ :aria-expanded="open"
142
+ @click="toggleDropdown"
143
+ >
144
+ <span :class="t.phone.flag">{{ flagFor(selectedCountry.isoCode) }}</span>
145
+ <span :class="t.phone.dial">{{ selectedCountry.dialCode }}</span>
146
+ <svg xmlns="http://www.w3.org/2000/svg" :class="t.phone.chevron" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
147
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
148
+ </svg>
149
+ </button>
150
+
151
+ <input
152
+ v-model="input"
153
+ type="tel"
154
+ :class="t.phone.input"
155
+ :placeholder="placeholder"
156
+ :disabled="disabled"
157
+ @input="updateValue"
158
+ @focus="emit('clearValidationErrors')"
159
+ >
160
+
161
+ <div v-if="open" :class="t.phone.dropdown">
162
+ <input
163
+ ref="searchEl"
164
+ v-model="countrySearch"
165
+ type="text"
166
+ :class="t.phone.search"
167
+ placeholder="Search country or code..."
168
+ @keydown="onSearchKeydown"
169
+ @input="highlighted = 0"
170
+ >
171
+ <div :class="t.phone.list" role="listbox">
172
+ <button
173
+ v-for="(country, index) in filteredCountries"
174
+ :key="country.isoCode"
175
+ type="button"
176
+ role="option"
177
+ :aria-selected="country.isoCode === selectedCountry.isoCode"
178
+ :class="index === highlighted ? t.phone.optionActive : t.phone.option"
179
+ @pointerdown.prevent="pickCountry(country)"
180
+ @mouseenter="highlighted = index"
181
+ >
182
+ <span :class="t.phone.flag">{{ flagFor(country.isoCode) }}</span>
183
+ <span :class="t.phone.optionName">{{ country.name }}</span>
184
+ <span :class="t.phone.optionDial">{{ country.dialCode }}</span>
185
+ </button>
186
+ <p v-if="!filteredCountries.length" :class="t.phone.empty">No country matches "{{ countrySearch }}"</p>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import { shApis } from '@iankibetsh/sh-core'
4
+ import { normalizeOptions } from '../../../utils/normalizeOptions.js'
5
+
6
+ const props = defineProps({
7
+ modelValue: [String, Number],
8
+ placeholder: String,
9
+ isInvalid: Boolean,
10
+ disabled: Boolean,
11
+ // inline array of options, or omit and provide url
12
+ options: Array,
13
+ // remote source; fetched with { all: 1 } like shframework's SelectInput
14
+ url: String
15
+ })
16
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
17
+
18
+ const remoteOptions = ref([])
19
+ const loading = ref(false)
20
+
21
+ const items = computed(() => normalizeOptions(props.options ?? remoteOptions.value))
22
+
23
+ const model = computed({
24
+ get: () => props.modelValue,
25
+ set: (value) => emit('update:modelValue', value)
26
+ })
27
+
28
+ onMounted(() => {
29
+ if (!props.options && props.url) {
30
+ loading.value = true
31
+ shApis.doGet(props.url, { all: 1 }).then(res => {
32
+ const rows = Array.isArray(res.data) ? res.data : (res.data?.data ?? [])
33
+ remoteOptions.value = rows
34
+ }).finally(() => {
35
+ loading.value = false
36
+ })
37
+ }
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <select
43
+ v-model="model"
44
+ :disabled="disabled || loading"
45
+ @focus="emit('clearValidationErrors')"
46
+ >
47
+ <option :value="null" disabled>{{ loading ? 'Loading...' : (placeholder || 'Select...') }}</option>
48
+ <option v-for="option in items" :key="option.id" :value="option.id">{{ option.label }}</option>
49
+ </select>
50
+ </template>
@@ -0,0 +1,198 @@
1
+ <script setup>
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import { shApis } from '@iankibetsh/sh-core'
4
+ import { normalizeOptions } from '../../../utils/normalizeOptions.js'
5
+ import { useTheme } from '../../../theme/useTheme.js'
6
+
7
+ defineOptions({ inheritAttrs: false })
8
+
9
+ const props = defineProps({
10
+ modelValue: [String, Number, Array],
11
+ placeholder: { type: String, default: 'Type to search...' },
12
+ isInvalid: Boolean,
13
+ disabled: Boolean,
14
+ // inline data array, or remote url searched with { all: 1, filter_value }
15
+ options: Array,
16
+ url: String,
17
+ multiple: Boolean,
18
+ // allow submitting free text that matches no option
19
+ allowCustom: Boolean,
20
+ // optional component rendered per option (receives :option)
21
+ optionTemplate: [Object, Function]
22
+ })
23
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
24
+
25
+ const t = useTheme('inputs')
26
+ const root = ref(null)
27
+ const search = ref('')
28
+ const open = ref(false)
29
+ const loading = ref(false)
30
+ const highlighted = ref(-1)
31
+ const remote = ref([])
32
+ const selected = ref([]) // [{ id, label }]
33
+
34
+ const items = computed(() => {
35
+ const source = normalizeOptions(props.options ?? remote.value)
36
+ const picked = new Set(selected.value.map(s => s.id))
37
+ let list = source.filter(option => !picked.has(option.id))
38
+ if (props.options && search.value) {
39
+ const term = search.value.toLowerCase()
40
+ list = list.filter(option => option.label.toLowerCase().includes(term))
41
+ }
42
+ return list
43
+ })
44
+
45
+ let debounceTimer = null
46
+ const remoteSearch = () => {
47
+ if (!props.url) {
48
+ return
49
+ }
50
+ clearTimeout(debounceTimer)
51
+ debounceTimer = setTimeout(() => {
52
+ loading.value = true
53
+ shApis.doGet(props.url, { all: 1, filter_value: search.value }).then(res => {
54
+ const rows = Array.isArray(res.data) ? res.data : (res.data?.data ?? [])
55
+ remote.value = rows
56
+ }).finally(() => {
57
+ loading.value = false
58
+ })
59
+ }, 300)
60
+ }
61
+
62
+ const emitValue = () => {
63
+ if (props.multiple) {
64
+ emit('update:modelValue', selected.value.map(s => s.id))
65
+ } else {
66
+ emit('update:modelValue', selected.value[0]?.id ?? null)
67
+ }
68
+ }
69
+
70
+ const pick = (option) => {
71
+ if (props.multiple) {
72
+ selected.value.push(option)
73
+ } else {
74
+ selected.value = [option]
75
+ open.value = false
76
+ }
77
+ search.value = ''
78
+ highlighted.value = -1
79
+ emitValue()
80
+ }
81
+
82
+ const remove = (option) => {
83
+ selected.value = selected.value.filter(s => s.id !== option.id)
84
+ emitValue()
85
+ }
86
+
87
+ const submitCustom = () => {
88
+ if (!props.allowCustom || !search.value) {
89
+ return
90
+ }
91
+ pick({ id: search.value, label: search.value })
92
+ }
93
+
94
+ const onInput = () => {
95
+ open.value = true
96
+ emit('clearValidationErrors')
97
+ if (props.url) {
98
+ remoteSearch()
99
+ }
100
+ }
101
+
102
+ const onKeydown = (e) => {
103
+ if (e.key === 'ArrowDown') {
104
+ e.preventDefault()
105
+ open.value = true
106
+ highlighted.value = Math.min(highlighted.value + 1, items.value.length - 1)
107
+ } else if (e.key === 'ArrowUp') {
108
+ e.preventDefault()
109
+ highlighted.value = Math.max(highlighted.value - 1, 0)
110
+ } else if (e.key === 'Enter') {
111
+ e.preventDefault()
112
+ if (highlighted.value >= 0 && items.value[highlighted.value]) {
113
+ pick(items.value[highlighted.value])
114
+ } else {
115
+ submitCustom()
116
+ }
117
+ } else if (e.key === 'Escape') {
118
+ open.value = false
119
+ } else if (e.key === 'Backspace' && !search.value && selected.value.length) {
120
+ remove(selected.value[selected.value.length - 1])
121
+ }
122
+ }
123
+
124
+ // Map incoming model ids back to badges once data is available
125
+ const initializeExisting = () => {
126
+ const incoming = props.modelValue
127
+ if (incoming === null || typeof incoming === 'undefined' || incoming === '') {
128
+ return
129
+ }
130
+ const ids = Array.isArray(incoming) ? incoming : [incoming]
131
+ const source = normalizeOptions(props.options ?? remote.value)
132
+ selected.value = ids.map(id =>
133
+ source.find(option => option.id === id) ?? { id, label: String(id) }
134
+ )
135
+ }
136
+
137
+ const onOutsidePointer = (e) => {
138
+ if (root.value && !root.value.contains(e.target)) {
139
+ open.value = false
140
+ }
141
+ }
142
+
143
+ watch(() => props.options, initializeExisting)
144
+
145
+ onMounted(() => {
146
+ document.addEventListener('pointerdown', onOutsidePointer)
147
+ initializeExisting()
148
+ if (props.url && !props.options) {
149
+ remoteSearch()
150
+ }
151
+ })
152
+
153
+ onBeforeUnmount(() => {
154
+ document.removeEventListener('pointerdown', onOutsidePointer)
155
+ clearTimeout(debounceTimer)
156
+ })
157
+ </script>
158
+
159
+ <template>
160
+ <div ref="root" :class="t.suggest.wrapper">
161
+ <div v-if="multiple && selected.length" :class="t.suggest.badges">
162
+ <span v-for="option in selected" :key="option.id" :class="t.suggest.badge">
163
+ {{ option.label }}
164
+ <span :class="t.suggest.badgeRemove" @click="remove(option)">&times;</span>
165
+ </span>
166
+ </div>
167
+ <input
168
+ v-bind="$attrs"
169
+ v-model="search"
170
+ type="text"
171
+ :placeholder="!multiple && selected.length ? selected[0].label : placeholder"
172
+ :disabled="disabled"
173
+ autocomplete="off"
174
+ @input="onInput"
175
+ @keydown="onKeydown"
176
+ @focus="open = true; emit('clearValidationErrors')"
177
+ >
178
+ <div v-if="open && !disabled" :class="t.suggest.dropdown">
179
+ <div v-if="loading" :class="t.suggest.empty">Searching...</div>
180
+ <template v-else-if="items.length">
181
+ <div
182
+ v-for="(option, index) in items"
183
+ :key="option.id"
184
+ :class="index === highlighted ? t.suggest.optionActive : t.suggest.option"
185
+ @pointerdown.prevent="pick(option)"
186
+ @mouseenter="highlighted = index"
187
+ >
188
+ <component :is="optionTemplate" v-if="optionTemplate" :option="option.raw ?? option" />
189
+ <template v-else>{{ option.label }}</template>
190
+ </div>
191
+ </template>
192
+ <div v-else-if="allowCustom && search" :class="t.suggest.option" @pointerdown.prevent="submitCustom">
193
+ Use "{{ search }}"
194
+ </div>
195
+ <div v-else :class="t.suggest.empty">No matches</div>
196
+ </div>
197
+ </div>
198
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: [String, Number],
6
+ placeholder: String,
7
+ isInvalid: Boolean,
8
+ disabled: Boolean,
9
+ rows: { type: [Number, String], default: 3 }
10
+ })
11
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
12
+
13
+ const model = computed({
14
+ get: () => props.modelValue,
15
+ set: (value) => emit('update:modelValue', value)
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <textarea
21
+ v-model="model"
22
+ :rows="rows"
23
+ :placeholder="placeholder"
24
+ :disabled="disabled"
25
+ @focus="emit('clearValidationErrors')"
26
+ />
27
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: [String, Number],
6
+ placeholder: String,
7
+ isInvalid: Boolean,
8
+ disabled: Boolean
9
+ })
10
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
11
+
12
+ const model = computed({
13
+ get: () => props.modelValue,
14
+ set: (value) => emit('update:modelValue', value)
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <input
20
+ v-model="model"
21
+ type="text"
22
+ :placeholder="placeholder"
23
+ :disabled="disabled"
24
+ @focus="emit('clearValidationErrors')"
25
+ >
26
+ </template>
@@ -0,0 +1,143 @@
1
+ <script setup>
2
+ import { computed, provide, ref, watch } from 'vue'
3
+ import { useDialog } from '../../composables/useDialog.js'
4
+ import { useTheme } from '../../theme/useTheme.js'
5
+ import { SH_DIALOG_CONTEXT } from '../../theme/keys.js'
6
+
7
+ const props = defineProps({
8
+ open: Boolean,
9
+ title: String,
10
+ size: { type: String, default: 'md' }, // sm | md | lg | xl | full
11
+ static: Boolean,
12
+ hideClose: Boolean,
13
+ // keep the dialog open when a nested ShForm submits successfully
14
+ retainOnSuccess: Boolean,
15
+ classes: Object
16
+ })
17
+ const emit = defineEmits(['update:open', 'opened', 'closed'])
18
+
19
+ const t = useTheme('dialog', computed(() => props.classes))
20
+ const panel = ref(null)
21
+ const rendered = ref(false) // keeps the teleport mounted through leave animation
22
+ const visible = ref(false) // drives the transitions
23
+ const pulsing = ref(false) // static-backdrop feedback
24
+
25
+ const dialog = useDialog({
26
+ isStatic: () => props.static,
27
+ onClose: () => emit('update:open', false)
28
+ })
29
+
30
+ const show = () => {
31
+ rendered.value = true
32
+ dialog.show()
33
+ requestAnimationFrame(() => {
34
+ visible.value = true
35
+ })
36
+ if (!props.open) {
37
+ emit('update:open', true)
38
+ }
39
+ }
40
+
41
+ const close = (reason) => {
42
+ visible.value = false
43
+ dialog.close(reason)
44
+ }
45
+
46
+ const onBackdrop = () => {
47
+ if (props.static) {
48
+ // Bootstrap-style attention pulse
49
+ pulsing.value = true
50
+ setTimeout(() => { pulsing.value = false }, 150)
51
+ return
52
+ }
53
+ close('backdrop')
54
+ }
55
+
56
+ watch(() => props.open, (value) => {
57
+ if (value) {
58
+ show()
59
+ } else if (visible.value) {
60
+ close('prop')
61
+ }
62
+ }, { immediate: true })
63
+
64
+ // External Escape close happens through useDialog; sync visible state
65
+ watch(dialog.isOpen, (value) => {
66
+ if (!value && visible.value) {
67
+ visible.value = false
68
+ }
69
+ })
70
+
71
+ // Nested forms can ask their host dialog to close (replaces the old
72
+ // Bootstrap `.closest('.modal-dialog')` hack)
73
+ provide(SH_DIALOG_CONTEXT, {
74
+ close: () => close('context'),
75
+ requestClose: (reason) => {
76
+ if (reason === 'success') {
77
+ if (props.retainOnSuccess) {
78
+ return
79
+ }
80
+ // brief delay so the success toast registers before the panel leaves
81
+ setTimeout(() => close('success'), 600)
82
+ return
83
+ }
84
+ close(reason)
85
+ }
86
+ })
87
+
88
+ defineExpose({ show, close })
89
+ </script>
90
+
91
+ <template>
92
+ <teleport to="body">
93
+ <div v-if="rendered" class="fixed inset-0" :style="{ zIndex: dialog.zIndex.value }">
94
+ <Transition
95
+ enter-active-class="transition-opacity duration-200 ease-out"
96
+ enter-from-class="opacity-0"
97
+ enter-to-class="opacity-100"
98
+ leave-active-class="transition-opacity duration-150 ease-in"
99
+ leave-from-class="opacity-100"
100
+ leave-to-class="opacity-0"
101
+ >
102
+ <div v-show="visible" :class="t.backdrop" @click="onBackdrop" />
103
+ </Transition>
104
+ <Transition
105
+ enter-active-class="transition duration-200 ease-out"
106
+ enter-from-class="opacity-0 scale-95 translate-y-2"
107
+ enter-to-class="opacity-100 scale-100 translate-y-0"
108
+ leave-active-class="transition duration-150 ease-in"
109
+ leave-from-class="opacity-100 scale-100"
110
+ leave-to-class="opacity-0 scale-95"
111
+ @after-enter="panel?.focus(); emit('opened')"
112
+ @after-leave="rendered = false; emit('closed')"
113
+ >
114
+ <div v-show="visible" :class="t.wrapper" @click.self="onBackdrop">
115
+ <div
116
+ ref="panel"
117
+ tabindex="-1"
118
+ role="dialog"
119
+ aria-modal="true"
120
+ :class="[t.panel, t.sizes[size] ?? t.sizes.md, pulsing ? 'scale-[1.02] transition-transform duration-150' : '']"
121
+ >
122
+ <header v-if="title || $slots.title || !hideClose" :class="t.header">
123
+ <slot name="title">
124
+ <h3 :class="t.title">{{ title }}</h3>
125
+ </slot>
126
+ <button v-if="!hideClose" type="button" :class="t.closeBtn" aria-label="Close" @click="close('button')">
127
+ <svg xmlns="http://www.w3.org/2000/svg" class="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
128
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
129
+ </svg>
130
+ </button>
131
+ </header>
132
+ <div :class="t.body">
133
+ <slot :close="close" />
134
+ </div>
135
+ <footer v-if="$slots.footer" :class="t.footer">
136
+ <slot name="footer" :close="close" />
137
+ </footer>
138
+ </div>
139
+ </div>
140
+ </Transition>
141
+ </div>
142
+ </teleport>
143
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import ShDialog from './ShDialog.vue'
4
+ import { useTheme } from '../../theme/useTheme.js'
5
+
6
+ defineProps({
7
+ title: String,
8
+ size: { type: String, default: 'md' },
9
+ static: Boolean,
10
+ hideClose: Boolean,
11
+ btnClass: String,
12
+ classes: Object
13
+ })
14
+ defineEmits(['opened', 'closed'])
15
+
16
+ const buttons = useTheme('buttons')
17
+ const open = ref(false)
18
+ </script>
19
+
20
+ <template>
21
+ <button type="button" :class="btnClass ?? buttons.secondary" @click="open = true">
22
+ <slot name="trigger">Open</slot>
23
+ </button>
24
+ <ShDialog
25
+ v-model:open="open"
26
+ :title="title"
27
+ :size="size"
28
+ :static="static"
29
+ :hide-close="hideClose"
30
+ :classes="classes"
31
+ @opened="$emit('opened')"
32
+ @closed="$emit('closed')"
33
+ >
34
+ <template #default="{ close }">
35
+ <slot :close="close" />
36
+ </template>
37
+ <template v-if="$slots.footer" #footer="{ close }">
38
+ <slot name="footer" :close="close" />
39
+ </template>
40
+ </ShDialog>
41
+ </template>