@saasmakers/ui 1.4.50 → 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.
@@ -115,6 +115,9 @@ async function onClose(event: MouseEvent) {
115
115
  "fr": {
116
116
  "closeThisMessage": "Fermer ce message"
117
117
  },
118
+ "id": {
119
+ "closeThisMessage": "Tutup pesan ini"
120
+ },
118
121
  "it": {
119
122
  "closeThisMessage": "Chiudi questo messaggio"
120
123
  },
@@ -136,9 +139,6 @@ async function onClose(event: MouseEvent) {
136
139
  "pt-BR": {
137
140
  "closeThisMessage": "Fechar esta mensagem"
138
141
  },
139
- "id": {
140
- "closeThisMessage": "Tutup pesan ini"
141
- },
142
142
  "vi": {
143
143
  "closeThisMessage": "Đóng thông báo này"
144
144
  }
@@ -172,6 +172,10 @@ function onMouseLeave() {
172
172
  "edit": "Modifier",
173
173
  "fileSizeTooLarge": "Le fichier est trop grand (max: {maxSizeMb}MB)"
174
174
  },
175
+ "id": {
176
+ "edit": "Edit",
177
+ "fileSizeTooLarge": "File terlalu besar (maks.: {maxSizeMb}MB)"
178
+ },
175
179
  "it": {
176
180
  "edit": "Modifica",
177
181
  "fileSizeTooLarge": "Il file è troppo grande (max: {maxSizeMb}MB)"
@@ -200,10 +204,6 @@ function onMouseLeave() {
200
204
  "edit": "Editar",
201
205
  "fileSizeTooLarge": "O arquivo é muito grande (máx.: {maxSizeMb}MB)"
202
206
  },
203
- "id": {
204
- "edit": "Edit",
205
- "fileSizeTooLarge": "File terlalu besar (maks.: {maxSizeMb}MB)"
206
- },
207
207
  "vi": {
208
208
  "edit": "Chỉnh sửa",
209
209
  "fileSizeTooLarge": "Tệp quá lớn (tối đa: {maxSizeMb}MB)"
@@ -150,6 +150,9 @@ function onClick(event: MouseEvent) {
150
150
  "fr": {
151
151
  "confirm": "Confirmer ?"
152
152
  },
153
+ "id": {
154
+ "confirm": "Konfirmasi?"
155
+ },
153
156
  "it": {
154
157
  "confirm": "Confermare?"
155
158
  },
@@ -171,9 +174,6 @@ function onClick(event: MouseEvent) {
171
174
  "pt-BR": {
172
175
  "confirm": "Confirmar?"
173
176
  },
174
- "id": {
175
- "confirm": "Konfirmasi?"
176
- },
177
177
  "vi": {
178
178
  "confirm": "Xác nhận?"
179
179
  }
@@ -37,7 +37,10 @@ defineSlots<{
37
37
  }>()
38
38
 
39
39
  const { getIcon } = useLayerIcons()
40
- const hasAvatarBox = computed<boolean>(() => !!(props.avatar || props.emoji || props.icon || props.image))
40
+
41
+ const hasAvatarBox = computed<boolean>(() => {
42
+ return !!(props.avatar || props.emoji || props.icon || props.image)
43
+ })
41
44
 
42
45
  const isClickable = computed(() => {
43
46
  return props.to || props.clickable
@@ -132,6 +132,11 @@ function onNavigate(event: MouseEvent, direction: BaseDividerNavigateDirection)
132
132
  "next": "Suivant",
133
133
  "previous": "Précédent"
134
134
  },
135
+ "id": {
136
+ "loading": "Memuat…",
137
+ "next": "Berikutnya",
138
+ "previous": "Sebelumnya"
139
+ },
135
140
  "it": {
136
141
  "loading": "Caricamento…",
137
142
  "next": "Successivo",
@@ -167,11 +172,6 @@ function onNavigate(event: MouseEvent, direction: BaseDividerNavigateDirection)
167
172
  "next": "Próximo",
168
173
  "previous": "Anterior"
169
174
  },
170
- "id": {
171
- "loading": "Memuat…",
172
- "next": "Berikutnya",
173
- "previous": "Sebelumnya"
174
- },
175
175
  "vi": {
176
176
  "loading": "Đang tải…",
177
177
  "next": "Sau",
@@ -173,6 +173,9 @@ function onKeyDown(event: KeyboardEvent) {
173
173
  "fr": {
174
174
  "confirm": "Confirmer ?"
175
175
  },
176
+ "id": {
177
+ "confirm": "Konfirmasi?"
178
+ },
176
179
  "it": {
177
180
  "confirm": "Confermare?"
178
181
  },
@@ -194,9 +197,6 @@ function onKeyDown(event: KeyboardEvent) {
194
197
  "pt-BR": {
195
198
  "confirm": "Confirmar?"
196
199
  },
197
- "id": {
198
- "confirm": "Konfirmasi?"
199
- },
200
200
  "vi": {
201
201
  "confirm": "Xác nhận?"
202
202
  }
@@ -170,6 +170,9 @@ function onClose(event: MouseEvent) {
170
170
  "fr": {
171
171
  "close": "Fermer"
172
172
  },
173
+ "id": {
174
+ "close": "Tutup"
175
+ },
173
176
  "it": {
174
177
  "close": "Chiudi"
175
178
  },
@@ -191,9 +194,6 @@ function onClose(event: MouseEvent) {
191
194
  "pt-BR": {
192
195
  "close": "Fechar"
193
196
  },
194
- "id": {
195
- "close": "Tutup"
196
- },
197
197
  "vi": {
198
198
  "close": "Đóng"
199
199
  }
@@ -241,6 +241,11 @@ function onUpdateTag(event: FocusEvent | KeyboardEvent, name: string, tagId?: nu
241
241
  "newTag": "Nouveau tag",
242
242
  "showMore": "& {value} autre | & {value} autres"
243
243
  },
244
+ "id": {
245
+ "cancel": "Batal",
246
+ "newTag": "Tag baru",
247
+ "showMore": "& {value} lainnya | & {value} lainnya"
248
+ },
244
249
  "it": {
245
250
  "cancel": "Annulla",
246
251
  "newTag": "Nuovo tag",
@@ -276,11 +281,6 @@ function onUpdateTag(event: FocusEvent | KeyboardEvent, name: string, tagId?: nu
276
281
  "newTag": "Nova tag",
277
282
  "showMore": "& {value} outro | & {value} outros"
278
283
  },
279
- "id": {
280
- "cancel": "Batal",
281
- "newTag": "Tag baru",
282
- "showMore": "& {value} lainnya | & {value} lainnya"
283
- },
284
284
  "vi": {
285
285
  "cancel": "Hủy",
286
286
  "newTag": "Thẻ mới",
@@ -155,6 +155,9 @@ function onShowMore() {
155
155
  "fr": {
156
156
  "showMore": "(afficher)"
157
157
  },
158
+ "id": {
159
+ "showMore": "(tampilkan lebih banyak)"
160
+ },
158
161
  "it": {
159
162
  "showMore": "(mostra altro)"
160
163
  },
@@ -176,9 +179,6 @@ function onShowMore() {
176
179
  "pt-BR": {
177
180
  "showMore": "(mostrar mais)"
178
181
  },
179
- "id": {
180
- "showMore": "(tampilkan lebih banyak)"
181
- },
182
182
  "vi": {
183
183
  "showMore": "(xem thêm)"
184
184
  }
@@ -68,13 +68,13 @@ function onOpenFilePicker() {
68
68
  :for-field="id"
69
69
  has-margin-bottom
70
70
  :icon="labelIcon"
71
- :label="props.label || t('label')"
71
+ :label="label || t('label')"
72
72
  :required="required"
73
73
  :size="size"
74
74
  />
75
75
 
76
76
  <div
77
- :aria-label="loading ? t('actionLoading') : (typeof props.action === 'string' ? props.action : (props.action?.base || t('action')))"
77
+ :aria-label="loading ? t('actionLoading') : (typeof action === 'string' ? action : (action?.base || t('action')))"
78
78
  class="w-full flex items-center gap-2 px-3 normal-case outline-none"
79
79
  :class="{
80
80
  'cursor-pointer': !disabled && !loading,
@@ -131,7 +131,7 @@ function onOpenFilePicker() {
131
131
 
132
132
  <BaseText
133
133
  class="min-w-0 flex-1 text-gray-900 dark:text-gray-100"
134
- :text="loading ? t('actionLoading') : (props.action || t('action'))"
134
+ :text="loading ? t('actionLoading') : (action || t('action'))"
135
135
  />
136
136
  </div>
137
137
 
@@ -159,81 +159,81 @@ function onOpenFilePicker() {
159
159
  <i18n lang="json">
160
160
  {
161
161
  "de": {
162
- "fileSizeTooLarge": "Die Datei ist zu groß (max.: {maxSizeMb}MB)",
163
162
  "action": "Foto ändern",
164
163
  "actionLoading": "Foto wird aktualisiert...",
164
+ "fileSizeTooLarge": "Die Datei ist zu groß (max.: {maxSizeMb}MB)",
165
165
  "label": "Profilbild"
166
166
  },
167
167
  "en": {
168
- "fileSizeTooLarge": "The file is too large (max: {maxSizeMb}MB)",
169
168
  "action": "Change photo",
170
169
  "actionLoading": "Updating photo...",
170
+ "fileSizeTooLarge": "The file is too large (max: {maxSizeMb}MB)",
171
171
  "label": "Profile picture"
172
172
  },
173
173
  "es": {
174
- "fileSizeTooLarge": "El archivo es demasiado grande (máx.: {maxSizeMb}MB)",
175
174
  "action": "Cambiar foto",
176
175
  "actionLoading": "Actualizando foto...",
176
+ "fileSizeTooLarge": "El archivo es demasiado grande (máx.: {maxSizeMb}MB)",
177
177
  "label": "Foto de perfil"
178
178
  },
179
179
  "fr": {
180
- "fileSizeTooLarge": "Le fichier est trop grand (max: {maxSizeMb}MB)",
181
180
  "action": "Changer la photo",
182
181
  "actionLoading": "Mise a jour de la photo...",
182
+ "fileSizeTooLarge": "Le fichier est trop grand (max: {maxSizeMb}MB)",
183
183
  "label": "Photo de profil"
184
184
  },
185
+ "id": {
186
+ "action": "Ganti foto",
187
+ "actionLoading": "Memperbarui foto...",
188
+ "fileSizeTooLarge": "File terlalu besar (maks.: {maxSizeMb}MB)",
189
+ "label": "Foto profil"
190
+ },
185
191
  "it": {
186
- "fileSizeTooLarge": "Il file è troppo grande (max: {maxSizeMb}MB)",
187
192
  "action": "Cambia foto",
188
193
  "actionLoading": "Aggiornamento foto...",
194
+ "fileSizeTooLarge": "Il file è troppo grande (max: {maxSizeMb}MB)",
189
195
  "label": "Foto del profilo"
190
196
  },
191
197
  "ja": {
192
- "fileSizeTooLarge": "ファイルが大きすぎます (最大: {maxSizeMb}MB)",
193
198
  "action": "写真を変更",
194
199
  "actionLoading": "写真を更新中...",
200
+ "fileSizeTooLarge": "ファイルが大きすぎます (最大: {maxSizeMb}MB)",
195
201
  "label": "プロフィール写真"
196
202
  },
197
203
  "ko": {
198
- "fileSizeTooLarge": "파일이 너무 큽니다 (최대: {maxSizeMb}MB)",
199
204
  "action": "사진 변경",
200
205
  "actionLoading": "사진 업데이트 중...",
206
+ "fileSizeTooLarge": "파일이 너무 큽니다 (최대: {maxSizeMb}MB)",
201
207
  "label": "프로필 사진"
202
208
  },
203
209
  "nl": {
204
- "fileSizeTooLarge": "Het bestand is te groot (max.: {maxSizeMb}MB)",
205
210
  "action": "Foto wijzigen",
206
211
  "actionLoading": "Foto wordt bijgewerkt...",
212
+ "fileSizeTooLarge": "Het bestand is te groot (max.: {maxSizeMb}MB)",
207
213
  "label": "Profielfoto"
208
214
  },
209
215
  "pl": {
210
- "fileSizeTooLarge": "Plik jest za duży (maks.: {maxSizeMb}MB)",
211
216
  "action": "Zmień zdjęcie",
212
217
  "actionLoading": "Aktualizowanie zdjęcia...",
218
+ "fileSizeTooLarge": "Plik jest za duży (maks.: {maxSizeMb}MB)",
213
219
  "label": "Zdjęcie profilowe"
214
220
  },
215
221
  "pt": {
216
- "fileSizeTooLarge": "O ficheiro é demasiado grande (máx.: {maxSizeMb}MB)",
217
222
  "action": "Alterar foto",
218
223
  "actionLoading": "A atualizar foto...",
224
+ "fileSizeTooLarge": "O ficheiro é demasiado grande (máx.: {maxSizeMb}MB)",
219
225
  "label": "Foto de perfil"
220
226
  },
221
227
  "pt-BR": {
222
- "fileSizeTooLarge": "O arquivo é muito grande (máx.: {maxSizeMb}MB)",
223
228
  "action": "Alterar foto",
224
229
  "actionLoading": "Atualizando foto...",
230
+ "fileSizeTooLarge": "O arquivo é muito grande (máx.: {maxSizeMb}MB)",
225
231
  "label": "Foto de perfil"
226
232
  },
227
- "id": {
228
- "fileSizeTooLarge": "File terlalu besar (maks.: {maxSizeMb}MB)",
229
- "action": "Ganti foto",
230
- "actionLoading": "Memperbarui foto...",
231
- "label": "Foto profil"
232
- },
233
233
  "vi": {
234
- "fileSizeTooLarge": "Tệp quá lớn (tối đa: {maxSizeMb}MB)",
235
234
  "action": "Đổi ảnh",
236
235
  "actionLoading": "Dang cap nhat anh...",
236
+ "fileSizeTooLarge": "Tệp quá lớn (tối đa: {maxSizeMb}MB)",
237
237
  "label": "Ảnh đại diện"
238
238
  }
239
239
  }
@@ -125,6 +125,17 @@ function onUpdateDays(event: MouseEvent, day: number) {
125
125
  "wednesday": "Mercredi"
126
126
  }
127
127
  },
128
+ "id": {
129
+ "days": {
130
+ "friday": "Jumat",
131
+ "monday": "Senin",
132
+ "saturday": "Sabtu",
133
+ "sunday": "Minggu",
134
+ "thursday": "Kamis",
135
+ "tuesday": "Selasa",
136
+ "wednesday": "Rabu"
137
+ }
138
+ },
128
139
  "it": {
129
140
  "days": {
130
141
  "friday": "Venerdì",
@@ -202,17 +213,6 @@ function onUpdateDays(event: MouseEvent, day: number) {
202
213
  "wednesday": "Quarta-feira"
203
214
  }
204
215
  },
205
- "id": {
206
- "days": {
207
- "friday": "Jumat",
208
- "monday": "Senin",
209
- "saturday": "Sabtu",
210
- "sunday": "Minggu",
211
- "thursday": "Kamis",
212
- "tuesday": "Selasa",
213
- "wednesday": "Rabu"
214
- }
215
- },
216
216
  "vi": {
217
217
  "days": {
218
218
  "friday": "Thứ Sáu",
@@ -163,6 +163,21 @@ function onEmojiClick(event: MouseEvent, emoji?: string) {
163
163
  },
164
164
  "searchEmoji": "Rechercher un emoji (en anglais)"
165
165
  },
166
+ "id": {
167
+ "categories": {
168
+ "activity": "Aktivitas",
169
+ "diversity": "Warna kulit",
170
+ "flags": "Bendera",
171
+ "food": "Makanan & minuman",
172
+ "nature": "Hewan & alam",
173
+ "objects": "Benda",
174
+ "people": "Smiley & orang",
175
+ "regional": "Regional",
176
+ "symbol": "Simbol",
177
+ "travel": "Perjalanan & tempat"
178
+ },
179
+ "searchEmoji": "Cari emoji"
180
+ },
166
181
  "it": {
167
182
  "categories": {
168
183
  "activity": "Attività",
@@ -268,21 +283,6 @@ function onEmojiClick(event: MouseEvent, emoji?: string) {
268
283
  },
269
284
  "searchEmoji": "Pesquisar um emoji"
270
285
  },
271
- "id": {
272
- "categories": {
273
- "activity": "Aktivitas",
274
- "diversity": "Warna kulit",
275
- "flags": "Bendera",
276
- "food": "Makanan & minuman",
277
- "nature": "Hewan & alam",
278
- "objects": "Benda",
279
- "people": "Smiley & orang",
280
- "regional": "Regional",
281
- "symbol": "Simbol",
282
- "travel": "Perjalanan & tempat"
283
- },
284
- "searchEmoji": "Cari emoji"
285
- },
286
286
  "vi": {
287
287
  "categories": {
288
288
  "activity": "Hoạt động",
@@ -217,6 +217,25 @@ function ruleIsInvalid(rule: unknown) {
217
217
  "sameAs": "La valeur ne correspond pas à: {field}",
218
218
  "url": "La valeur n'est pas une URL valide"
219
219
  },
220
+ "id": {
221
+ "alpha": "Nilai hanya menerima huruf",
222
+ "alphaNum": "Nilai hanya menerima karakter alfanumerik",
223
+ "between": "Nilai harus antara {min} dan {max}",
224
+ "decimal": "Nilai hanya menerima bilangan desimal positif dan negatif",
225
+ "email": "Nilai bukan email yang valid",
226
+ "integer": "Nilai hanya menerima bilangan bulat positif dan negatif",
227
+ "invalid": "Nilai tidak valid",
228
+ "ipAddress": "Nilai hanya menerima alamat IPv4 yang valid",
229
+ "macAddress": "Nilai hanya menerima alamat MAC yang valid",
230
+ "maxLength": "Nilai terlalu panjang (maks.: {max})",
231
+ "maxValue": "Nilai maksimum yang diizinkan: {max}",
232
+ "minLength": "Nilai terlalu pendek (min.: {min})",
233
+ "minValue": "Nilai minimum yang diizinkan: {min}",
234
+ "numeric": "Nilai hanya menerima angka",
235
+ "required": "Nilai wajib diisi",
236
+ "sameAs": "Nilai tidak cocok dengan: {field}",
237
+ "url": "Nilai bukan URL yang valid"
238
+ },
220
239
  "it": {
221
240
  "alpha": "Il valore accetta solo caratteri alfabetici",
222
241
  "alphaNum": "Il valore accetta solo caratteri alfanumerici",
@@ -350,25 +369,6 @@ function ruleIsInvalid(rule: unknown) {
350
369
  "sameAs": "O valor não corresponde a: {field}",
351
370
  "url": "O valor não é uma URL válida"
352
371
  },
353
- "id": {
354
- "alpha": "Nilai hanya menerima huruf",
355
- "alphaNum": "Nilai hanya menerima karakter alfanumerik",
356
- "between": "Nilai harus antara {min} dan {max}",
357
- "decimal": "Nilai hanya menerima bilangan desimal positif dan negatif",
358
- "email": "Nilai bukan email yang valid",
359
- "integer": "Nilai hanya menerima bilangan bulat positif dan negatif",
360
- "invalid": "Nilai tidak valid",
361
- "ipAddress": "Nilai hanya menerima alamat IPv4 yang valid",
362
- "macAddress": "Nilai hanya menerima alamat MAC yang valid",
363
- "maxLength": "Nilai terlalu panjang (maks.: {max})",
364
- "maxValue": "Nilai maksimum yang diizinkan: {max}",
365
- "minLength": "Nilai terlalu pendek (min.: {min})",
366
- "minValue": "Nilai minimum yang diizinkan: {min}",
367
- "numeric": "Nilai hanya menerima angka",
368
- "required": "Nilai wajib diisi",
369
- "sameAs": "Nilai tidak cocok dengan: {field}",
370
- "url": "Nilai bukan URL yang valid"
371
- },
372
372
  "vi": {
373
373
  "alpha": "Giá trị chỉ chấp nhận chữ cái",
374
374
  "alphaNum": "Giá trị chỉ chấp nhận ký tự chữ và số",
@@ -396,6 +396,10 @@ function selectOption(event: MouseEvent, value: string) {
396
396
  "noResults": "Aucun résultat",
397
397
  "search": "Rechercher"
398
398
  },
399
+ "id": {
400
+ "noResults": "Tidak ada hasil",
401
+ "search": "Cari"
402
+ },
399
403
  "it": {
400
404
  "noResults": "Nessun risultato",
401
405
  "search": "Cerca"
@@ -424,10 +428,6 @@ function selectOption(event: MouseEvent, value: string) {
424
428
  "noResults": "Nenhum resultado",
425
429
  "search": "Pesquisar"
426
430
  },
427
- "id": {
428
- "noResults": "Tidak ada hasil",
429
- "search": "Cari"
430
- },
431
431
  "vi": {
432
432
  "noResults": "Không có kết quả",
433
433
  "search": "Tìm kiếm"
@@ -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,55 +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
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
-
170
+ watch(visible, (isVisible) => {
300
171
  if (!isVisible) {
301
172
  resetDragState()
302
173
  }
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
174
  })
315
175
  </script>
316
176
 
@@ -347,7 +207,7 @@ onUnmounted(() => {
347
207
  leave-active-class="transition-transform duration-300 ease motion-reduce:transition-none motion-reduce:duration-0"
348
208
  leave-from-class="translate-y-0"
349
209
  leave-to-class="translate-y-full motion-reduce:translate-y-0"
350
- @after-leave="onPanelAfterLeave"
210
+ @after-leave="onAfterLeave"
351
211
  >
352
212
  <div
353
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/nuxt.config.ts CHANGED
@@ -5,9 +5,11 @@ import { defineNuxtConfig } from 'nuxt/config'
5
5
  import uno from './uno.config.js'
6
6
 
7
7
  const currentDir = path.dirname(fileURLToPath(import.meta.url))
8
+ const isPostHogEnabled = process.env.NODE_ENV === 'production' && Boolean(process.env.NUXT_PUBLIC_POSTHOG_KEY)
8
9
 
9
10
  export default defineNuxtConfig({
10
11
  modules: [
12
+ '@posthog/nuxt',
11
13
  '@nuxt/icon',
12
14
  '@nuxt/scripts',
13
15
  '@nuxtjs/color-mode',
@@ -88,6 +90,20 @@ export default defineNuxtConfig({
88
90
  experimental: { typedPages: true },
89
91
  },
90
92
 
93
+ posthogConfig: {
94
+ clientConfig: {
95
+ capture_pageview: isPostHogEnabled,
96
+ defaults: '2026-01-30',
97
+ disable_session_recording: !isPostHogEnabled,
98
+ opt_out_capturing_by_default: !isPostHogEnabled,
99
+ person_profiles: 'identified_only',
100
+ session_recording: { maskAllInputs: true },
101
+ ui_host: 'https://eu.posthog.com',
102
+ },
103
+ host: 'https://eu.i.posthog.com',
104
+ publicKey: isPostHogEnabled ? process.env.NUXT_PUBLIC_POSTHOG_KEY : '',
105
+ },
106
+
91
107
  plausible: {
92
108
  apiHost: 'https://plausible.saasmakers.dev',
93
109
  autoOutboundTracking: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "1.4.50",
3
+ "version": "1.4.52",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "@nuxtjs/robots": "6.1.0",
31
31
  "@nuxtjs/sitemap": "8.2.0",
32
32
  "@pinia/nuxt": "0.11.3",
33
+ "@posthog/nuxt": "1.7.76",
33
34
  "@unhead/vue": "2.0.19",
34
35
  "@unocss/nuxt": "66.7.0",
35
36
  "@unocss/reset": "66.7.0",