iT邦幫忙

2024 iThome 鐵人賽

DAY 18
3
Modern Web

為你自己寫 Vue Component系列 第 18

[為你自己寫 Vue Component] AtomicModal

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicModal

如果要希望在不換頁的前提上塞入更多畫面無法容納的資訊,Modal 經常會是網頁設計師的首選之一。如果要從 UI 樣式上細分,又可以延伸出像是對話框(Dialog)、抽屜(Drawer)等不同變化以因應各種不同需求。因此本篇要實作的 <AtomicModal> 目標是作為其他元件的基礎元件,例如:<AtomicDialog><AtomicDrawer>,也有些人會考慮作為 <AtomicSelect> 的基礎元件。

也因為它幾乎可以作為所有具有「modal」功能的元件的基礎元件,所以在這個元件需要考量的情境會非常多且複雜。

元件分析

元件命名

在元件的命名上,有很長一段時間我不太能區分 ModalDialogPopup 以及 Lightbox 之間有什麼差異,每次遇到不同的團隊與角色都會發現他們使用的命名都不一樣,但最後在 UI 表現上卻是一樣的東西。

直到我收到了 Vue Final Modal 的作者,Hunter Liu,轉分享的一篇文章才豁然開朗。

Popups: 10 Problematic Trends and Alternatives

在這篇文章中以兩個維度來分類這些元件:

第一個維度是:使用者是否能夠與頁面其餘部分進行交互

  • Modal:使用者無法與彈出視窗以外(背景)的內容進行互動,例如使用 Focus Trap 的機制限制將焦點匡在彈出視窗內部。
  • Nonmodal:使用者仍然可以與彈出視窗以外的內容進行互動。

第二個維度是:背景是否變暗

  • Lightbox:彈出視窗的背景會變暗,我們則稱之為「燈箱」。
  • Nonlightbox:彈出視窗的背景不會變暗。

以這裡的敘述為例,我們可以整理成以下的表格:

Modal Nonmodal
Lightbox Dialog, Drawer
Nonlightbox FullScreen Modal, Select Dropdown, Tooltip, Notification, Alert

有些元件設計會讓 Select 打開時不能與背景做互動(點擊外部按鈕、連結並且禁止滾動)

當然,那些元件要放在哪一個分類中,還是要依照整體產品的需求來定義。不過我們可以得知,與其說 Modal 是一個元件,我倒覺得可以把它視為一個概念,而這個概念實作出的基礎可以被應用在很多不同的元件上。

元件架構

AtomicModal 架構圖

  1. Backdrop(選):背景區塊,當 Modal 打開時,背景會變暗,並且使用者無法與背景互動。
  2. Content:Modal 的主要區塊,這裡會放置 Modal 的內容。

功能設計

我們盡可能地讓 <AtomicModal> 這個元件可以應用在不同的場景上,所以它的功能要盡可能地單純且必要。

因此我們只需要考慮以下介面:

  • 能透過 modelValue 這個 props 控制開關。
  • 能透過 hideBackdrop 這個 props 決定是否隱藏背景。
  • 能透過 disableEscapePress 這個 props 決定是否禁用 ESC 鍵關閉 Modal。
  • 能透過 disableBackdropClick 這個 props 決定是否禁用點擊背景關閉 Modal。

disableEscapePressdisableBackdropClick 在某些情境下會非常好用,例如 Modal 裡面顯示的是一個重要資訊,這可以防止使用者不小心按下 ESC 鍵或是點擊背景關閉 Modal。

除此之外我們需完成以下功能:

  • 點擊 Backdrop 區塊時關閉 Modal。
  • 按下 ESC 鍵時關閉 Modal。
  • Transition:當 Modal 開啟時淡入,關閉時淡出。
  • Scroll Lock:當 Modal 開啟時,背景不可滾動。
  • Focus Trap:當 Modal 開啟時,焦點只能在 Modal 內部移動。

使用結構如下:

<template>
  <AtomicButton>
    開啟 Modal
  </AtomicButton>
  <AtomicModal v-model="modal">
    <div>
      內容
    </div>
  </AtomicModal>
</template>

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

Props

名稱 型別 預設值 說明
modelValue boolean 控制 Modal 開關
hideBackdrop boolean false 是否隱藏背景
disableEscapePress boolean false 是否禁用 ESC 鍵關閉 Modal
disableBackdropClick boolean false 是否禁用點擊背景關閉 Modal

Emits

名稱 參數 說明
update:modelValue boolean 控制 Modal 開關
interface AtomicModalProps {
  modelValue: boolean;
  hideBackdrop?: boolean;
}

interface AtomicModalEmits {
  (event: 'update:modelValue', value: boolean): void;
}

const props = withDefaults(defineProps<AtomicModalProps>(), {});

const emit = defineEmits<AtomicModalEmits>();

雙向綁定的部份我們沒有打算作成非受控元件,所以簡單處理雙向綁定的部分:

const modelValueWritable = computed({
  get() {
    return props.modelValue;
  },
  set(value) {
    emit('update:modelValue', value);
  },
});

模板的部分,因為 <AtomicModal> 的主要目的是作為像是 <AtomicDialog><AtomicDrawer> 元件的基礎元件,所以我們只需要一個 Backdrop 區塊讓使用者可以在 Modal 跟 Nonlightbox 之間切換,還有一個 default slot 讓其他的元件可以自定義需要的內容。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueWritable"
      class="atomic-modal"
    >
      <div 
        v-if="!hideBackdrop" 
        class="atomic-modal__backdrop"
      />
      <slot name="default" />
    </div>
  </Teleport>
</template>
.atomic-modal {
  position: fixed;
  inset: 0;

  &__backdrop {
    position: fixed;
    z-index: -1;
    background-color: rgba(0, 0, 0, 0.6);
    inset: 0;
  }
}

關閉 Modal

當使用者點擊 Backdrop 區塊時或是按下 ESC 鍵時,我們需要關閉 Modal。

首先我們先處理點擊 Backdrop 區塊時關閉 Modal,記得要實作當這兩個方法分別被禁用的判斷。

const onRootKeydown = (event: KeyboardEvent) => {
  if (props.disableEscapePress) return;
  if (event.key !== 'Escape') return;

  event.preventDefault();
  modelValueWritable.value = false;
};

const onBackdropClick = () => {
  if (event.relatedTarget) return;
  modelValueWritable.value = false;
};

接著將事件綁定到 Root 區塊與 Backdrop 區塊上。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueWritable"
      class="atomic-modal"
      @keydown="onRootKeydown"
    >
      <div 
        v-if="!hideBackdrop" 
        class="atomic-modal__backdrop"
        @click="onBackdropClick"
      />
      <slot name="default" />
    </div>
  </Teleport>
</template>

現在點擊事件已經可以順利進行了,但是當我們按下 ESC 鍵時,Modal 並沒有關閉!

原因很簡單,此時我們的 Root 區塊並沒有聚焦,儘管 Modal 被打開了,當前的焦點還是停留在開啟 Modal 的按鈕上。

焦點還停留在開啟 Modal 的按鈕上

這個問題留到 Focus Trap 完成後就可以解決。有了 Focus Trap 後,焦點會被限縮在 Modal 內部,這時在 Modal 內部按下 ESC 鍵時關閉 Modal,不論在 Modal 裡面哪個位置,事件冒泡都會將鍵盤事件傳遞到 Root 區塊上進而觸發 onRootKeydown 這個 function。

Transition

Backdrop 的部分我們選擇淡入淡出的過場效果,但是內容的部分 Dialog 比較多的可能是淡入淡出,Drawer 則有可能由上下左右滑入。因此在元件中不多處理這個部分,而是交給繼承的元件或是使用的開發人員處理。

因此我們僅針對 Backdrop 的部分做淡入淡出的處理。

.transition-fade {
  &-enter-from,
  &-leave-to {
    opacity: 0;
  }

  &-enter-active,
  &-leave-active {
    transition-property: opacity;
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    transition-duration: 300ms;
  }
}

在專案中動畫的 CSS 設定,除非是很特別為某個元件設計的,通常建議放在全域的 CSS 檔案中,這樣除了 Atomic 元件可以使用,在專案其他的部分也可以共用。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueWritable"
      class="atomic-modal"
    >
      <Transition
        v-if="!hideBackdrop"
        name="transition-fade"
      >
        <div 
          v-if="modelValueWritable"
          class="atomic-modal__backdrop"
        />
      </Transition>
      <slot name="default" />
    </div>
  </Teleport>
</template>

但目前不會有任何過場效果,Backdrop 而是會「啪」的一下出現,「啪」的一下消失。因為 Backdrop 現在會跟著 Root 同時被渲染跟同時被移除。

我們可以使用 <Transition> 元件提供的 appear 屬性,讓元件在第一次渲染時也會有過場效果。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueWritable"
      class="atomic-modal"
    >
      <Transition
        v-if="!hideBackdrop"
        appear
        name="transition-fade"
      >
        <div 
          v-if="modelValueWritable"
          class="atomic-modal__backdrop"
        />
      </Transition>
      <slot name="default" />
    </div>
  </Teleport>
</template>

但如果使用者在外層元件中切換 modelValue,那麼在離場時還是會「啪」的一下消失,所以我們需要多一個狀態,modelValueLocal,讓元件可以等到內部動畫完成後才移除 Root 區塊。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueLocal"
      class="atomic-modal"
    >
      <Transition
        v-if="!hideBackdrop"
        appear
        name="transition-fade"
      >
        <div 
          v-if="modelValueWritable"
          class="atomic-modal__backdrop"
        />
      </Transition>
      <slot name="default" />
    </div>
  </Teleport>
</template>

具體操作流程如下:

  • modelValuemodelValueWritable) 開啟時,立即同步 modelValueLocal 開啟。
  • modelValue 關閉,等待過場結束後才切換 modelValueLocal 關閉。
const modelValueWritable = computed({
  // 略
});

const modelValueLocal = ref(modelValueWritable.value);

const onAfterLeave = () => {
  modelValueLocal.value = false;
};

watch(
  modelValueWritable,
  async value => {
    if (value) return modelValueLocal.value = value;
  },
  {
    flush: 'sync',
  }
);
<template>
  <Teleport to="body">
    <div
      v-if="modelValueLocal"
      class="atomic-modal"
    >
      <Transition
        v-if="!hideBackdrop"
        appear
        name="transition-fade"
        @after-leave="onAfterLeave"
      >
        <div 
          v-if="modelValueWritable"
          class="atomic-modal__backdrop"
        />
      </Transition>
      <slot name="default" />
    </div>
  </Teleport>
</template>

AtomicModal Transition

統整一下,Modal 過場顯示我們用了兩個狀態管理。

  1. modelValueWritablemodelValue):外部控制 Modal 開關。
  2. modelValueLocal:開啟 Modal 時立即同步,關閉 Modal 時等待過場結束後才切換。

另外!因為現在我們的 modelValuemodelValueLocal 在關閉 Modal 時並不會馬上同步,為了讓未來繼承的元件能夠同步到 modelValueLocal 的狀態,我們可以透過 default slot 的方式將 modelValueLocal 傳遞給子元件。

<slot
  name="default"
  open="modelValueLocal"
/>

這樣我們在使用 <AtomicModal> 時就可以透過 open 這個 prop 得知 <AtomicModal> 內部的的開關狀態。

<template>
  <AtomicModal v-model="modal">
    <template #default="{ open }">
      Modal 內部開關狀態:{{ open }}
    </template>
  </AtomicModal>
</template>

Scroll Lock

當我們開啟 Modal 後,我們會希望能夠禁止背景的滾動,以免操作的過程中讓開啟前的位置跑掉。

在這裡,我們的 Modal 覆蓋了整個頁面,所以我們要在開啟時鎖住 <body> 的滾動,關閉後解開鎖定。

watch(
  modelValueLocal,
  value => {
    if (value) {
      document.body.style.overflow = 'hidden';
      return
    }

    document.body.style.overflow = '';
  },
  {
    immediate: true,
  }
);

這個做法在 Apple 使用者的瀏覽器上看起來可能會正常,但在 Windows 使用者的瀏覽器上會有 UI 跳動的問題,因為我們沒有考慮到 Scrollbar 有寬度的情況。

所以在鎖住滾動前,我們要先計算出滾動條的寬度,並將 padding-right 設定為當前 Scrollbar 的寬度;解開鎖定時,則需要將鎖的狀態還原。

watch(
  modelValueLocal,
  value => {
    if (value) {
      const scrollbarWidth =
        window.innerWidth - document.documentElement.clientWidth;
      document.body.style.paddingRight = toUnit(scrollbarWidth) || '';
      document.body.style.overflow = 'hidden';
      return;
    }

    document.body.style.paddingRight = '';
    document.body.style.overflow = '';
  },
  {
    immediate: true,
  }
);

這樣我們就解決了 Scrollbar 寬度造成的 UI 跳動問題。

但是!如果我們今天連續開了兩個 Modal 的話,就會有問題,我們在開啟第二個 Modal 時,會再次計算一次 Scrollbar 寬度,這時得到的寬度為 0,這樣就會造成 padding-right 的值被覆蓋掉。

我們需要一個狀態來記錄有多少 Modal 需要鎖住 Scrollbar。最簡單的方法我們可以用一個全域的變數來記錄。

// 全域變數
let lockCount = 0;
watch(
  modelValueLocal,
  value => {
    if (value) {
      if (!lockCount) {
        const scrollbarWidth =
          window.innerWidth - document.documentElement.clientWidth;
        document.body.style.paddingRight = toUnit(scrollbarWidth) || '';
        document.body.style.overflow = 'hidden';
      }

      lockCount += 1;
      return;
    }

    if (lockCount > 0) lockCount -= 1;
    if (!lockCount) {
      document.body.style.paddingRight = '';
      document.body.style.overflow = '';
    }
  },
  {
    immediate: true,
  }
);

記得預防 Modal 被直接銷毀,我們需要在 onUnmounted 時將 lockCount 減一確認。

onUnmounted(() => {
  if (lockCount > 0) lockCount -= 1;
  if (!lockCount) {
    document.body.style.paddingRight = '';
    document.body.style.overflow = '';
  }
});

這樣我們開第二個 Modal 時就不會對 <body> 重新上鎖,同時當其中一個 Modal 關閉時,也因為還有 Modal 需要鎖住 <body>,所以不會解開鎖定。

Focus Trap

當 Modal 開啟時,我們希望焦點只能在 Modal 內部移動,這樣使用者才不會一不小心操作到 Modal 以外的功能,這種把焦點可移動的範圍限制在一個區域內的概念稱之為 Focus Trap。

簡單敘述一下 Focus Trap 可以怎麼做:

  1. 當 Modal 開啟時,我們可以找到 Modal 內所有可聚焦的元素,並將焦點移動到 Modal 內的第一個可聚焦元素。
  2. 按下 Tab 鍵時,我們可以將焦點移動到 Modal 內的下一個可聚焦元素,如果當前焦點為 Modal 內最後一個可聚焦的元素,下一個就跳到 Modal 內可聚焦元素的第一個。如果按下 Shift + Tab 鍵時,則相反。
  3. 我們的 Modal 有可能連續開啟多個,當新的 Modal 開啟時,我們需要將焦點移動到新的 Modal 內的第一個可聚焦元素,並且 Focus Trap 的範圍為新的 Modal。
  4. 當 Modal 關閉時,我們需要將焦點移動到開啟 Modal 的元素上。

概念大致如上,那我們要怎麼找到 Modal 內所有可聚焦的元素呢?

以下是一個叫 tabbable 的工具,原始碼中找尋所有可聚焦元素的選擇器。

const candidateSelectors = [
  'input:not([inert])',
  'select:not([inert])',
  'textarea:not([inert])',
  'a[href]:not([inert])',
  'button:not([inert])',
  '[tabindex]:not(slot):not([inert])',
  'audio[controls]:not([inert])',
  'video[controls]:not([inert])',
  '[contenteditable]:not([contenteditable="false"]):not([inert])',
  'details>summary:first-of-type:not([inert])',
  'details:not([inert])',
];

剩下的,我相信只要挽起袖子,一個情境一個情境地解決,過陣子就會有一個完整的 Focus Trap 功能了!

為了快速我們可以借助一下另外一個工具:focus-trap

focus-trap 可以幫助我們將焦點鎖定在選定的範圍內,並且當新的 Focus Trap 啟動時,預設會暫停舊的 Focus Trap,而在新的 Focus Trap 關閉後,舊的 Focus Trap 會恢復到啟用的狀態。

所以我們只要關注何時啟動 Focus Trap 與何時關閉 Focus Trap 就好了。

首先我們先要取得 Root 區塊的 DOM 元素,這樣我們才能將焦點鎖定在這個區塊內。

<template>
  <Teleport to="body">
    <div
      v-if="modelValueLocal"
      ref="rootRef"
      class="atomic-modal"
    >
      <!-- 略 -->
    </div>
  </Teleport>
</template>

接著我們在 rootRef 更新時,初始化並啟用 Focus Trap。

const rootRef = ref<HTMLElement>();

let trap: FocusTrap | undefined;

watch(rootRef, (element) => {
  if (!element) return

  trap = createFocusTrap(element);
  trap.activate();
})

停用的時機有兩個,分別為 modelValueWritablemodelValueLocal 關閉時。

使用者有可能同時開啟多個 Modal,如果在 modelValueLocal 關閉後才停用,讓舊的 Focus Trap 重新啟動,就可能會發生一些比較心急的使用者頻繁地按下 ESC 鍵,但卻沒有關閉 Modal 的情況。

所以一旦確定元件要離開,越早停用 Focus Trap 越好,也因此我們選擇在 modelValueLocal 關閉時停用。

watch(
  modelValueWritable,
  async value => {
    if (value) {
      modelValueLocal.value = value
      return
    };

    trap?.deactivate();
    trap = undefined;
  },
  {
    flush: 'sync',
  }
);

另外一樣要記得在元件被銷毀時,也要停用 Focus Trap。

onUnmounted(() => {
  // 略

  trap?.deactivate();
  trap = undefined;
});

到這裡我們就完成了 Focus Trap 的功能。

不過 focus-trap 這個工具本身有一個限制,在指定要啟動 Focus Trap 的區塊時,區塊內一定要有可一個是可以透過 Tab 聚焦的元素,否則 Focus Trap 會拋出錯誤警告。

Error: Your focus-trap must have at least one container with at least one tabbable node in it at all times

要解決這個問題我們可以嘗試確保在 Modal 裡面至少有一個可以透過 Tab 聚焦的元素

<template>
  <Teleport
    v-if="modelValueLocal"
    to="body"
  >
    <div
      ref="rootRef"
      class="atomic-modal"
      @keydown="onRootKeydown"
    >
      <!-- 略 -->
      <slot name="default" />
      <div tabindex="0" />
    </div>
  </Teleport>
</template>

這樣 Modal 開啟就保底會有一個可以透過 Tab 聚焦的元素,這樣就不會有 Focus Trap 啟動時拋出錯誤警告的問題。

但這個保底用的元素又衍生了一個問題,當我們按下 Tab 鍵時,焦點會從 Modal 內最後一個可聚焦的元素跳到這個保底元素,這樣會讓使用者感到困惑。

要解決這個問題我們可以讓焦點移動到這個保底元素上時強制移動到可聚焦元素的第一個。

let focusableElements: FocusableElement[] = [];

const onSentinelFocus = () => {
  if (focusableElements.length === 1) return;
  focusableElements[0]?.focus();
};

watch(rootRef, element => {
  if (!element) return;


  focusableElements = tabbable(element);
  if (!focusableElements.length) return;

  trap = createFocusTrap(element);
  trap.activate();
});
<div
  ref="rootRef"
  class="atomic-modal"
  @keydown="onRootKeydown"
>
  <!-- 略 -->
  <slot name="default" />
  <div
    tabindex="0"
    @focus="onSentinelFocus"
  />
</div>

這樣 Focus Trap 就大致完成,焦點會在我們預期的內容內移動,沒有任何元素可以聚焦時也至少還有一個保底元素可以被聚焦。

元件整合

在實際開發應用中我們可能會在 <AtomicModal> 加入 <AtomicDropdown><AtomicTooltip> 等其他元件。第一個要面臨的問題是 Focus Trap 現在只能在 <AtomicModal> 內部移動,這時如果我們點開 <AtomicDropdown> 的 Dropdown,這個 Dropdown 不在 Focus Trap 的範圍內,我們將無法對 Dropdown 進行任何操作;另外一個問題,我們也有在 <AtomicDropdown> 點擊 Dropdown 以外的地方關閉 Dropdown。但在 <AtomicModal> 內的 <AtomicDropdown> 開啟時是點擊到 Modal 的 Backdrop,會連同 Dropdown 跟 Modal 一起被關閉,這樣的使用體驗不是很好。

AtomicModal 與 AtomicDropdown 整合

要解決第一個問題,我們可以在 <AtomicDropdown> 底層元件 <AtomicPopover> 中也加入 Focus Trap 的功能,這樣當我們開啟 <AtomicDropdown> 時,Focus Trap 的範圍就會變成 <AtomicDropdown> 的範圍。

AtomicPopover 擴充 Focus Trap

let trap: FocusTrap | undefined;

watch(popoverRef, popover => {
  if (!popover) {
    trap?.deactivate();
    trap = undefined;
    return;
  }

  if (!tabbable(popover).length) return;

  trap = createFocusTrap(popover);

  trap.activate();
});

這樣我們就可以確保當 <AtomicDropdown> 開啟時,Focus Trap 的範圍就變成 <AtomicDropdown> 的 Menu。不過我們又會發現,<AtomicDropdown> 點擊外部關閉 Menu 的功能也會失效,這是因為 Focus Trap 限制了點擊外部事件的觸發,好在 focus-trap 有一些選項可以讓我們調整這個行為。

trap = createFocusTrap(popover, {
  clickOutsideDeactivates: true,
})

因為原本的期望是我們點擊了 <AtomicDropdown> 外部就關閉 Menu,所以我們將 clickOutsideDeactivates 設定為 true,這樣當我們點擊了 <AtomicDropdown> 外部時,Focus Trap 就會自動停用,如此一來我們就可以順利地關閉 Menu 了。

加入 Popups 資料管理中心

在 Modal 與 Dropdown 同時開啟時點擊 Backdrop 會同時關閉 Dropdown 跟 Modal。

AtomicModal 與 AtomicDropdown 整合不完全

我們希望點擊 Backdrop 時只會關閉 Dropdown,第二次才會關閉 Modal。

在前面我們有統整了四個象限的分類,這些彈出覆蓋在畫面上的元件我們統稱為 Popups。有了 Popups 的資料管理中心,我們可以紀錄當前開啟的 Popups 有哪些,也可以找到最新開啟的 Popup 是哪一個。

我們用 Vue Plugin 的方法來實作 Popups 資料管理中心。

import { inject, shallowReactive } from 'vue'
import type { ComponentInternalInstance, InjectionKey, ObjectPlugin } from 'vue'

type Popup = ComponentInternalInstance

const POPUPS_MANAGEMENT: InjectionKey<PopupsManager> = Symbol()

export function createPopupsManager () {
  const overlays = shallowReactive<Popup[]>([])

  const add = (popup: Popup) => {}
  const remove = (popup: Popup) => {}
  const isTop = (popup: Popup) => {}

  const manager: PopupsManager = {
    add,
    remove,
    isTop,
  }

  return <ObjectPlugin>{
    install(app) {
      app.provide(POPUPS_MANAGEMENT, manager)
    }
  }
}

我們需要實作三個方法:addremoveisTop

add 新增 Popop

當 Popup 開啟時,我們將其推入 Popups 資料管理中心,如果重複加入則需要排除。

const add = (popup: Popup) => {
  let index = popups.indexOf(popup)
  if (index !== -1) return index

  index = popups.length
  popups.push(popup)

  return index
}

remove 刪除 Popop

當 Popup 關閉時,我們將其從 Popups 資料管理中心移除。

const remove = (popup: Popup) => {
  const index = indexOf(popups, popup)
  if (index === -1) return index;

  popups.splice(index, 1)

  return index
}

isTop 確認 Popup 是否在最上層

確認傳入 Popup 的是否為最新開啟的 Popup。

const isTop = (popup: Popup) => {
  return popups.length > 0 && popups[popups.length - 1] === popup
}

接著我們將 Popups 資料管理中心安裝到 Vue App 中。

const popups = createPopupsManager()

const app = createApp(App)
app.use(popups)
app.mount('#app')

現在我們有一個全域的 Popups 資料管理中心,我們可以透過 Popups 資料管理中心來管理所有 Popups 的開啟與關閉。

不過可以的話,我們也提供一個 Composables API 讓元件可以更方便地取用 Popups 資料管理中心。

export function usePopupsManager () {
  const popups = inject(POPUPS_MANAGEMENT, null)
  if (!context) throw new Error('error')
  return popups
}

接著我們就要在 <AtomicModal><AtomicPopover> 中使用 Popups 資料管理中心,並在適當的時機新增刪除 Popup。

AtomicModal

const instance = getCurrentInstance()!;
const popups = usePopupsManager();

watch(modelValueLocal, value => {
  if (value) {
    popups.add(instance);
    return;
  }
});

watch(
  modelValueWritable,
  async value => {
    if (value) return;
    popups.remove(instance);
  },
  {
    flush: 'sync',
  }
);

AtomicPopover

const instance = getCurrentInstance()!;
const popups = usePopupsManager();

watch(
  modelValueWritable,
  value => {
    !value ? popups.remove(instance) : popups.add(instance);
  },
  {
    immediate: true,
    flush: 'sync',
  }
);

這樣不論是基於 <AtomicModal> 還是 <AtomicPopover> 的 Popups,在開啟時都會被推進 Popups 資料管理中心,並在關閉時移除。

這樣我們可以在點擊 Backdrop 時檢查 Popups 資料管理中心,如果最新開啟的 Popup 是自己,就執行關閉,如果不是,那可能開啟的是其他 Popups,不執行關閉的動作。

const onBackdropClick = () => {
  if (props.disableBackdropClick) return;
  if (!popups.isTop(instance)) return;
  modelValueWritable.value = false;
};

這樣我們再看看 Modal 與 Dropdown 同時開啟時的情況。

Modal 與 Dropdown 整合成功

總結

這次我們花了很大的篇幅在製作 <AtomicModal>,元件本身架構非常單純,但是在實作上卻有很多細節需要注意,例如 Transition、Scroll Lock、Focus Trap 等等。

在技術執行上,繁雜的 Scroll Lock 或 Focus Trap,我們只要把所有情境一個一個列舉並且逐一解決,除了耗時外都不算太難達成,有時程壓力或是擔心列舉的情境不夠全面的話,可以試著找找看有沒有工具專注在解決這些「計算」問題,像是 Scroll Lock 如果想更詳盡地覆蓋各種 edge case,可以考慮使用 body-scroll-lock 或是 scroll-lock 等 npm 套件。

除了元件本身外,我們還新增了 Popups 資料管理中心,讓我們跨多個不同元件的整合可以更加容易。除了有提到的「開啟狀態」管理外,Scroll Lock 也可以收進 Popup 管理中心管理,這樣可以讓元件邏輯更加清晰,也可以讓更多元件更容易地共享這些狀態。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicTooltip
下一篇
[為你自己寫 Vue Component] AtomicDialog
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言