@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
- function unlockBodyScroll() {
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="onPanelAfterLeave"
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
+ }
@@ -94,6 +94,7 @@ declare global {
94
94
 
95
95
  // Layout
96
96
  type LayoutBottomSheet = import('./layout').LayoutBottomSheet
97
+ type LayoutModal = import('./layout').LayoutModal
97
98
 
98
99
  // Project
99
100
  type LayerIconIcon = import('../composables/useLayerIcons').LayerIconIcon
@@ -1,3 +1,7 @@
1
1
  export interface LayoutBottomSheet {
2
2
  title?: string
3
3
  }
4
+
5
+ export interface LayoutModal {
6
+ title?: string
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "1.4.51",
3
+ "version": "1.4.52",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",