@saasmakers/ui 1.4.51 → 1.4.52
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.
|
@@ -9,11 +9,10 @@ defineSlots<{
|
|
|
9
9
|
|
|
10
10
|
const closeThresholdRatio = 0.25
|
|
11
11
|
const dragMoveThreshold = 8
|
|
12
|
-
const focusableSelector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
13
12
|
const visible = defineModel<boolean>({ default: false })
|
|
14
13
|
const contentRef = ref<HTMLElement>()
|
|
15
|
-
const focusTrigger = ref<HTMLElement>()
|
|
16
14
|
const panelRef = ref<HTMLElement>()
|
|
15
|
+
const { release: onAfterLeave } = useDialog(visible, panelRef, { ignoreClass: 'layout-bottom-sheet' })
|
|
17
16
|
|
|
18
17
|
const drag = reactive({
|
|
19
18
|
closing: false,
|
|
@@ -24,56 +23,10 @@ const drag = reactive({
|
|
|
24
23
|
startY: 0,
|
|
25
24
|
})
|
|
26
25
|
|
|
27
|
-
let savedBodyOverflow = ''
|
|
28
|
-
let savedBodyPaddingRight = ''
|
|
29
|
-
|
|
30
|
-
const fixedElementPadding = new Map<HTMLElement, string>()
|
|
31
|
-
|
|
32
26
|
const closeThreshold = computed(() => {
|
|
33
27
|
return (panelRef.value?.offsetHeight ?? 320) * closeThresholdRatio
|
|
34
28
|
})
|
|
35
29
|
|
|
36
|
-
function getFocusableElements(container: HTMLElement) {
|
|
37
|
-
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
|
|
38
|
-
.filter((element) => {
|
|
39
|
-
return element.offsetParent !== null || getComputedStyle(element).position === 'fixed'
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function lockBodyScroll() {
|
|
44
|
-
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
45
|
-
|
|
46
|
-
savedBodyOverflow = document.body.style.overflow
|
|
47
|
-
savedBodyPaddingRight = document.body.style.paddingRight
|
|
48
|
-
document.body.style.overflow = 'hidden'
|
|
49
|
-
|
|
50
|
-
if (scrollbarWidth > 0) {
|
|
51
|
-
document.body.style.paddingRight = `${scrollbarWidth}px`
|
|
52
|
-
|
|
53
|
-
for (const element of document.querySelectorAll('body *')) {
|
|
54
|
-
if (!(element instanceof HTMLElement)) {
|
|
55
|
-
continue
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (element.classList.contains('layout-bottom-sheet')) {
|
|
59
|
-
continue
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const { position } = getComputedStyle(element)
|
|
63
|
-
|
|
64
|
-
if (position !== 'fixed' && position !== 'sticky') {
|
|
65
|
-
continue
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
fixedElementPadding.set(element, element.style.paddingRight)
|
|
69
|
-
|
|
70
|
-
const currentPadding = Number.parseFloat(getComputedStyle(element).paddingRight) || 0
|
|
71
|
-
|
|
72
|
-
element.style.paddingRight = `${currentPadding + scrollbarWidth}px`
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
30
|
function onClose() {
|
|
78
31
|
visible.value = false
|
|
79
32
|
}
|
|
@@ -92,34 +45,6 @@ function onContentPointerDown(event: PointerEvent) {
|
|
|
92
45
|
drag.startY = event.clientY
|
|
93
46
|
}
|
|
94
47
|
|
|
95
|
-
function onFocusTrap(event: KeyboardEvent) {
|
|
96
|
-
if (event.key !== 'Tab' || !panelRef.value) {
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const focusableElements = getFocusableElements(panelRef.value)
|
|
101
|
-
|
|
102
|
-
if (focusableElements.length === 0) {
|
|
103
|
-
event.preventDefault()
|
|
104
|
-
panelRef.value.focus()
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const firstElement = focusableElements[0]
|
|
110
|
-
const lastElement = focusableElements[focusableElements.length - 1]
|
|
111
|
-
const activeElement = document.activeElement
|
|
112
|
-
|
|
113
|
-
if (event.shiftKey && activeElement === firstElement) {
|
|
114
|
-
event.preventDefault()
|
|
115
|
-
lastElement.focus()
|
|
116
|
-
}
|
|
117
|
-
else if (!event.shiftKey && activeElement === lastElement) {
|
|
118
|
-
event.preventDefault()
|
|
119
|
-
firstElement.focus()
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
48
|
function onHeaderPointerDown(event: PointerEvent) {
|
|
124
49
|
if (drag.closing) {
|
|
125
50
|
return
|
|
@@ -136,26 +61,6 @@ function onHeaderPointerDown(event: PointerEvent) {
|
|
|
136
61
|
handle.setPointerCapture(event.pointerId)
|
|
137
62
|
}
|
|
138
63
|
|
|
139
|
-
function onKeydown(event: KeyboardEvent) {
|
|
140
|
-
if (!visible.value) {
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (event.key === 'Escape') {
|
|
145
|
-
onClose()
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function onPanelAfterLeave() {
|
|
150
|
-
if (import.meta.client) {
|
|
151
|
-
unlockBodyScroll()
|
|
152
|
-
window.removeEventListener('keydown', onFocusTrap)
|
|
153
|
-
focusTrigger.value?.focus()
|
|
154
|
-
|
|
155
|
-
focusTrigger.value = undefined
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
64
|
function onPanelTransitionEnd(event: TransitionEvent) {
|
|
160
65
|
if (event.propertyName !== 'transform' || !drag.closing) {
|
|
161
66
|
return
|
|
@@ -262,53 +167,10 @@ function resetPendingDrag() {
|
|
|
262
167
|
drag.startedFromContent = false
|
|
263
168
|
}
|
|
264
169
|
|
|
265
|
-
|
|
266
|
-
document.body.style.overflow = savedBodyOverflow
|
|
267
|
-
document.body.style.paddingRight = savedBodyPaddingRight
|
|
268
|
-
|
|
269
|
-
for (const [element, paddingRight] of fixedElementPadding) {
|
|
270
|
-
element.style.paddingRight = paddingRight
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
fixedElementPadding.clear()
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
watch(visible, async (isVisible) => {
|
|
277
|
-
if (import.meta.client && isVisible) {
|
|
278
|
-
focusTrigger.value = document.activeElement instanceof HTMLElement ? document.activeElement : undefined
|
|
279
|
-
|
|
280
|
-
lockBodyScroll()
|
|
281
|
-
|
|
282
|
-
await nextTick()
|
|
283
|
-
|
|
284
|
-
if (panelRef.value) {
|
|
285
|
-
const focusableElements = getFocusableElements(panelRef.value)
|
|
286
|
-
|
|
287
|
-
if (focusableElements.length > 0) {
|
|
288
|
-
focusableElements[0].focus()
|
|
289
|
-
}
|
|
290
|
-
else {
|
|
291
|
-
panelRef.value.focus()
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
window.addEventListener('keydown', onFocusTrap)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
170
|
+
watch(visible, (isVisible) => {
|
|
298
171
|
if (!isVisible) {
|
|
299
172
|
resetDragState()
|
|
300
173
|
}
|
|
301
|
-
}, { immediate: true })
|
|
302
|
-
|
|
303
|
-
onMounted(() => window.addEventListener('keydown', onKeydown))
|
|
304
|
-
|
|
305
|
-
onUnmounted(() => {
|
|
306
|
-
window.removeEventListener('keydown', onKeydown)
|
|
307
|
-
window.removeEventListener('keydown', onFocusTrap)
|
|
308
|
-
|
|
309
|
-
if (import.meta.client) {
|
|
310
|
-
unlockBodyScroll()
|
|
311
|
-
}
|
|
312
174
|
})
|
|
313
175
|
</script>
|
|
314
176
|
|
|
@@ -345,7 +207,7 @@ onUnmounted(() => {
|
|
|
345
207
|
leave-active-class="transition-transform duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
|
|
346
208
|
leave-from-class="translate-y-0"
|
|
347
209
|
leave-to-class="translate-y-full motion-reduce:translate-y-0"
|
|
348
|
-
@after-leave="
|
|
210
|
+
@after-leave="onAfterLeave"
|
|
349
211
|
>
|
|
350
212
|
<div
|
|
351
213
|
v-if="visible"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { LayoutModal } from '../../types/layout'
|
|
3
|
+
|
|
4
|
+
withDefaults(defineProps<LayoutModal>(), { title: undefined })
|
|
5
|
+
|
|
6
|
+
defineSlots<{
|
|
7
|
+
default?: () => VNode[]
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const visible = defineModel<boolean>({ default: false })
|
|
11
|
+
const panelRef = ref<HTMLElement>()
|
|
12
|
+
const { release: onAfterLeave } = useDialog(visible, panelRef, { ignoreClass: 'layout-modal' })
|
|
13
|
+
|
|
14
|
+
function onClose() {
|
|
15
|
+
visible.value = false
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<ClientOnly>
|
|
21
|
+
<Teleport to="body">
|
|
22
|
+
<Transition
|
|
23
|
+
enter-active-class="transition-opacity duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
|
|
24
|
+
enter-from-class="opacity-0 motion-reduce:opacity-100"
|
|
25
|
+
enter-to-class="opacity-100"
|
|
26
|
+
leave-active-class="transition-opacity duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
|
|
27
|
+
leave-from-class="opacity-100"
|
|
28
|
+
leave-to-class="opacity-0 motion-reduce:opacity-100"
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
v-if="visible"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
class="layout-modal fixed inset-0 z-[60] bg-black/50"
|
|
34
|
+
@click="onClose"
|
|
35
|
+
/>
|
|
36
|
+
</Transition>
|
|
37
|
+
|
|
38
|
+
<Transition
|
|
39
|
+
enter-active-class="transition duration-200 ease motion-reduce:transition-none motion-reduce:duration-0"
|
|
40
|
+
enter-from-class="opacity-0 scale-95 motion-reduce:scale-100 motion-reduce:opacity-100"
|
|
41
|
+
enter-to-class="opacity-100 scale-100"
|
|
42
|
+
leave-active-class="transition duration-200 ease motion-reduce:transition-none motion-reduce:duration-0"
|
|
43
|
+
leave-from-class="opacity-100 scale-100"
|
|
44
|
+
leave-to-class="opacity-0 scale-95 motion-reduce:scale-100 motion-reduce:opacity-100"
|
|
45
|
+
@after-leave="onAfterLeave"
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
v-if="visible"
|
|
49
|
+
class="layout-modal pointer-events-none fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
ref="panelRef"
|
|
53
|
+
:aria-label="title"
|
|
54
|
+
aria-modal="true"
|
|
55
|
+
class="pointer-events-auto max-h-[85dvh] max-w-md w-full overflow-y-auto rounded-2xl bg-white p-5 text-gray-900 shadow-lg dark:bg-gray-900 dark:text-gray-100"
|
|
56
|
+
role="dialog"
|
|
57
|
+
tabindex="-1"
|
|
58
|
+
>
|
|
59
|
+
<slot />
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</Transition>
|
|
63
|
+
</Teleport>
|
|
64
|
+
</ClientOnly>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
interface UseDialogOptions {
|
|
2
|
+
ignoreClass: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export default function useDialog(
|
|
6
|
+
visible: Ref<boolean>,
|
|
7
|
+
panelRef: Ref<HTMLElement | undefined>,
|
|
8
|
+
options: UseDialogOptions,
|
|
9
|
+
) {
|
|
10
|
+
const focusableSelector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
11
|
+
const focusTrigger = ref<HTMLElement>()
|
|
12
|
+
const fixedElementPadding = new Map<HTMLElement, string>()
|
|
13
|
+
|
|
14
|
+
let savedBodyOverflow = ''
|
|
15
|
+
let savedBodyPaddingRight = ''
|
|
16
|
+
|
|
17
|
+
function getFocusableElements(container: HTMLElement) {
|
|
18
|
+
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
|
|
19
|
+
.filter((element) => {
|
|
20
|
+
return element.offsetParent !== null || getComputedStyle(element).position === 'fixed'
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lockBodyScroll() {
|
|
25
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
26
|
+
|
|
27
|
+
savedBodyOverflow = document.body.style.overflow
|
|
28
|
+
savedBodyPaddingRight = document.body.style.paddingRight
|
|
29
|
+
document.body.style.overflow = 'hidden'
|
|
30
|
+
|
|
31
|
+
if (scrollbarWidth > 0) {
|
|
32
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`
|
|
33
|
+
|
|
34
|
+
for (const element of document.querySelectorAll('body *')) {
|
|
35
|
+
if (!(element instanceof HTMLElement)) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Skip the dialog's own teleported elements so they are not padded.
|
|
40
|
+
if (element.classList.contains(options.ignoreClass)) {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { position } = getComputedStyle(element)
|
|
45
|
+
|
|
46
|
+
if (position !== 'fixed' && position !== 'sticky') {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fixedElementPadding.set(element, element.style.paddingRight)
|
|
51
|
+
|
|
52
|
+
const currentPadding = Number.parseFloat(getComputedStyle(element).paddingRight) || 0
|
|
53
|
+
|
|
54
|
+
element.style.paddingRight = `${currentPadding + scrollbarWidth}px`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onFocusTrap(event: KeyboardEvent) {
|
|
60
|
+
if (event.key !== 'Tab' || !panelRef.value) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const focusableElements = getFocusableElements(panelRef.value)
|
|
65
|
+
|
|
66
|
+
if (focusableElements.length === 0) {
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
panelRef.value.focus()
|
|
69
|
+
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const firstElement = focusableElements[0]
|
|
74
|
+
const lastElement = focusableElements[focusableElements.length - 1]
|
|
75
|
+
const activeElement = document.activeElement
|
|
76
|
+
|
|
77
|
+
if (event.shiftKey && activeElement === firstElement) {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
lastElement.focus()
|
|
80
|
+
}
|
|
81
|
+
else if (!event.shiftKey && activeElement === lastElement) {
|
|
82
|
+
event.preventDefault()
|
|
83
|
+
firstElement.focus()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onKeydown(event: KeyboardEvent) {
|
|
88
|
+
if (visible.value && event.key === 'Escape') {
|
|
89
|
+
visible.value = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unlockBodyScroll() {
|
|
94
|
+
document.body.style.overflow = savedBodyOverflow
|
|
95
|
+
document.body.style.paddingRight = savedBodyPaddingRight
|
|
96
|
+
|
|
97
|
+
for (const [element, paddingRight] of fixedElementPadding) {
|
|
98
|
+
element.style.paddingRight = paddingRight
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fixedElementPadding.clear()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Restores the page state once the leave transition has finished.
|
|
105
|
+
function release() {
|
|
106
|
+
if (import.meta.client) {
|
|
107
|
+
unlockBodyScroll()
|
|
108
|
+
window.removeEventListener('keydown', onFocusTrap)
|
|
109
|
+
focusTrigger.value?.focus()
|
|
110
|
+
|
|
111
|
+
focusTrigger.value = undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
watch(visible, async (isVisible) => {
|
|
116
|
+
if (import.meta.client && isVisible) {
|
|
117
|
+
focusTrigger.value = document.activeElement instanceof HTMLElement ? document.activeElement : undefined
|
|
118
|
+
|
|
119
|
+
lockBodyScroll()
|
|
120
|
+
|
|
121
|
+
await nextTick()
|
|
122
|
+
|
|
123
|
+
if (panelRef.value) {
|
|
124
|
+
const focusableElements = getFocusableElements(panelRef.value)
|
|
125
|
+
|
|
126
|
+
if (focusableElements.length > 0) {
|
|
127
|
+
focusableElements[0].focus()
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
panelRef.value.focus()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
window.addEventListener('keydown', onFocusTrap)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, { immediate: true })
|
|
137
|
+
|
|
138
|
+
onMounted(() => window.addEventListener('keydown', onKeydown))
|
|
139
|
+
|
|
140
|
+
onUnmounted(() => {
|
|
141
|
+
window.removeEventListener('keydown', onKeydown)
|
|
142
|
+
window.removeEventListener('keydown', onFocusTrap)
|
|
143
|
+
|
|
144
|
+
if (import.meta.client) {
|
|
145
|
+
unlockBodyScroll()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return { release }
|
|
150
|
+
}
|
package/app/types/global.d.ts
CHANGED
package/app/types/layout.d.ts
CHANGED