@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.
- package/README.md +177 -0
- package/dist/sh-tailwind.cjs.js +1 -0
- package/dist/sh-tailwind.es.js +3695 -0
- package/package.json +56 -0
- package/src/components/actions/ShConfirmAction.vue +78 -0
- package/src/components/actions/ShSilentAction.vue +66 -0
- package/src/components/actions/ShSpinner.vue +6 -0
- package/src/components/form/ShForm.vue +272 -0
- package/src/components/form/ShFormSteps.vue +30 -0
- package/src/components/form/inputs/DateInput.vue +29 -0
- package/src/components/form/inputs/EmailInput.vue +27 -0
- package/src/components/form/inputs/NumberInput.vue +32 -0
- package/src/components/form/inputs/PasswordInput.vue +47 -0
- package/src/components/form/inputs/PhoneInput.vue +190 -0
- package/src/components/form/inputs/SelectInput.vue +50 -0
- package/src/components/form/inputs/ShSuggest.vue +198 -0
- package/src/components/form/inputs/TextAreaInput.vue +27 -0
- package/src/components/form/inputs/TextInput.vue +26 -0
- package/src/components/overlay/ShDialog.vue +143 -0
- package/src/components/overlay/ShDialogBtn.vue +41 -0
- package/src/components/overlay/ShDialogForm.vue +80 -0
- package/src/components/overlay/ShDrawer.vue +129 -0
- package/src/components/overlay/ShDrawerBtn.vue +40 -0
- package/src/components/table/ShTable.vue +472 -0
- package/src/components/table/ShTablePagination.vue +96 -0
- package/src/composables/useDialog.js +68 -0
- package/src/composables/useScrollLock.js +19 -0
- package/src/data/countries.js +1474 -0
- package/src/index.js +45 -0
- package/src/plugin/ShTailwind.js +36 -0
- package/src/table/localQuery.js +60 -0
- package/src/table/tableCache.js +116 -0
- package/src/table/useTableData.js +125 -0
- package/src/theme/defaultTheme.js +148 -0
- package/src/theme/keys.js +3 -0
- package/src/theme/useTheme.js +11 -0
- package/src/utils/deepMerge.js +19 -0
- package/src/utils/normalizeField.js +61 -0
- package/src/utils/normalizeOptions.js +18 -0
- 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)">×</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>
|