Skip to content

Instantly share code, notes, and snippets.

@bojidaryovchev
Created March 17, 2024 15:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bojidaryovchev/301b829404691e3af16ce8a29f831f3f to your computer and use it in GitHub Desktop.
Save bojidaryovchev/301b829404691e3af16ce8a29f831f3f to your computer and use it in GitHub Desktop.
SideModal with TrapFocus built with Vue 3
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path
d="m12 13.4142 4.9498 4.9497 1.4142-1.4142L13.4142 12l4.9498-4.9498-1.4142-1.4142L12 10.5857 7.0503 5.636 5.636 7.0502 10.5859 12l-4.9497 4.9497 1.4142 1.4142L12 13.4142z"
fill="currentColor"
/>
</svg>
</template>
export enum SideModalSlideDirection {
FROM_LEFT,
FROM_RIGHT,
}
<template>
<Teleport to="body">
<TrapFocus :visible="visible">
<div
class="SideModal"
:class="{
visible,
'SideModal--from-left': slideDirection === SideModalSlideDirection.FROM_LEFT,
'SideModal--from-right': slideDirection === SideModalSlideDirection.FROM_RIGHT,
}"
>
<div class="SideModal__header">
<h2>{{ title }}</h2>
<button type="button" :aria-label="closeLabel" @click="onClose">
<CrossIcon />
</button>
</div>
<div class="SideModal__body">
<slot name="body"></slot>
</div>
<div class="SideModal__footer">
<slot name="footer"></slot>
</div>
</div>
<div class="SideModal__backdrop" :class="{ visible }" @click="onBackdropClick"></div>
</TrapFocus>
</Teleport>
</template>
<script lang="ts" setup>
import { CrossIcon } from '@/components/Icons/CrossIcon';
import { TrapFocus } from '@/components/TrapFocus';
import { SideModalSlideDirection } from '@/types/side-modal-slide-direction.enum';
withDefaults(
defineProps<{
visible: boolean;
title?: string;
closeLabel?: string;
slideDirection?: SideModalSlideDirection;
}>(),
{
title: '',
closeLabel: '',
slideDirection: SideModalSlideDirection.FROM_RIGHT,
},
);
const emit = defineEmits<{
(event: 'update:visible', visible: boolean): void;
}>();
const onBackdropClick = (): void => {
emit('update:visible', false);
};
const onClose = (): void => {
emit('update:visible', false);
};
</script>
<style lang="scss" scoped>
$width: 30rem;
$transition-duration-ms: 200ms;
$backdrop-opacity: 0.4;
.SideModal {
position: fixed;
top: 0;
bottom: 0;
width: $width;
z-index: 1010;
background: rgb(var(--colour-interactive-subtle-bg-default));
display: flex;
flex-direction: column;
&__header {
height: 6rem;
position: relative;
display: flex;
align-items: center;
justify-content: center;
h2 {
font-size: 1rem;
font-weight: bold;
}
button {
all: unset;
cursor: pointer;
position: absolute;
right: 1rem;
display: flex;
padding: 0.5rem;
border-radius: 50%;
transition: all $transition-duration-ms;
svg {
font-size: 1.5rem;
color: rgb(var(--colour-text-and-icon-1));
}
&:hover {
background: rgb(var(--colour-static-light-grey));
}
&:active {
background: rgb(var(--colour-static-grey));
}
}
}
&__body {
flex: 1;
padding: 2rem;
}
&__footer {
padding: 1.5rem;
}
&--from-left {
border-radius: 0 0.5rem 0.5rem 0;
left: 0;
transform: translateX(-100%);
transition: transform $transition-duration-ms;
&.visible {
transform: translateX(0);
}
}
&--from-right {
border-radius: 0.5rem 0 0 0.5rem;
right: 0;
transform: translateX(100%);
transition: transform $transition-duration-ms;
&.visible {
transform: translateX(0);
}
}
}
.SideModal__backdrop {
position: fixed;
inset: 0;
background: rgba(var(--colour-static-black), $backdrop-opacity);
opacity: 0;
visibility: hidden;
transition: all $transition-duration-ms;
z-index: 1000;
&.visible {
opacity: 100;
visibility: visible;
}
}
</style>
<template>
<div ref="focusTrapContainer">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watchEffect } from 'vue';
const props = defineProps<{
visible: boolean;
}>();
const focusTrapContainer = ref<HTMLElement | null>(null);
const getFocusableElements = (): HTMLElement[] => {
const selectors = [
'a[href]:not([disabled])',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
];
return focusTrapContainer.value
? Array.from(focusTrapContainer.value.querySelectorAll<HTMLElement>(selectors.join(',')))
: [];
};
let firstFocusableElement: HTMLElement | null = null;
let lastFocusableElement: HTMLElement | null = null;
const updateFocusableElements = (): void => {
const focusableElements = getFocusableElements();
firstFocusableElement = focusableElements[0] || null;
lastFocusableElement = focusableElements[focusableElements.length - 1] || null;
};
const trapFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !props.visible) {
return;
}
updateFocusableElements();
if (document.activeElement === lastFocusableElement && !event.shiftKey) {
event.preventDefault();
firstFocusableElement?.focus();
} else if (document.activeElement === firstFocusableElement && event.shiftKey) {
event.preventDefault();
lastFocusableElement?.focus();
}
};
watchEffect(async () => {
if (props.visible) {
updateFocusableElements();
document.addEventListener('keydown', trapFocus);
await nextTick();
firstFocusableElement?.focus();
} else {
document.removeEventListener('keydown', trapFocus);
}
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', trapFocus);
});
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment