@saasmakers/ui 1.4.49 → 1.4.50

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.
@@ -17,7 +17,6 @@ const props = withDefaults(defineProps<BaseCard>(), {
17
17
  id: undefined,
18
18
  image: '',
19
19
  isSelected: false,
20
- size: 'base',
21
20
  title: undefined,
22
21
  titleIcon: undefined,
23
22
  to: undefined,
@@ -57,8 +56,7 @@ function onClick(event: MouseEvent) {
57
56
  'cursor-pointer': isClickable,
58
57
  'border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-1.5 pr-2.5': hasBackground,
59
58
  'hover:border-gray-300 dark:hover:border-gray-700': hasBackground && isClickable,
60
- 'rounded-lg': ['sm'].includes(size) && hasBackground,
61
- 'rounded-xl': ['base', 'lg'].includes(size) && hasBackground,
59
+ 'rounded-xl': hasBackground,
62
60
  }"
63
61
  :to="to"
64
62
  @click="onClick"
@@ -75,15 +73,11 @@ function onClick(event: MouseEvent) {
75
73
 
76
74
  <span
77
75
  v-if="hasAvatarBox"
78
- class="relative z-10 flex items-center justify-center flex-initial"
76
+ class="relative z-10 h-9 w-9 flex items-center justify-center flex-initial"
79
77
  :class="{
80
78
  'border shadow-inner': !avatar && !image,
81
- 'rounded-md': !avatar && !image && size === 'sm',
82
- 'rounded-lg': !avatar && !image && ['base', 'lg'].includes(size),
79
+ 'rounded-lg': !avatar && !image,
83
80
  'border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900': !avatar && !image,
84
- 'h-8 w-8': size === 'sm' && !avatar && !image,
85
- 'h-9 w-9': size === 'base' && !avatar && !image,
86
- 'h-10 w-10': size === 'lg' && !avatar && !image,
87
81
  'mr-2': direction === 'horizontal',
88
82
  'mb-1': direction === 'vertical',
89
83
  }"
@@ -92,12 +86,7 @@ function onClick(event: MouseEvent) {
92
86
 
93
87
  <BaseAvatar
94
88
  v-if="avatar"
95
- class="rounded-lg flex-initial"
96
- :class="{
97
- 'h-8 w-8': size === 'sm',
98
- 'h-10 w-10': size === 'base',
99
- 'h-14 w-14': size === 'lg',
100
- }"
89
+ class="h-10 w-10 rounded-lg flex-initial"
101
90
  :src="avatar"
102
91
  />
103
92
 
@@ -117,12 +106,7 @@ function onClick(event: MouseEvent) {
117
106
 
118
107
  <img
119
108
  v-else-if="image"
120
- class="flex-initial object-cover shadow-sm drag-none"
121
- :class="{
122
- 'h-7 w-7 rounded-md': size === 'sm',
123
- 'h-9 w-9 rounded-lg': size === 'base',
124
- 'h-10 w-10 rounded-lg': size === 'lg',
125
- }"
109
+ class="h-9 w-9 rounded-lg object-cover shadow-sm drag-none flex-initial"
126
110
  :alt="title"
127
111
  loading="lazy"
128
112
  :src="image"
@@ -135,25 +119,19 @@ function onClick(event: MouseEvent) {
135
119
  </span>
136
120
 
137
121
  <div
138
- class="flex min-w-0 flex-1 flex-col justify-center leading-snug"
122
+ class="min-w-0 flex flex-1 flex-col justify-center leading-snug"
139
123
  :class="{
140
124
  'mr-4': direction === 'horizontal',
141
125
  'items-center': direction === 'vertical',
142
126
  }"
143
127
  >
144
- <div
145
- class="flex w-full flex-col"
146
- :class="{
147
- 'gap-0': ['sm', 'base'].includes(size),
148
- 'gap-0.25': size === 'lg',
149
- }"
150
- >
128
+ <div class="w-full flex flex-col gap-0">
151
129
  <BaseIcon
152
130
  v-if="title"
153
131
  class="w-full"
154
132
  :class="{ 'self-center': !hasAvatarBox }"
155
133
  :icon="titleIcon"
156
- :size="size"
134
+ size="base"
157
135
  :text="title"
158
136
  truncate
159
137
  />
@@ -162,7 +140,7 @@ function onClick(event: MouseEvent) {
162
140
  v-if="description"
163
141
  block
164
142
  class="text-gray-600 leading-4 dark:text-gray-400"
165
- :size="size === 'lg' ? 'xs' : '2xs'"
143
+ size="2xs"
166
144
  :text="description"
167
145
  />
168
146
  </div>
@@ -0,0 +1,393 @@
1
+ <script lang="ts" setup>
2
+ import type { LayoutBottomSheet } from '../../types/layout'
3
+
4
+ withDefaults(defineProps<LayoutBottomSheet>(), { title: undefined })
5
+
6
+ defineSlots<{
7
+ default?: () => VNode[]
8
+ }>()
9
+
10
+ const closeThresholdRatio = 0.25
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
+ const visible = defineModel<boolean>({ default: false })
14
+ const contentRef = ref<HTMLElement>()
15
+ const focusTrigger = ref<HTMLElement>()
16
+ const panelRef = ref<HTMLElement>()
17
+
18
+ const drag = reactive({
19
+ closing: false,
20
+ isDragging: false,
21
+ offset: 0,
22
+ pointerId: undefined as number | undefined,
23
+ startedFromContent: false,
24
+ startY: 0,
25
+ })
26
+
27
+ let savedBodyOverflow = ''
28
+ let savedBodyPaddingRight = ''
29
+
30
+ const fixedElementPadding = new Map<HTMLElement, string>()
31
+
32
+ const closeThreshold = computed(() => {
33
+ return (panelRef.value?.offsetHeight ?? 320) * closeThresholdRatio
34
+ })
35
+
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
+ function onClose() {
78
+ visible.value = false
79
+ }
80
+
81
+ function onContentPointerDown(event: PointerEvent) {
82
+ if (drag.closing) {
83
+ return
84
+ }
85
+
86
+ if ((contentRef.value?.scrollTop ?? 0) > 0) {
87
+ return
88
+ }
89
+
90
+ drag.startedFromContent = true
91
+ drag.pointerId = event.pointerId
92
+ drag.startY = event.clientY
93
+ }
94
+
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
+ function onHeaderPointerDown(event: PointerEvent) {
124
+ if (drag.closing) {
125
+ return
126
+ }
127
+
128
+ drag.startedFromContent = false
129
+ drag.isDragging = true
130
+ drag.pointerId = event.pointerId
131
+ drag.startY = event.clientY
132
+ drag.offset = 0
133
+
134
+ const handle = event.currentTarget as HTMLElement
135
+
136
+ handle.setPointerCapture(event.pointerId)
137
+ }
138
+
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
+ function onPanelTransitionEnd(event: TransitionEvent) {
160
+ if (event.propertyName !== 'transform' || !drag.closing) {
161
+ return
162
+ }
163
+
164
+ onClose()
165
+ resetDragState()
166
+ }
167
+
168
+ function onPointerMove(event: PointerEvent) {
169
+ if (drag.startedFromContent && !drag.isDragging) {
170
+ if (event.pointerId !== drag.pointerId) {
171
+ return
172
+ }
173
+
174
+ if ((contentRef.value?.scrollTop ?? 0) > 0) {
175
+ resetPendingDrag()
176
+
177
+ return
178
+ }
179
+
180
+ const offset = event.clientY - drag.startY
181
+
182
+ if (offset < 0) {
183
+ resetPendingDrag()
184
+
185
+ return
186
+ }
187
+
188
+ if (offset < dragMoveThreshold) {
189
+ return
190
+ }
191
+
192
+ drag.isDragging = true
193
+ drag.offset = offset
194
+
195
+ const handle = event.currentTarget as HTMLElement
196
+
197
+ handle.setPointerCapture(event.pointerId)
198
+
199
+ return
200
+ }
201
+
202
+ if (!drag.isDragging) {
203
+ return
204
+ }
205
+
206
+ const offset = event.clientY - drag.startY
207
+
208
+ if (drag.startedFromContent && offset < 0) {
209
+ drag.isDragging = false
210
+
211
+ resetPendingDrag()
212
+
213
+ drag.offset = 0
214
+
215
+ const handle = event.currentTarget as HTMLElement
216
+
217
+ if (handle.hasPointerCapture(event.pointerId)) {
218
+ handle.releasePointerCapture(event.pointerId)
219
+ }
220
+
221
+ return
222
+ }
223
+
224
+ drag.offset = Math.max(0, offset)
225
+ }
226
+
227
+ function onPointerUp(event: PointerEvent) {
228
+ if (!drag.isDragging) {
229
+ resetPendingDrag()
230
+
231
+ return
232
+ }
233
+
234
+ drag.isDragging = false
235
+
236
+ resetPendingDrag()
237
+
238
+ const handle = event.currentTarget as HTMLElement
239
+
240
+ handle.releasePointerCapture(event.pointerId)
241
+
242
+ if (drag.offset >= closeThreshold.value) {
243
+ drag.closing = true
244
+
245
+ return
246
+ }
247
+
248
+ drag.offset = 0
249
+ }
250
+
251
+ function resetDragState() {
252
+ drag.closing = false
253
+ drag.isDragging = false
254
+ drag.offset = 0
255
+ drag.pointerId = undefined
256
+ drag.startY = 0
257
+ drag.startedFromContent = false
258
+ }
259
+
260
+ function resetPendingDrag() {
261
+ drag.pointerId = undefined
262
+ drag.startedFromContent = false
263
+ }
264
+
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
279
+ ? document.activeElement
280
+ : undefined
281
+
282
+ lockBodyScroll()
283
+
284
+ await nextTick()
285
+
286
+ if (panelRef.value) {
287
+ const focusableElements = getFocusableElements(panelRef.value)
288
+
289
+ if (focusableElements.length > 0) {
290
+ focusableElements[0].focus()
291
+ }
292
+ else {
293
+ panelRef.value.focus()
294
+ }
295
+
296
+ window.addEventListener('keydown', onFocusTrap)
297
+ }
298
+ }
299
+
300
+ if (!isVisible) {
301
+ resetDragState()
302
+ }
303
+ }, { immediate: true })
304
+
305
+ onMounted(() => window.addEventListener('keydown', onKeydown))
306
+
307
+ onUnmounted(() => {
308
+ window.removeEventListener('keydown', onKeydown)
309
+ window.removeEventListener('keydown', onFocusTrap)
310
+
311
+ if (import.meta.client) {
312
+ unlockBodyScroll()
313
+ }
314
+ })
315
+ </script>
316
+
317
+ <template>
318
+ <ClientOnly>
319
+ <Teleport to="body">
320
+ <Transition
321
+ enter-active-class="transition-opacity duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
322
+ enter-from-class="opacity-0 motion-reduce:opacity-100"
323
+ enter-to-class="opacity-100"
324
+ leave-active-class="transition-opacity duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
325
+ leave-from-class="opacity-100"
326
+ leave-to-class="opacity-0 motion-reduce:opacity-100"
327
+ >
328
+ <div
329
+ v-if="visible"
330
+ aria-hidden="true"
331
+ class="layout-bottom-sheet fixed inset-0 z-[60] bg-black/50"
332
+ :class="{
333
+ 'transition-none motion-reduce:transition-none': drag.isDragging,
334
+ 'transition-opacity duration-200 ease motion-reduce:transition-none motion-reduce:duration-0': !drag.isDragging && (drag.closing || drag.offset > 0),
335
+ }"
336
+ :style="{
337
+ opacity: drag.closing ? 0 : drag.offset > 0 ? Math.max(0, 1 - drag.offset / (panelRef?.offsetHeight ?? 320)) : undefined,
338
+ }"
339
+ @click="onClose"
340
+ />
341
+ </Transition>
342
+
343
+ <Transition
344
+ enter-active-class="transition-transform duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
345
+ enter-from-class="translate-y-full motion-reduce:translate-y-0"
346
+ enter-to-class="translate-y-0"
347
+ leave-active-class="transition-transform duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
348
+ leave-from-class="translate-y-0"
349
+ leave-to-class="translate-y-full motion-reduce:translate-y-0"
350
+ @after-leave="onPanelAfterLeave"
351
+ >
352
+ <div
353
+ v-if="visible"
354
+ ref="panelRef"
355
+ :aria-label="title"
356
+ aria-modal="true"
357
+ class="layout-bottom-sheet fixed bottom-0 left-0 right-0 z-[60] mx-auto max-h-[85dvh] max-w-screen-md flex flex-col rounded-t-2xl bg-white text-gray-900 shadow-lg safe-bottom dark:bg-gray-900 dark:text-gray-100"
358
+ :class="{
359
+ 'transition-none motion-reduce:transition-none': drag.isDragging,
360
+ 'transition-transform duration-200 ease motion-reduce:transition-none motion-reduce:duration-0': !drag.isDragging && (drag.closing || drag.offset > 0),
361
+ }"
362
+ role="dialog"
363
+ :style="{
364
+ transform: drag.closing ? 'translateY(100%)' : drag.offset > 0 ? `translateY(${drag.offset}px)` : undefined,
365
+ }"
366
+ tabindex="-1"
367
+ @transitionend="onPanelTransitionEnd"
368
+ >
369
+ <div
370
+ class="flex shrink-0 cursor-grab touch-none justify-center px-5 pb-2 pt-3 active:cursor-grabbing"
371
+ @pointercancel="onPointerUp"
372
+ @pointerdown="onHeaderPointerDown"
373
+ @pointermove="onPointerMove"
374
+ @pointerup="onPointerUp"
375
+ >
376
+ <div class="h-1.5 w-10 rounded-full bg-gray-300 dark:bg-gray-700" />
377
+ </div>
378
+
379
+ <div
380
+ ref="contentRef"
381
+ class="min-h-0 flex-1 overflow-y-auto px-5 pb-4"
382
+ @pointercancel="onPointerUp"
383
+ @pointerdown="onContentPointerDown"
384
+ @pointermove="onPointerMove"
385
+ @pointerup="onPointerUp"
386
+ >
387
+ <slot />
388
+ </div>
389
+ </div>
390
+ </Transition>
391
+ </Teleport>
392
+ </ClientOnly>
393
+ </template>
@@ -87,7 +87,6 @@ export interface BaseCard {
87
87
  id?: number | string
88
88
  image?: string
89
89
  isSelected?: boolean
90
- size?: BaseCardSize
91
90
  title?: string
92
91
  titleIcon?: string
93
92
  to?: RouteLocationNamedI18n
@@ -95,8 +94,6 @@ export interface BaseCard {
95
94
 
96
95
  export type BaseCardDirection = 'horizontal' | 'vertical'
97
96
 
98
- export type BaseCardSize = 'base' | 'lg' | 'sm'
99
-
100
97
  export interface BaseCharacter {
101
98
  character?: BaseCharacterCharacter
102
99
  size?: BaseCharacterSize
@@ -14,7 +14,6 @@ declare global {
14
14
  type BaseButtonType = import('./bases').BaseButtonType
15
15
  type BaseCard = import('./bases').BaseCard
16
16
  type BaseCardDirection = import('./bases').BaseCardDirection
17
- type BaseCardSize = import('./bases').BaseCardSize
18
17
  type BaseCharacter = import('./bases').BaseCharacter
19
18
  type BaseCharacterCharacter = import('./bases').BaseCharacterCharacter
20
19
  type BaseCharacterSize = import('./bases').BaseCharacterSize
@@ -93,6 +92,9 @@ declare global {
93
92
  type FieldTextarea = import('./fields').FieldTextarea
94
93
  type FieldTime = import('./fields').FieldTime
95
94
 
95
+ // Layout
96
+ type LayoutBottomSheet = import('./layout').LayoutBottomSheet
97
+
96
98
  // Project
97
99
  type LayerIconIcon = import('../composables/useLayerIcons').LayerIconIcon
98
100
  type LayerIconValue = import('../composables/useLayerIcons').LayerIconValue
@@ -0,0 +1,3 @@
1
+ export interface LayoutBottomSheet {
2
+ title?: string
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "1.4.49",
3
+ "version": "1.4.50",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",