![[為你自己寫 Vue Component] AtomicDropdown](https://ithelp.ithome.com.tw/upload/images/20240917/20120484IjFLVrU1OB.png)
Dropdown 是一個用於顯示和選擇選項的 UI 元件,通常由一個觸發按鈕或文字組成,當使用者點擊該按鈕或文字時,會展開一個選單列表,列出多個選項供使用者選擇。選項可以是簡單的文字或帶有 Icon 的項目,使用者可以通過點擊選單中的選項來進行選擇,選擇後選單通常會自動收起。
<AtomicDropdown> 與之後會實作的 <AtomicSelect> 有一些差異。<AtomicDropdown> 是一個可以顯示彈出選單的元件,點擊選單裡面的選項可能會切換頁面,也可能會觸發像是新增、修改、刪除的操作;<AtomicSelect> 是一個用於選擇選項的元件,它通常用於表單的控制元件。換句話說,<AtomicDropdown> 是一個 UI (或 Navigation)元件,而 <AtomicSelect> 是一個表單元件。
在上一篇文章中,我們已經實作了 <AtomicPopover>,這一篇我們將以它為基礎,實作一個 <AtomicDropdown> 元件。

在開始實作前,我們先研究各個 UI Library 的 Dropdown 元件是如何設計的。
Element Plus

<template>
  <ElDropdown trigger="click">
    <span>
      Dropdown List
      <ElIcon>
        <ArrowDown />
      </ElIcon>
    </span>
    <template #dropdown>
      <ElDropdownMenu>
        <ElDropdownItem>Action 1</ElDropdownItem>
        <ElDropdownItem>Action 2</ElDropdownItem>
        <ElDropdownItem>Action 3</ElDropdownItem>
        <ElDropdownItem disabled>Action 4</ElDropdownItem>
        <ElDropdownItem divided>Action 5</ElDropdownItem>
      </ElDropdownMenu>
    </template>
  </ElDropdown>
</template>
Element Plus 的 <ElDropdown> 使用方式大致與 <ElPopover> 相似,只是在 <ElDropdown> 另外提供了 <ElDropdownMenu> 與 <ElDropdownItem>,分別用來包裹選單與選項。
Vuetify
<template>
  <VMenu 
    :location="location" 
    offset="8" 
    open-on-hover
  >
    <template v-slot:activator="{ props }">
      <VBtn
        color="primary"
        v-bind="props"
      >
        Activator slot
      </VBtn>
    </template>
    <VList>
      <VListItem
        v-for="(item, index) in items"
        :key="index"
      >
        <VListItemTitle>{{ item.title }}</VListItemTitle>
      </VListItem>
    </VList>
  </VMenu>
</template>
在 Vuetify 中相似功能的元件叫做 <VMenu>,這在 <AtomicPopover> 的實作中已有提到。Vuetify 的 <VMenu> 也提供了 <VList> 與 <VListItem>,來分別包裹選單與選項。
Nuxt UI

<template>
  <UDropdown :items="items" :popper="{ placement: 'bottom-start' }">
    <UButton color="white" label="Options" trailing-icon="i-heroicons-chevron-down-20-solid" />
  </UDropdown>
</template>
Nuxt UI 的 <UDropdown> 在使用上就單純很多,default slot 提供給開發人員自定義按鈕外觀,items 傳入一個二維陣列,提供選單選項。如果要設定 Menu 的定位則用 popper 設定。
相較於 Element Plus 與 Vuetify 的設計,Nuxt UI 的 <UDropdown> 使用方式是我比較偏好的,開發人員不需要使用各種不同的元件來包裹選單與選項,只需傳入一個選單資料即可。但在某些特殊樣式需求下,Element Plus 與 Vuetify 的設計有更高的靈活性。Nuxt UI 的設計若要達到這樣的彈性,必須盡可能地將各種可能性納入 <UDropdown> 元件內部實作,或是使用 slot 讓開發人員自定義每個項目。
綜合以上並結合自身經驗,我們統整出 <AtomicDropdown> 的功能:
default slot 提供觸發 Popover 的元素。items 提供選單選項。trigger 來決定 Popover 的觸發事件。offset 來調整 Popover 的位置偏移。menuitem slot 來自定義選項內容。使用結構如下:
<template>
  <AtomicDropdown
    :items="items"
    :placement="placement"
    :trigger="trigger"
    :offset="offset"
  >
    <AtomicButton>Click to activate</AtomicButton>
  </AtomicDropdown>
</template>
首先,我們將需求中提到的功能整理成 props 與 emit 的介面,我們會需要下列屬性:
| 名稱 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| items | AtomicDropdownItem[] | 控制 Menu 的顯示與否 | |
| placement | top,right,bottom,left,top-start,top-end,right-start,right-end,bottom-start,bottom-end,left-start,left-end | bottom-start | Menu 的位置 | 
| trigger | click,hover | click | 觸發 Menu 的事件 | 
| offset | number,Partial<{ mainAxis: number; crossAxis: number; }> | 8 | Menu 與觸發元素的距離 | 
| disabled | boolean | false | 是否禁用 Menu | 
type AtomicPopoverProps = ComponentProps<typeof AtomicPopover>;
interface AtomicDropdownItem {
  label: string;
  value: any;
  onClick?(value: any): void;
  disabled?: boolean;
}
interface AtomicDropdownProps {
  items: AtomicDropdownItem[];
  placement?: AtomicPopoverProps['placement'];
  offset?: AtomicPopoverProps['offset'];
  trigger?: 'click' | 'hover';
  disabled?: boolean;
}
const props = withDefaults(defineProps<AtomicDropdownProps>(), {
  items: () => [],
  placement: 'bottom-start',
  offset: 8,
});
<AtomicDropdown> 會自行管理開關的狀態,我們並不打算將 <AtomicDropdown> 設計為受控元件,因為絕大多數時候開發人員並不在意 <AtomicDropdown> 是否被開啟或關閉。
const active = ref(false);
我們先將 props.items 稍作整理,方便我們套用到模板上。
const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value);
    };
    return {
      ...item,
      onClick,
    };
  });
});
<template>
  <AtomicPopover
    v-model="active"
    :disabled="disabled"
    :offset="offset"
    :placement="placement"
    :trigger="trigger"
  >
    <template #reference>
      <slot name="default" />
    </template>
    <ul class="atomic-dropdown">
      <li
        v-for="item in itemsCompose"
        :key="item.value"
        class="atomic-dropdown__menuitem"
        @click="item.onClick"
      >
        {{ item.label }}
      </li>
    </ul>
  </AtomicPopover>
</template>
到這裡已經可以使用 <AtomicDropdown> 元件了,但有個問題,點擊選項後無法控制 Menu 是否關閉。
為了在點擊選項後自動關閉 Menu,我們可以在 items 的 onClick 裡加上一個 close 函式,用來關閉 Menu。
const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value);
      active.value = false;
    };
    return {
      ...item,
      onClick,
    };
  });
});
不過這樣一來,使用者點擊選項後 Menu 必定會關閉,這樣的設計在某些情境下可能會造成限制,例如需求可能是想在點擊選項後執行一些操作,然後再手動關閉 Menu。
我們可以讓 items 的 onClick 多接收第二個參數,這個參數是一個函式,當使用者點擊選項後,可以呼叫這個函式來關閉 Menu。
const onClick = (value: string, close: () => void) => {
  // Do something
  close();
};
這樣一來我們就要調整 itemsCompose 的實作。
const active = ref(false);
const close = () => (active.value = false);
const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      item.onClick(item.value, close);
    };
    return {
      ...item,
      onClick,
    };
  });
});
但如果大多數情境下只需點擊選項就關閉 Menu,開發人員還要每次都自己呼叫 close,未免太麻煩。我們可以參考 <AtomicTabs> 的做法,如果開發人員有使用到第二個參數,則認定開發人員想要自己控制是否關閉,如果沒有,則自動關閉 Menu。
為了實現這個功能,我們再次調整 itemsCompose 的實作。
const itemsCompose = computed(() => {
  return props.items.map(item => {
    const onClick = () => {
      if (item.disabled || !isFunction(item.onClick)) return;
      if (item.onClick.length <= 1) {
        item.onClick(item.value, noop);
        close();
        return;
      }
      return item.onClick(item.value, close);
    };
    return {
      ...item,
      onClick,
    };
  });
});
這樣如果開發人員傳入的 onClick 有接收第二個參數,元件就不會自動關閉,將控制權交給開發人員,反之則會自動關閉。
接著我們來處理客製化的需求,我們可能會遇到開發人員想在每個 MenuItem 上加上一些客製化的內容,這時我們可以使用 menuitem slot 來自定義選單的內容。
<template>
  <li
    v-for="item in itemsCompose"
    :key="item.value"
    class="atomic-dropdown__menuitem"
    @click="item.onClick"
  >
    <slot
      :disabled="item.disabled"
      :label="item.label"
      name="menuitem"
      :value="item.value"
    >
      {{ item.label }}
    </slot>
  </li>
</template>
這樣在畫面上可以這樣使用:
<AtomicDropdown :items="items">
  <button type="button">
    <MoreSvg />
  </button>
  <template #menuitem="{ label }">
    <div class="flex justify-between w-40">
      <span>{{ label }}</span>
    </div>
  </template> 
</AtomicDropdown>
在設計上,我們可以在 items 的每個物件上加上一個 context 屬性,並允許開發人員傳入任何他們想要使用的資料,這會有助於使用 slot 判斷哪個選項需要加入哪些客製化內容。
interface AtomicDropdownItem {
  // 略
  context?: any;
}
<li
  v-for="item in itemsCompose"
  :key="item.value"
  class="atomic-dropdown__menuitem"
  @click="item.onClick"
>
  <slot
    :context="item.context"
    :disabled="item.disabled"
    :label="item.label"
    name="menuitem"
    :value="item.value"
  >
    {{ item.label }}
  </slot>
</li>
這樣我們就完成了 <AtomicDropdown> 的實作。

如果可以使用鍵盤操作就更好了,在無障礙實作指南裡面有提到關於 Listbox 的 Pattern,我們可以參考這個指南來實作 <AtomicDropdown> 元件的鍵盤操作功能。

我們在實作 <AtomicTabs> 時有寫了 moveFocus、nextItem 與 previousItem 這三個 function,在這裡我們可以重複利用這些 function。
import { moveFocus, nextItem, previousItem } from '~/utils/dom';
const onMenuKeydown = (event: KeyboardEvent) => {
  const container = menuRef.value as HTMLElement;
  const currentFocus = document.activeElement as HTMLElement;
  if (!container) return;
  switch (event.key) {
    case 'Tab':
      event.preventDefault();
      active.value = false;
      break;
    case 'ArrowDown':
      event.preventDefault();
      moveFocus(container, currentFocus, nextItem);
      break;
    case 'ArrowUp':
      event.preventDefault();
      moveFocus(container, currentFocus, previousItem);
      break;
    case 'Home':
      event.preventDefault();
      moveFocus(container, null, nextItem);
      break;
    case 'End':
      event.preventDefault();
      moveFocus(container, null, previousItem);
      break;
  }
};
我們將 onMenuKeydown 事件綁定在 Menu 上,這樣儘管使使用者聚焦在 MenuItem 上,我們也可以透過事件冒泡機制監聽到鍵盤事件。
<ul
  ref="menu"
  class="atomic-dropdown"
  @keydown="onMenuKeydown"
>
  <!-- 略 -->
</ul>
接著處理在 MenuItem 上按下 Enter 或 Space 的事件,這在 <AtomicButton> 內有提到過。在這裡,MenuItem 的 <li> 元素被視為按鈕使用,因此我們得讓它的行為與按鈕一致。
我們可以在 itemsCompose 裡擴充。
const itemsCompose = computed(() => {
  return props.items.map((item, index) => {
    const onClick = () => {
      // 略
    };
    const onKeydown = (event: KeyboardEvent) => {
      if (event.key !== 'Enter' && event.key !== ' ') return;
      event.preventDefault();
      onClick();
    };
    return {
      ...item,
      onClick,
      onKeydown,
    };
  });
});
並將事件綁定在 MenuItem 上。
<li
  v-for="item in itemsCompose"
  :key="item.value"
  class="atomic-dropdown__menuitem"
  @click="item.onClick"
  @keydown="item.onKeydown"
>
  <!-- 略 -->
</li>
到這裡,我們可以用 Up Arrow、Down Arrow、Home、End 來移動焦點,並且在 MenuItem 上按下 Enter 或 Space 來觸發點擊事件。
接著處理當開啟 Menu 時,將焦點自動移動到第一個 MenuItem。
我們需要先取得 Menu 的 Element,才能找到 Menu 裡的第一個 MenuItem。
<ul
  ref="menuRef"
  class="atomic-dropdown"
>
  <!-- 略 -->
</ul>
接著我們觀察 active 的變化,當 active 變為 true 時,我們就可以將焦點移動到第一個 MenuItem。
const menuRef = ref<HTMLElement>();
watch([active, menuRef] as const, ([isActive, menu]) => {
  if (!isActive || !menu) return;
  moveFocus(menu, null, nextItem);
});
最後處理當關閉 Menu 時,將焦點移回到觸發 Menu 的元素。
但這一步有個比較困難的問題,Reference 是由開發人員傳入的,我們要怎麼取得它呢?幸好我們在 <AtomicPopover> 中有類似的實作,可以重複利用這個 function。
import findFirstLegitChild from '~/helpers/findFirstLegitChild';
const referenceRef = ref<HTMLElement>();
const ReferenceComponent = defineComponent({
  name: 'ReferenceComponent',
  setup() {
    return () => {
      const child = findFirstLegitChild(slots.default?.());
      if (!child) return;
      return withDirectives(child, [
        [
          {
            mounted(el: HTMLElement) {
              referenceRef.value = el;
            },
            updated(el: HTMLElement) {
              referenceRef.value = el;
            },
            unmounted() {
              referenceRef.value = undefined;
            },
          },
        ],
      ]);
    }
  }
})
模板部分改使用 <ReferenceComponent>。
<template>
  <AtomicPopover>
    <template #reference>
      <ReferenceComponent />
    </template>
    <!-- 略 -->
  </AtomicPopover>
</template>
這樣我們就可以取得 Reference 的 Element,並在關閉 Menu 時將焦點移回到 Reference 上。
watch([active, referenceRef] as const, ([isActive, reference]) => {
  if (isActive || !reference) return;
  reference.focus();
});
前面實作的鍵盤操作部分已經涵蓋了無障礙的一部分,但我們還可以再加入一些設定來讓元件更符合無障礙標準。
在 HTML 元素上添加 role 屬性會讓網頁對於使用輔助技術(如螢幕閱讀器)的使用者更加友善。
在這裡我們要加上 menu、menuitem 這兩個 role。
<ul
  class="atomic-dropdown"
  role="menu"
>
  <li
    v-for="item in itemsCompose"
    :key="item.value"
    class="atomic-dropdown__menuitem"
    role="menuitem"
  >
    <!-- 略 -->
  </li>
</ul>
在 <AtomicPopover> 內部已經實作了 aria-controls 與 aria-expanded,在這裡我們只需要再加上 aria-haspopup 屬性即可。
aria-haspopup 可以接受的值有:menu、listbox、tree、grid、dialog 以及 true,其中 true 與 menu 的含義相同。
<template>
  <AtomicPopover v-model="active">
    <template #reference>
      <component 
        :is="ReferenceVNode"
        aria-haspopup="true"
      />
    </template>
    <!-- 略 -->
  </AtomicPopover>
</template>
在 <AtomicDropdown> 的實作中,我們以 <AtomicPopover> 為基礎,實作了一個可以顯示彈出選單的元件。我們可以透過 items 來提供選單選項,並且還可以透過 menuitem slot 來自定義選項內容。
鍵盤操作部分我們參考了無障礙實作指南,並應用了在 <AtomicPopover>、<AtomicTabs> 與 <AtomicButton> 中實作與提到的概念,讓元件更加好用。也因為建立在這些元件的基礎與知識上,我們才能更有效率地完成這個元件。
<AtomicDropdown> 原始碼:AtomicDropdown.vue
<AtomicPopover> 實作回顧:[為你自己寫 Vue Component] AtomicPopover