@saasmakers/ui 1.4.51 → 1.4.53

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.
@@ -35,7 +35,6 @@ defineSlots<{
35
35
  }"
36
36
  size="xs"
37
37
  :text="title"
38
- uppercase
39
38
  />
40
39
 
41
40
  <div
@@ -10,6 +10,7 @@ const props = withDefaults(defineProps<BaseCard>(), {
10
10
  details: '',
11
11
  detailsIcon: undefined,
12
12
  direction: 'horizontal',
13
+ disabled: false,
13
14
  emoji: undefined,
14
15
  hasBackground: true,
15
16
  hasChevron: false,
@@ -17,6 +18,7 @@ const props = withDefaults(defineProps<BaseCard>(), {
17
18
  id: undefined,
18
19
  image: '',
19
20
  isSelected: false,
21
+ size: 'base',
20
22
  title: undefined,
21
23
  titleIcon: undefined,
22
24
  to: undefined,
@@ -43,25 +45,32 @@ const hasAvatarBox = computed<boolean>(() => {
43
45
  })
44
46
 
45
47
  const isClickable = computed(() => {
46
- return props.to || props.clickable
48
+ return !props.disabled && (props.to || props.clickable)
47
49
  })
48
50
 
49
51
  function onClick(event: MouseEvent) {
52
+ if (props.disabled) {
53
+ return
54
+ }
55
+
50
56
  emit('click', event, props.id)
51
57
  }
52
58
  </script>
53
59
 
54
60
  <template>
55
61
  <component
56
- :is="to ? NuxtLinkLocale : 'div'"
62
+ :is="to && !disabled ? NuxtLinkLocale : 'div'"
57
63
  class="group flex flex-col"
58
64
  :class="{
59
65
  'cursor-pointer': isClickable,
60
- 'border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-1.5 pr-2.5': hasBackground,
66
+ 'pointer-events-none': disabled,
67
+ 'opacity-50': disabled,
68
+ 'border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-1.5 pr-2.5': hasBackground && size === 'base',
69
+ 'border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm p-2.5 pr-4': hasBackground && size === 'lg',
61
70
  'hover:border-gray-300 dark:hover:border-gray-700': hasBackground && isClickable,
62
71
  'rounded-xl': hasBackground,
63
72
  }"
64
- :to="to"
73
+ :to="disabled ? undefined : to"
65
74
  @click="onClick"
66
75
  >
67
76
  <div
@@ -76,20 +85,28 @@ function onClick(event: MouseEvent) {
76
85
 
77
86
  <span
78
87
  v-if="hasAvatarBox"
79
- class="relative z-10 h-9 w-9 flex items-center justify-center flex-initial"
88
+ class="relative z-10 flex items-center justify-center flex-initial"
80
89
  :class="{
81
90
  'border shadow-inner': !avatar && !image,
82
91
  'rounded-lg': !avatar && !image,
83
92
  'border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900': !avatar && !image,
84
- 'mr-2': direction === 'horizontal',
85
- 'mb-1': direction === 'vertical',
93
+ 'mr-2': direction === 'horizontal' && size === 'base',
94
+ 'mr-3': direction === 'horizontal' && size === 'lg',
95
+ 'mb-1': direction === 'vertical' && size === 'base',
96
+ 'mb-1.5': direction === 'vertical' && size === 'lg',
97
+ 'h-9 w-9': size === 'base',
98
+ 'h-11 w-11': size === 'lg',
86
99
  }"
87
100
  >
88
101
  <slot name="innerBoxLeft" />
89
102
 
90
103
  <BaseAvatar
91
104
  v-if="avatar"
92
- class="h-10 w-10 rounded-lg flex-initial"
105
+ class="rounded-lg flex-initial"
106
+ :class="{
107
+ 'h-10 w-10': size === 'base',
108
+ 'h-12 w-12': size === 'lg',
109
+ }"
93
110
  :src="avatar"
94
111
  />
95
112
 
@@ -98,18 +115,26 @@ function onClick(event: MouseEvent) {
98
115
  class="m-0.5"
99
116
  :emoji="emoji"
100
117
  :has-box="false"
118
+ :size="size === 'lg' ? 'lg' : 'base'"
101
119
  />
102
120
 
103
121
  <BaseIcon
104
122
  v-else-if="icon"
105
- class="text-lg"
123
+ :class="{
124
+ 'text-lg': size === 'base',
125
+ 'text-2xl': size === 'lg',
126
+ }"
106
127
  :color="color"
107
128
  :icon="icon"
108
129
  />
109
130
 
110
131
  <img
111
132
  v-else-if="image"
112
- class="h-9 w-9 rounded-lg object-cover shadow-sm drag-none flex-initial"
133
+ class="rounded-lg object-cover shadow-sm drag-none flex-initial"
134
+ :class="{
135
+ 'h-9 w-9': size === 'base',
136
+ 'h-11 w-11': size === 'lg',
137
+ }"
113
138
  :alt="title"
114
139
  loading="lazy"
115
140
  :src="image"
@@ -124,17 +149,24 @@ function onClick(event: MouseEvent) {
124
149
  <div
125
150
  class="min-w-0 flex flex-1 flex-col justify-center leading-snug"
126
151
  :class="{
127
- 'mr-4': direction === 'horizontal',
152
+ 'mr-4': direction === 'horizontal' && size === 'base',
153
+ 'mr-5': direction === 'horizontal' && size === 'lg',
128
154
  'items-center': direction === 'vertical',
129
155
  }"
130
156
  >
131
- <div class="w-full flex flex-col gap-0">
157
+ <div
158
+ class="w-full flex flex-col"
159
+ :class="{
160
+ 'gap-0': size === 'base',
161
+ 'gap-0.5': size === 'lg',
162
+ }"
163
+ >
132
164
  <BaseIcon
133
165
  v-if="title"
134
166
  class="w-full"
135
167
  :class="{ 'self-center': !hasAvatarBox }"
136
168
  :icon="titleIcon"
137
- size="base"
169
+ :size="size === 'lg' ? 'base' : 'sm'"
138
170
  :text="title"
139
171
  truncate
140
172
  />
@@ -142,8 +174,8 @@ function onClick(event: MouseEvent) {
142
174
  <BaseText
143
175
  v-if="description"
144
176
  block
145
- class="text-gray-600 leading-4 dark:text-gray-400"
146
- size="2xs"
177
+ class="text-gray-600 dark:text-gray-400"
178
+ :size="size === 'lg' ? 'xs' : '2xs'"
147
179
  :text="description"
148
180
  />
149
181
  </div>
@@ -155,7 +187,7 @@ function onClick(event: MouseEvent) {
155
187
  v-if="details"
156
188
  class="mt-0.5 whitespace-nowrap text-gray-700 font-semibold tracking-tighter dark:text-gray-300"
157
189
  :icon="detailsIcon"
158
- size="2xs"
190
+ :size="size === 'lg' ? 'xs' : '2xs'"
159
191
  :text="details"
160
192
  />
161
193
  </div>
@@ -163,14 +195,23 @@ function onClick(event: MouseEvent) {
163
195
 
164
196
  <BaseIcon
165
197
  v-if="isSelected"
166
- class="mr-1.5 self-center text-2xl flex-initial"
198
+ class="mr-1.5 self-center flex-initial"
199
+ :class="{
200
+ 'text-2xl': size === 'base',
201
+ 'text-3xl': size === 'lg',
202
+ }"
167
203
  color="green"
168
204
  :icon="getIcon('checkCircle')"
169
205
  />
170
206
 
171
207
  <BaseIcon
172
208
  v-else-if="to || hasChevron"
173
- 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"
209
+ class="self-center text-gray-500 flex-initial dark:text-gray-500"
210
+ :class="{
211
+ 'group-hover:text-gray-900 dark:group-hover:text-gray-100': isClickable,
212
+ 'text-xl': size === 'base',
213
+ 'text-2xl': size === 'lg',
214
+ }"
174
215
  :icon="getIcon('chevronRight')"
175
216
  />
176
217
 
@@ -63,7 +63,6 @@ function onNavigate(event: MouseEvent, direction: BaseDividerNavigateDirection)
63
63
  :icon="getIcon('chevronLeft')"
64
64
  size="xs"
65
65
  :text="t('previous')"
66
- :uppercase="false"
67
66
  @click="onNavigate($event, 'previous')"
68
67
  />
69
68
 
@@ -102,7 +101,6 @@ function onNavigate(event: MouseEvent, direction: BaseDividerNavigateDirection)
102
101
  :icon="getIcon('chevronRight')"
103
102
  size="xs"
104
103
  :text="t('next')"
105
- :uppercase="false"
106
104
  @click="onNavigate($event, 'next')"
107
105
  />
108
106
  </div>
@@ -16,7 +16,6 @@ const props = withDefaults(defineProps<BaseIcon>(), {
16
16
  to: undefined,
17
17
  truncate: false,
18
18
  underline: false,
19
- uppercase: true,
20
19
  })
21
20
 
22
21
  const emit = defineEmits<{
@@ -154,7 +153,6 @@ function onKeyDown(event: KeyboardEvent) {
154
153
  :text="confirming ? t('confirm') : text"
155
154
  :truncate="truncate"
156
155
  :underline="underline"
157
- :uppercase="uppercase"
158
156
  />
159
157
  </div>
160
158
  </template>
@@ -8,7 +8,6 @@ const props = withDefaults(defineProps<BaseSpinner>(), {
8
8
  reverse: false,
9
9
  size: 'base',
10
10
  text: '',
11
- uppercase: true,
12
11
  })
13
12
 
14
13
  const emit = defineEmits<{
@@ -80,7 +79,6 @@ function onKeyDown(event: KeyboardEvent) {
80
79
  :reverse="reverse"
81
80
  :size="size"
82
81
  :text="text"
83
- :uppercase="uppercase"
84
82
  />
85
83
  </div>
86
84
  </template>
@@ -16,7 +16,6 @@ const props = withDefaults(defineProps<BaseText>(), {
16
16
  to: undefined,
17
17
  truncate: false,
18
18
  underline: false,
19
- uppercase: false,
20
19
  })
21
20
 
22
21
  const emit = defineEmits<{
@@ -81,7 +80,7 @@ function onShowMore() {
81
80
  'bg-white dark:bg-gray-900': background === 'white',
82
81
  'block': block,
83
82
  'font-bold': bold,
84
- 'font-medium': uppercase || !bold,
83
+ 'font-medium': !bold,
85
84
  'ml-0.5': ['3xs'].includes(size) && !reverse && hasMargin,
86
85
  'ml-1.5': ['2xs', 'xs', 'sm'].includes(size) && !reverse && hasMargin,
87
86
  'ml-2.5': ['xl', '2xl'].includes(size) && !reverse && hasMargin,
@@ -92,23 +91,22 @@ function onShowMore() {
92
91
  'mr-2.5': ['xl', '2xl'].includes(size) && reverse && hasMargin,
93
92
  'mr-2': ['base', 'lg'].includes(size) && reverse && hasMargin,
94
93
  'mr-3': ['3xl', '4xl'].includes(size) && reverse && hasMargin,
95
- 'text-2xl': (size === '2xl' && !uppercase) || (size === '3xl' && uppercase),
96
- 'text-2xs': (size === '2xs') || (size === 'xs' && uppercase),
97
- 'text-3xl': (size === '3xl' && !uppercase) || (size === '4xl' && uppercase),
98
- 'text-3xs': (size === '3xs') && !uppercase,
99
- 'text-4xl': size === '4xl' && !uppercase,
100
- 'text-base': (size === 'base' && !uppercase) || (size === 'lg' && uppercase),
94
+ 'text-2xl': size === '2xl',
95
+ 'text-2xs': size === '2xs',
96
+ 'text-3xl': size === '3xl',
97
+ 'text-3xs': size === '3xs',
98
+ 'text-4xl': size === '4xl',
99
+ 'text-base': size === 'base',
101
100
  'text-center': alignment === 'center',
102
101
  'text-left': alignment === 'left',
103
- 'text-lg': (size === 'lg' && !uppercase) || (size === 'xl' && uppercase),
102
+ 'text-lg': size === 'lg',
104
103
  'text-right': alignment === 'right',
105
- 'text-sm': (size === 'sm' && !uppercase) || (size === 'base' && uppercase),
106
- 'text-xl': (size === 'xl' && !uppercase) || (size === '2xl' && uppercase),
107
- 'text-xs': (size === 'xs' && !uppercase) || (size === 'sm' && uppercase),
104
+ 'text-sm': size === 'sm',
105
+ 'text-xl': size === 'xl',
106
+ 'text-xs': size === 'xs',
108
107
  'truncate': truncate,
109
108
  'underline text-indigo-700 dark:text-indigo-300': !!to,
110
109
  'underline': underline,
111
- 'uppercase': uppercase,
112
110
  'whitespace-nowrap': noWrap,
113
111
  }"
114
112
  :to="to"
@@ -31,7 +31,7 @@ function onClose(event: KeyboardEvent | MouseEvent) {
31
31
 
32
32
  <template>
33
33
  <div
34
- class="group flex select-none items-center border border-gray-200 rounded-full bg-white px-3 py-2 text-base text-gray-800 font-normal shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
34
+ class="group flex select-none items-center border border-gray-200 rounded-full bg-white px-3 py-2 text-base text-gray-800 font-normal uppercase shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
35
35
  :class="{ 'cursor-pointer': hasClose }"
36
36
  role="button"
37
37
  tabindex="0"
@@ -47,7 +47,7 @@ function onClose(event: KeyboardEvent | MouseEvent) {
47
47
 
48
48
  <button
49
49
  v-if="action"
50
- class="ml-1.5 min-h-6 inline-flex items-center justify-center gap-1.25 rounded-md px-2 py-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-800"
50
+ class="ml-2 min-h-6 inline-flex items-center justify-center gap-1.25 rounded-md px-2 py-0.5 uppercase transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-800"
51
51
  :class="{
52
52
  'bg-red-100 text-red-700 hover:bg-red-200 focus-visible:ring-red-500 dark:bg-red-400/15 dark:text-red-400 dark:hover:bg-red-400/25': status === 'error',
53
53
  'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 focus-visible:ring-indigo-500 dark:bg-indigo-400/15 dark:text-indigo-400 dark:hover:bg-indigo-400/25': status === 'info',
@@ -58,12 +58,10 @@ function onClose(event: KeyboardEvent | MouseEvent) {
58
58
  @click.stop="onAction"
59
59
  >
60
60
  <BaseText
61
- bold
62
61
  class="pointer-events-none leading-none"
63
62
  no-wrap
64
63
  size="sm"
65
64
  :text="action.label"
66
- uppercase
67
65
  />
68
66
 
69
67
  <BaseShortcut
@@ -23,6 +23,19 @@ const isClickable = computed(() => {
23
23
  return !props.loading && !props.disabled
24
24
  })
25
25
 
26
+ const textSize = computed<BaseSize>(() => {
27
+ switch (props.size) {
28
+ case 'lg':
29
+ return 'base'
30
+ case 'sm':
31
+ return 'xs'
32
+ case 'xs':
33
+ return '2xs'
34
+ default:
35
+ return 'sm'
36
+ }
37
+ })
38
+
26
39
  function onClick(event: MouseEvent) {
27
40
  emit('click', event)
28
41
  }
@@ -71,14 +84,14 @@ function onKeyDown(event: KeyboardEvent) {
71
84
  >
72
85
  <BaseIcon
73
86
  :icon="icon"
74
- :size="size"
87
+ :size="textSize"
75
88
  :text="label"
76
89
  />
77
90
 
78
91
  <BaseText
79
92
  v-if="required"
80
93
  class="ml-1 text-red-700 dark:text-red-300"
81
- :size="size"
94
+ :size="textSize"
82
95
  text="*"
83
96
  />
84
97
  </component>
@@ -120,6 +120,19 @@ function ruleIsInvalid(rule: unknown) {
120
120
 
121
121
  return false
122
122
  }
123
+
124
+ const textSize = computed<BaseSize>(() => {
125
+ switch (props.size) {
126
+ case 'lg':
127
+ return 'sm'
128
+ case 'sm':
129
+ return 'xs'
130
+ case 'xs':
131
+ return '2xs'
132
+ default:
133
+ return 'xs'
134
+ }
135
+ })
123
136
  </script>
124
137
 
125
138
  <template>
@@ -134,7 +147,7 @@ function ruleIsInvalid(rule: unknown) {
134
147
  'mt-3.5': size === 'lg',
135
148
  }"
136
149
  :color="status === 'error' ? 'red' : 'gray'"
137
- size="sm"
150
+ :size="textSize"
138
151
  :text="text"
139
152
  />
140
153
  </template>
@@ -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,144 @@
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 fixedElementPadding = new Map<HTMLElement, string>()
12
+
13
+ let savedBodyOverflow = ''
14
+ let savedBodyPaddingRight = ''
15
+
16
+ function getFocusableElements(container: HTMLElement) {
17
+ return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
18
+ .filter((element) => {
19
+ return element.offsetParent !== null || getComputedStyle(element).position === 'fixed'
20
+ })
21
+ }
22
+
23
+ function lockBodyScroll() {
24
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
25
+
26
+ savedBodyOverflow = document.body.style.overflow
27
+ savedBodyPaddingRight = document.body.style.paddingRight
28
+ document.body.style.overflow = 'hidden'
29
+
30
+ if (scrollbarWidth > 0) {
31
+ document.body.style.paddingRight = `${scrollbarWidth}px`
32
+
33
+ for (const element of document.querySelectorAll('body *')) {
34
+ if (!(element instanceof HTMLElement)) {
35
+ continue
36
+ }
37
+
38
+ // Skip the dialog's own teleported elements so they are not padded.
39
+ if (element.classList.contains(options.ignoreClass)) {
40
+ continue
41
+ }
42
+
43
+ const { position } = getComputedStyle(element)
44
+
45
+ if (position !== 'fixed' && position !== 'sticky') {
46
+ continue
47
+ }
48
+
49
+ fixedElementPadding.set(element, element.style.paddingRight)
50
+
51
+ const currentPadding = Number.parseFloat(getComputedStyle(element).paddingRight) || 0
52
+
53
+ element.style.paddingRight = `${currentPadding + scrollbarWidth}px`
54
+ }
55
+ }
56
+ }
57
+
58
+ function onFocusTrap(event: KeyboardEvent) {
59
+ if (event.key !== 'Tab' || !panelRef.value) {
60
+ return
61
+ }
62
+
63
+ const focusableElements = getFocusableElements(panelRef.value)
64
+
65
+ if (focusableElements.length === 0) {
66
+ event.preventDefault()
67
+ panelRef.value.focus()
68
+
69
+ return
70
+ }
71
+
72
+ const firstElement = focusableElements[0]
73
+ const lastElement = focusableElements[focusableElements.length - 1]
74
+ const activeElement = document.activeElement
75
+
76
+ if (event.shiftKey && activeElement === firstElement) {
77
+ event.preventDefault()
78
+ lastElement.focus()
79
+ }
80
+ else if (!event.shiftKey && activeElement === lastElement) {
81
+ event.preventDefault()
82
+ firstElement.focus()
83
+ }
84
+ }
85
+
86
+ function onKeydown(event: KeyboardEvent) {
87
+ if (visible.value && event.key === 'Escape') {
88
+ visible.value = false
89
+ }
90
+ }
91
+
92
+ function unlockBodyScroll() {
93
+ document.body.style.overflow = savedBodyOverflow
94
+ document.body.style.paddingRight = savedBodyPaddingRight
95
+
96
+ for (const [element, paddingRight] of fixedElementPadding) {
97
+ element.style.paddingRight = paddingRight
98
+ }
99
+
100
+ fixedElementPadding.clear()
101
+ }
102
+
103
+ // Restores scroll lock and focus trap once the leave transition has finished.
104
+ function release() {
105
+ if (import.meta.client) {
106
+ unlockBodyScroll()
107
+ window.removeEventListener('keydown', onFocusTrap)
108
+ }
109
+ }
110
+
111
+ watch(visible, async (isVisible) => {
112
+ if (import.meta.client && isVisible) {
113
+ lockBodyScroll()
114
+
115
+ await nextTick()
116
+
117
+ if (panelRef.value) {
118
+ const focusableElements = getFocusableElements(panelRef.value)
119
+
120
+ if (focusableElements.length > 0) {
121
+ focusableElements[0].focus()
122
+ }
123
+ else {
124
+ panelRef.value.focus()
125
+ }
126
+
127
+ window.addEventListener('keydown', onFocusTrap)
128
+ }
129
+ }
130
+ }, { immediate: true })
131
+
132
+ onMounted(() => window.addEventListener('keydown', onKeydown))
133
+
134
+ onUnmounted(() => {
135
+ window.removeEventListener('keydown', onKeydown)
136
+ window.removeEventListener('keydown', onFocusTrap)
137
+
138
+ if (import.meta.client) {
139
+ unlockBodyScroll()
140
+ }
141
+ })
142
+
143
+ return { release }
144
+ }
@@ -80,6 +80,7 @@ export interface BaseCard {
80
80
  details?: string
81
81
  detailsIcon?: string
82
82
  direction?: BaseCardDirection
83
+ disabled?: boolean
83
84
  emoji?: string
84
85
  hasBackground?: boolean
85
86
  hasChevron?: boolean
@@ -87,6 +88,7 @@ export interface BaseCard {
87
88
  id?: number | string
88
89
  image?: string
89
90
  isSelected?: boolean
91
+ size?: BaseCardSize
90
92
  title?: string
91
93
  titleIcon?: string
92
94
  to?: RouteLocationNamedI18n
@@ -94,6 +96,8 @@ export interface BaseCard {
94
96
 
95
97
  export type BaseCardDirection = 'horizontal' | 'vertical'
96
98
 
99
+ export type BaseCardSize = 'base' | 'lg'
100
+
97
101
  export interface BaseCharacter {
98
102
  character?: BaseCharacterCharacter
99
103
  size?: BaseCharacterSize
@@ -187,7 +191,6 @@ export interface BaseIcon {
187
191
  to?: RouteLocationNamedI18n
188
192
  truncate?: boolean
189
193
  underline?: boolean
190
- uppercase?: boolean
191
194
  }
192
195
 
193
196
  export interface BaseMessage {
@@ -321,7 +324,6 @@ export interface BaseSpinner {
321
324
  reverse?: boolean
322
325
  size?: BaseSize
323
326
  text?: BaseTextText
324
- uppercase?: boolean
325
327
  }
326
328
 
327
329
  export type BaseStatus = | 'error' | 'info' | 'success' | 'warning'
@@ -382,7 +384,6 @@ export interface BaseText {
382
384
  to?: RouteLocationNamedI18n
383
385
  truncate?: boolean
384
386
  underline?: boolean
385
- uppercase?: boolean
386
387
  }
387
388
 
388
389
  export type BaseTextText = string | {
@@ -14,6 +14,7 @@ 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
17
18
  type BaseCharacter = import('./bases').BaseCharacter
18
19
  type BaseCharacterCharacter = import('./bases').BaseCharacterCharacter
19
20
  type BaseCharacterSize = import('./bases').BaseCharacterSize
@@ -94,6 +95,7 @@ declare global {
94
95
 
95
96
  // Layout
96
97
  type LayoutBottomSheet = import('./layout').LayoutBottomSheet
98
+ type LayoutModal = import('./layout').LayoutModal
97
99
 
98
100
  // Project
99
101
  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.53",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",