@saasmakers/ui 1.4.48 → 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.
@@ -0,0 +1,179 @@
1
+ <script lang="ts" setup>
2
+ import { NuxtLinkLocale } from '#components'
3
+ import type { BaseCard } from '../../types/bases'
4
+
5
+ const props = withDefaults(defineProps<BaseCard>(), {
6
+ avatar: '',
7
+ clickable: true,
8
+ color: 'gray',
9
+ description: '',
10
+ details: '',
11
+ detailsIcon: undefined,
12
+ direction: 'horizontal',
13
+ emoji: undefined,
14
+ hasBackground: true,
15
+ hasChevron: false,
16
+ icon: undefined,
17
+ id: undefined,
18
+ image: '',
19
+ isSelected: false,
20
+ title: undefined,
21
+ titleIcon: undefined,
22
+ to: undefined,
23
+ })
24
+
25
+ const emit = defineEmits<{
26
+ click: [event: MouseEvent, id?: number | string]
27
+ }>()
28
+
29
+ defineSlots<{
30
+ default?: () => VNode[]
31
+ detailsLeft?: () => VNode[]
32
+ innerBoxLeft?: () => VNode[]
33
+ innerBoxRight?: () => VNode[]
34
+ outerBoxLeft?: () => VNode[]
35
+ outerBoxRight?: () => VNode[]
36
+ right?: () => VNode[]
37
+ }>()
38
+
39
+ const { getIcon } = useLayerIcons()
40
+ const hasAvatarBox = computed<boolean>(() => !!(props.avatar || props.emoji || props.icon || props.image))
41
+
42
+ const isClickable = computed(() => {
43
+ return props.to || props.clickable
44
+ })
45
+
46
+ function onClick(event: MouseEvent) {
47
+ emit('click', event, props.id)
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <component
53
+ :is="to ? NuxtLinkLocale : 'div'"
54
+ class="group flex flex-col"
55
+ :class="{
56
+ 'cursor-pointer': isClickable,
57
+ 'border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-1.5 pr-2.5': hasBackground,
58
+ 'hover:border-gray-300 dark:hover:border-gray-700': hasBackground && isClickable,
59
+ 'rounded-xl': hasBackground,
60
+ }"
61
+ :to="to"
62
+ @click="onClick"
63
+ >
64
+ <div
65
+ class="flex overflow-hidden"
66
+ :class="{
67
+ 'flex-row items-center text-left': direction === 'horizontal',
68
+ 'flex-col text-center': direction === 'vertical',
69
+ }"
70
+ >
71
+ <span class="flex justify-center">
72
+ <slot name="outerBoxLeft" />
73
+
74
+ <span
75
+ v-if="hasAvatarBox"
76
+ class="relative z-10 h-9 w-9 flex items-center justify-center flex-initial"
77
+ :class="{
78
+ 'border shadow-inner': !avatar && !image,
79
+ 'rounded-lg': !avatar && !image,
80
+ 'border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900': !avatar && !image,
81
+ 'mr-2': direction === 'horizontal',
82
+ 'mb-1': direction === 'vertical',
83
+ }"
84
+ >
85
+ <slot name="innerBoxLeft" />
86
+
87
+ <BaseAvatar
88
+ v-if="avatar"
89
+ class="h-10 w-10 rounded-lg flex-initial"
90
+ :src="avatar"
91
+ />
92
+
93
+ <BaseEmoji
94
+ v-else-if="emoji"
95
+ class="m-0.5"
96
+ :emoji="emoji"
97
+ :has-box="false"
98
+ />
99
+
100
+ <BaseIcon
101
+ v-else-if="icon"
102
+ class="text-lg"
103
+ :color="color"
104
+ :icon="icon"
105
+ />
106
+
107
+ <img
108
+ v-else-if="image"
109
+ class="h-9 w-9 rounded-lg object-cover shadow-sm drag-none flex-initial"
110
+ :alt="title"
111
+ loading="lazy"
112
+ :src="image"
113
+ >
114
+
115
+ <slot name="innerBoxRight" />
116
+ </span>
117
+
118
+ <slot name="outerBoxRight" />
119
+ </span>
120
+
121
+ <div
122
+ class="min-w-0 flex flex-1 flex-col justify-center leading-snug"
123
+ :class="{
124
+ 'mr-4': direction === 'horizontal',
125
+ 'items-center': direction === 'vertical',
126
+ }"
127
+ >
128
+ <div class="w-full flex flex-col gap-0">
129
+ <BaseIcon
130
+ v-if="title"
131
+ class="w-full"
132
+ :class="{ 'self-center': !hasAvatarBox }"
133
+ :icon="titleIcon"
134
+ size="base"
135
+ :text="title"
136
+ truncate
137
+ />
138
+
139
+ <BaseText
140
+ v-if="description"
141
+ block
142
+ class="text-gray-600 leading-4 dark:text-gray-400"
143
+ size="2xs"
144
+ :text="description"
145
+ />
146
+ </div>
147
+
148
+ <div class="flex items-center">
149
+ <slot name="detailsLeft" />
150
+
151
+ <BaseIcon
152
+ v-if="details"
153
+ class="mt-0.5 whitespace-nowrap text-gray-700 font-semibold tracking-tighter dark:text-gray-300"
154
+ :icon="detailsIcon"
155
+ size="2xs"
156
+ :text="details"
157
+ />
158
+ </div>
159
+ </div>
160
+
161
+ <BaseIcon
162
+ v-if="isSelected"
163
+ class="mr-1.5 self-center text-2xl flex-initial"
164
+ color="green"
165
+ :icon="getIcon('checkCircle')"
166
+ />
167
+
168
+ <BaseIcon
169
+ v-else-if="to || hasChevron"
170
+ class="self-center text-xl text-gray-500 flex-initial dark:text-gray-500 group-hover:text-gray-900 dark:group-hover:text-gray-100"
171
+ :icon="getIcon('chevronRight')"
172
+ />
173
+
174
+ <slot name="right" />
175
+ </div>
176
+
177
+ <slot />
178
+ </component>
179
+ </template>
@@ -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>
@@ -72,6 +72,28 @@ export type BaseButtonSize
72
72
 
73
73
  export type BaseButtonType = 'button' | 'reset' | 'submit'
74
74
 
75
+ export interface BaseCard {
76
+ avatar?: string
77
+ clickable?: boolean
78
+ color?: BaseColor
79
+ description?: string
80
+ details?: string
81
+ detailsIcon?: string
82
+ direction?: BaseCardDirection
83
+ emoji?: string
84
+ hasBackground?: boolean
85
+ hasChevron?: boolean
86
+ icon?: string
87
+ id?: number | string
88
+ image?: string
89
+ isSelected?: boolean
90
+ title?: string
91
+ titleIcon?: string
92
+ to?: RouteLocationNamedI18n
93
+ }
94
+
95
+ export type BaseCardDirection = 'horizontal' | 'vertical'
96
+
75
97
  export interface BaseCharacter {
76
98
  character?: BaseCharacterCharacter
77
99
  size?: BaseCharacterSize
@@ -12,6 +12,8 @@ declare global {
12
12
  type BaseButtonRounded = import('./bases').BaseButtonRounded
13
13
  type BaseButtonSize = import('./bases').BaseButtonSize
14
14
  type BaseButtonType = import('./bases').BaseButtonType
15
+ type BaseCard = import('./bases').BaseCard
16
+ type BaseCardDirection = import('./bases').BaseCardDirection
15
17
  type BaseCharacter = import('./bases').BaseCharacter
16
18
  type BaseCharacterCharacter = import('./bases').BaseCharacterCharacter
17
19
  type BaseCharacterSize = import('./bases').BaseCharacterSize
@@ -90,6 +92,9 @@ declare global {
90
92
  type FieldTextarea = import('./fields').FieldTextarea
91
93
  type FieldTime = import('./fields').FieldTime
92
94
 
95
+ // Layout
96
+ type LayoutBottomSheet = import('./layout').LayoutBottomSheet
97
+
93
98
  // Project
94
99
  type LayerIconIcon = import('../composables/useLayerIcons').LayerIconIcon
95
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.48",
3
+ "version": "1.4.50",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",