PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.8
Booking for Appointments and Events Calendar – Amelia v2.4.8
2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / v3 / src / views / _components / slide-popup / AmSlidePopup.vue
ameliabooking / v3 / src / views / _components / slide-popup Last commit date
AmSlidePopup.vue 1 week ago
AmSlidePopup.vue
449 lines
1 <template>
2 <transition duration="550" name="nested">
3 <div
4 v-show="visibility"
5 class="am-slide-popup__block"
6 :class="`am-position-${position}`"
7 :style="{ ...cssVars, ...customCss }"
8 >
9 <div
10 ref="dialogRef"
11 v-click-outside="onClickOutside"
12 class="am-slide-popup__block-inner"
13 :class="[
14 { 'am-slide-popup__up-inner-mobile': checkScreen },
15 customClass,
16 `am-position-${position}`,
17 ]"
18 role="dialog"
19 aria-modal="true"
20 :aria-label="props.ariaLabel || undefined"
21 :aria-labelledby="props.ariaLabelledby || undefined"
22 tabindex="-1"
23 @keydown.tab="onTab"
24 @keydown.esc="onEscape"
25 >
26 <div v-if="popupHeaderVisibility" class="am-slide-popup__block-header">
27 <slot name="header"></slot>
28 <span
29 class="am-icon-close"
30 tabindex="0"
31 role="button"
32 aria-label="Close"
33 @click="emits('update:visibility', false)"
34 @keydown.enter="emits('update:visibility', false)"
35 @keydown.space.prevent="emits('update:visibility', false)"
36 />
37 </div>
38 <slot></slot>
39 <div v-if="props.footerVisibility" class="am-slide-popup__block-footer">
40 <slot name="footer"></slot>
41 </div>
42 </div>
43 </div>
44 </transition>
45 </template>
46
47 <script setup>
48 // * Import from Vue
49 import { inject, computed, useSlots, ref, watch, nextTick, onMounted } from 'vue'
50
51 // * Import from Libraries
52 import { ClickOutside as vClickOutside } from 'element-plus'
53
54 // * Composables
55 import { useColorTransparency } from '../../../assets/js/common/colorManipulation'
56
57 // * Components Props
58 let props = defineProps({
59 visibility: {
60 type: Boolean,
61 default: false,
62 },
63 customClass: {
64 type: String,
65 default: '',
66 },
67 position: {
68 type: String,
69 default: 'bottom',
70 validator(value) {
71 return ['bottom', 'top', 'left', 'right', 'center'].includes(value)
72 },
73 },
74 closeOutside: {
75 type: Boolean,
76 default: false,
77 },
78 customCss: {
79 type: Object,
80 default: () => {},
81 },
82 footerVisibility: {
83 type: Boolean,
84 default: true,
85 },
86 // Accessible name for the dialog — provide one of these two props.
87 // ariaLabel: plain string label (e.g. "Filter options").
88 // ariaLabelledby: id of a visible heading element inside the dialog.
89 ariaLabel: {
90 type: String,
91 default: '',
92 },
93 ariaLabelledby: {
94 type: String,
95 default: '',
96 },
97 })
98
99 // * Compomnets Emits
100 let emits = defineEmits(['click-outside', 'update:visibility', 'onClose'])
101
102 // ---------------------------------------------------------------------------
103 // Focus management
104 // ---------------------------------------------------------------------------
105
106 // The dialog element itself — used as a programmatic focus target fallback
107 const dialogRef = ref(null)
108
109 defineExpose({
110 dialogRef,
111 })
112
113 // The element that had focus before the dialog opened; restored on close
114 let triggerEl = null
115
116 /**
117 * Returns every keyboard-reachable element inside the dialog.
118 * Elements that are hidden (display:none / hidden attribute) are excluded.
119 */
120 function getFocusableElements() {
121 if (!dialogRef.value) return []
122 return Array.from(
123 dialogRef.value.querySelectorAll(
124 'a[href], area[href], ' +
125 'input:not([disabled]):not([type="hidden"]), ' +
126 'select:not([disabled]), ' +
127 'textarea:not([disabled]), ' +
128 'button:not([disabled]), ' +
129 '[tabindex]:not([tabindex="-1"]), ' +
130 '[contenteditable]',
131 ),
132 ).filter((el) => !el.hasAttribute('hidden') && !el.closest('[hidden]'))
133 }
134
135 /** Moves focus to the first interactive child, or the dialog container itself. */
136 function focusFirstElement() {
137 const focusable = getFocusableElements()
138 if (focusable.length) {
139 focusable[0].focus()
140 } else {
141 dialogRef.value?.focus()
142 }
143 }
144
145 /**
146 * Focus trap — keeps Tab / Shift+Tab cycling inside the dialog.
147 * Attached to @keydown.tab on the dialog container so it catches the event
148 * wherever focus currently sits inside the dialog.
149 */
150 function onTab(event) {
151 const focusable = getFocusableElements()
152 if (!focusable.length) {
153 event.preventDefault()
154 return
155 }
156
157 const first = focusable[0]
158 const last = focusable[focusable.length - 1]
159
160 if (event.shiftKey) {
161 // Shift+Tab from the first focusable (or the container): wrap to last
162 if (document.activeElement === first || document.activeElement === dialogRef.value) {
163 event.preventDefault()
164 last.focus()
165 }
166 } else {
167 // Tab from the last focusable: wrap to first
168 if (document.activeElement === last) {
169 event.preventDefault()
170 first.focus()
171 }
172 }
173 }
174
175 /** Escape closes the dialog (standard dialog keyboard pattern). */
176 function onEscape() {
177 emits('update:visibility', false)
178 }
179
180 // Save the triggering element when the dialog opens; restore it when it closes.
181 // Emit onClose whenever visibility transitions from true → false (X, Esc,
182 // click-outside, or parent-driven v-model change).
183 watch(
184 () => props.visibility,
185 (isVisible, wasVisible) => {
186 if (isVisible) {
187 triggerEl = document.activeElement
188 nextTick(focusFirstElement)
189 } else {
190 if (wasVisible) {
191 emits('onClose')
192 }
193 nextTick(() => {
194 triggerEl?.focus()
195 triggerEl = null
196 })
197 }
198 },
199 )
200
201 // Handle the case where the popup is already open at mount time
202 onMounted(() => {
203 if (props.visibility) {
204 nextTick(focusFirstElement)
205 }
206 })
207
208 const slots = useSlots()
209
210 // * Click outside of menu
211 function onClickOutside() {
212 emits('click-outside')
213 if (props.closeOutside) {
214 emits('update:visibility', false)
215 }
216 }
217
218 // * Container Width
219 let cWidth = inject('containerWidth', 0)
220 let checkScreen = computed(
221 () => cWidth.value < 460 || (cWidth.value > 560 && cWidth.value - 240 < 460),
222 )
223
224 let popupHeaderVisibility = computed(() => {
225 return !!slots.header?.()
226 })
227
228 // * Components Colors
229 let amColors = inject('amColors')
230
231 // * Css Variables
232 let cssVars = computed(() => {
233 return {
234 '--am-c-spb-bgr': amColors.value.colorMainBgr,
235 '--am-c-spb-text': amColors.value.colorMainText,
236 '--am-c-spb-text-op10': useColorTransparency(amColors.value.colorMainText, 0.1),
237 }
238 })
239 </script>
240
241 <style lang="scss">
242 @mixin am-slide-popup {
243 .am-slide-popup {
244 position: absolute;
245 top: 0;
246 left: 0;
247 display: block;
248 width: 100%;
249 }
250
251 .am-slide-popup__block {
252 $classP: '.am-slide-popup__block';
253 @extend .am-slide-popup;
254 height: 100%;
255 padding: 0;
256 //background: rgba(4, 8, 11, 0.3);
257 background-color: var(--am-c-spb-text-op10);
258 z-index: 1000;
259
260 * {
261 box-sizing: border-box;
262 }
263
264 &-header {
265 display: flex;
266 align-items: center;
267 justify-content: space-between;
268 gap: 0 24px;
269 background: var(--am-c-spb-bgr);
270 color: var(--am-c-spb-text);
271 padding: 0;
272
273 .am-icon-close {
274 color: var(--am-c-spb-text);
275 font-size: 20px;
276 cursor: pointer;
277 flex: 0 0 auto;
278
279 &:focus {
280 box-shadow: 0 0 0 1px var(--am-c-primary);
281 border-radius: 4px;
282 }
283 }
284 }
285
286 &-footer {
287 display: flex;
288 align-items: center;
289 justify-content: flex-end;
290 gap: 0 8px;
291
292 .am-fs__ps-popup__btn-mobile {
293 width: 100%;
294 display: flex;
295 justify-content: space-between;
296 }
297 }
298
299 &-inner {
300 @extend .am-slide-popup;
301 background: var(--am-c-main-bgr);
302 padding: 16px 32px;
303
304 // The container receives programmatic focus only (tabindex="-1" fallback).
305 // Interactive children retain their own visible focus rings.
306 &:focus {
307 outline: none;
308 }
309
310 &.am-position-top {
311 top: 0;
312 bottom: auto;
313 left: 0;
314 right: auto;
315 min-height: 100px;
316 }
317
318 &.am-position-right {
319 top: 0;
320 bottom: 0;
321 left: auto;
322 right: 0;
323 width: auto;
324 }
325
326 &.am-position-left {
327 top: 0;
328 bottom: 0;
329 left: 0;
330 right: auto;
331 width: auto;
332 }
333
334 &.am-position-bottom {
335 top: auto;
336 bottom: 0;
337 right: auto;
338 min-height: 100px;
339 }
340
341 &.am-position-center {
342 top: 50%;
343 bottom: auto;
344 left: 50%;
345 right: auto;
346 transform: translate(-50%, -50%);
347 width: auto;
348 max-width: calc(100% - 32px);
349 height: auto;
350 max-height: calc(100% - 32px);
351 }
352
353 &-mobile {
354 padding: 16px;
355
356 .am-fs__ps-popup__btn-mobile {
357 height: auto;
358 width: 100%;
359 flex-direction: column;
360 gap: 8px;
361 white-space: break-spaces;
362 }
363 }
364 }
365
366 &.nested-enter-active,
367 &.nested-leave-active {
368 transition: all 0.3s ease-in-out;
369
370 #{$classP}-inner {
371 transition: all 0.3s ease-in-out;
372 }
373 }
374
375 &.nested-enter-from,
376 &.nested-leave-to {
377 opacity: 0;
378
379 &.am-position-top {
380 transform: translateY(-30px);
381 }
382
383 &.am-position-right {
384 transform: translateX(30px);
385 }
386
387 &.am-position-left {
388 transform: translateX(-30px);
389 }
390
391 &.am-position-bottom,
392 &.am-position-center {
393 transform: translateY(30px);
394 }
395
396 #{$classP}-inner {
397 &.am-position-top {
398 transform: translateY(-30px);
399 }
400
401 &.am-position-right {
402 transform: translateX(30px);
403 }
404
405 &.am-position-left {
406 transform: translateX(-30px);
407 }
408
409 &.am-position-bottom {
410 transform: translateY(30px);
411 }
412
413 &.am-position-center {
414 transform: translate(-50%, 50%);
415 }
416 /*
417 Hack around a Chrome 96 bug in handling nested opacity transitions.
418 This is not needed in other browsers or Chrome 99+ where the bug
419 has been fixed.
420 */
421 opacity: 0.001;
422 }
423 }
424
425 /* delay leave of parent element */
426 &.nested-leave-active {
427 transition-delay: 0.25s;
428 }
429
430 /* delay enter of nested element */
431 &.nested-enter-active {
432 #{$classP}-inner {
433 transition-delay: 0.25s;
434 }
435 }
436 }
437 }
438
439 // public
440 .amelia-v2-booking #amelia-container {
441 @include am-slide-popup;
442 }
443
444 // admin
445 #amelia-app-backend-new {
446 @include am-slide-popup;
447 }
448 </style>
449