Newer
Older
gnexus-creds / frontend / src / components / GnModal.vue
@Eugene Sukhodolskiy Eugene Sukhodolskiy 3 days ago 3 KB Improve modal close and revealed field actions
<script setup>
import { nextTick, onBeforeUnmount, ref, watch } from "vue";

const props = defineProps({
  open: { type: Boolean, default: false },
  title: { type: String, default: "" },
  closeOnBackdrop: { type: Boolean, default: true }
});

const emit = defineEmits(["update:open", "close"]);

const titleId = `gn-modal-title-${Math.random().toString(36).slice(2)}`;
const dialogRef = ref(null);
const rendered = ref(false);
const stateClass = ref("");
let closeTimer = null;
let previousFocus = null;

function trapFocus(event) {
  if (event.key !== "Tab" || !dialogRef.value) return;

  const focusable = Array.from(
    dialogRef.value.querySelectorAll(
      'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
    )
  );

  if (!focusable.length) return;

  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
}

function onKeydown(event) {
  if (event.key === "Escape") {
    event.preventDefault();
    requestClose();
    return;
  }

  trapFocus(event);
}

function requestClose() {
  emit("update:open", false);
  emit("close");
}

function openModal() {
  clearTimeout(closeTimer);
  previousFocus = document.activeElement;
  rendered.value = true;
  stateClass.value = "";
  document.addEventListener("keydown", onKeydown);

  nextTick(() => {
    requestAnimationFrame(() => {
      stateClass.value = "a-show";
      dialogRef.value?.focus();
    });
  });
}

function closeModal() {
  document.removeEventListener("keydown", onKeydown);
  previousFocus?.focus?.();
  previousFocus = null;

  if (!rendered.value) return;

  clearTimeout(closeTimer);
  stateClass.value = "a-hide";
  closeTimer = setTimeout(() => {
    rendered.value = false;
    stateClass.value = "";
  }, 300);
}

watch(
  () => props.open,
  (open) => {
    if (open) {
      openModal();
    } else {
      closeModal();
    }
  },
  { immediate: true }
);

onBeforeUnmount(() => {
  clearTimeout(closeTimer);
  document.removeEventListener("keydown", onKeydown);
});
</script>

<template>
  <Teleport to="body">
    <div v-if="rendered" class="modal" :class="stateClass" :aria-hidden="props.open ? 'false' : 'true'">
      <div class="modal-backdrop" @click="props.closeOnBackdrop && requestClose()" />
      <div
        ref="dialogRef"
        class="modal-dialog"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="titleId"
        tabindex="-1"
      >
        <header class="modal-header">
          <h4 :id="titleId" class="modal-title">
            <slot name="title">{{ props.title }}</slot>
          </h4>
          <button class="btn-icon modal-close" type="button" aria-label="Close" @click="requestClose">
            <i class="ph ph-x" aria-hidden="true" />
          </button>
        </header>
        <div class="modal-panel">
          <div class="modal-body">
            <slot />
          </div>
          <footer v-if="$slots.footer || $slots.actions" class="modal-footer">
            <slot name="footer" />
            <div v-if="$slots.actions" class="actions">
              <slot name="actions" :close="requestClose" />
            </div>
          </footer>
        </div>
      </div>
    </div>
  </Teleport>
</template>