iT邦幫忙

2024 iThome 鐵人賽

DAY 7
3
Modern Web

為你自己寫 Vue Component系列 第 7

[為你自己寫 Vue Component] AtomicPopover

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicPopover

彈出視窗(Popover,浮動視窗)通常隨著使用者的互動而顯示,它浮動於互動元素的周圍,主要用來提供附加資訊或操作,而不會改變頁面佈局。

<AtomicPopover> 可以作為有「彈出視窗」功能元件的底層元件,例如:<AtomicDropdown><AtomicTooltip><AtomicSelect> 等。如果需要提供更多資訊或滿足互動需求,開發人員也可以直接使用 <AtomicPopover> 並根據需求調整顯示的內容。

由於 <AtomicPopover> 幾乎可作為所有具有「彈出」功能的元件基礎,所以在這個元件需要考量的情境會非常多且複雜。

元件分析

元件架構

AtomicPopover 元件架構

  1. Reference:觸發 Popover 的元素。
  2. Popover:彈出視窗的內容。

功能設計

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

Element Plus

Element Plus Popover

<template>
  <ElPopover placement="right" trigger="click">
    <template #reference>
      <ElButton style="margin-right: 16px">Click to activate</ElButton>
    </template>

    <ElTable :data="gridData">
      <ElTableColumn width="150" property="date" label="date" />
      <ElTableColumn width="100" property="name" label="name" />
      <ElTableColumn width="300" property="address" label="address" />
    </ElTable>
  </ElPopover>

  <ElPopover
    :visible="visible"
    placement="top-start"
    title="Title"
    content="this is content, this is content, this is content"
    offset="8"
  >
    <template #reference>
      <ElButton class="m-2" @click="visible = !visible">
        Manual to activate
      </ElButton>
    </template>
  </ElPopover>
</template>

Element Plus 使用了兩個 slots,referencedefaultreference 是觸發 Popover 的元素,default 是 Popover 的內容。

事件觸發則是透過 trigger 來設定,預設為 hover

值得注意的是,從範例中發現 Element Plus 的 reference slot 裡面的 <ElButton> 本身沒有事件綁定,渲染結果也沒有多出任何其他的 HTML 結構,除了可以找到元素定位外,還可以隨著 trigger 的設定觸發事件。

Element Plus 渲染出來的 HTML 結構如下:

<button 
  class="el-button el-button--default el-button--medium"
  style="margin-right: 16px"
  type="button" 
>
  <span>Click to activate</span>
</button>

另外,Element Plus 也支援 placementoffsetvisible 等設定。其中 placement 可以設定 Popover 的位置,offset 可以設定 Popover 位置的偏移,visible 允許我們透過外部資料決定 Popover 是否顯示。

Vuetify

Vuetify Popover

<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>
      <!-- 略 -->
    </VList>
  </VMenu>

  <VMenu v-model="active">
    <template v-slot:activator="{ props }">
      <VBtn
        color="primary"
        v-bind="props"
      >
        Activator slot
      </VBtn>
    </template>
    <VList>
      <!-- 略 -->
    </VList>
  </VMenu>
</template>

Vuetify 中與 Popover 功能相同的元件叫 VMenu,一樣有兩個 slots,activatordefaultactivator 是觸發 Popover 的元素,default 是 Popover 的內容。

事件觸發預設是點擊事件,可以透過 open-on-hover 設定為滑鼠移入。

<VMenu>activator slot 會提供 props 這個物件,裡面有各種屬性與事件,提供我們綁定到需要的元件上面。

另外,Vuetify 也提供 locationoffsetmodelValue 等設定,其中 location 可以設定 Popover 的位置,offset 可以設定 Popover 位置的偏移,modelValue 允許我們透過外部資料決定 Popover 是否顯示。

Nuxt UI

Nuxt UI Popover

<template>
  <UPopover 
    :popper="{
      placement: 'bottom-start',
      strategy: 'absolute',
      offsetDistance: 8
    }"
  >
    <UButton color="white" label="Open" />

    <template #panel>
      <div class="p-4">
        <div class="h-20 w-48" />
      </div>
    </template>
  </UPopover>

  <UPopover v-model:open="open">
    <UButton color="white" :label="open.toString()" />

    <template #panel>
      <div class="p-4">
        <div class="h-20 w-48" />
      </div>
    </template>
  </UPopover>
</template>

Nuxt UI 的 <UPopover> 也有兩個 slots,default slot 留給了觸發 Popover 的元素,而 panel slot 則是 Popover 的內容。

在範例中也可以發現,<UButton> 本身沒有綁定任何事件,與 Element Plus 一樣,儘管如此它還是可以觸發點擊事件。不過不同的是,Nuxt UI 渲染出來的 HTML 結構會多出一個 <div> 來包裹 <UButton>,而點擊事件與定位的基準點都落在該 <div> 上面。

<div
  class="inline-flex w-full" 
  role="button" 
  id="headlessui-popover-button-nzBDbpLyCOd-7" 
  aria-expanded="false"
>
  <button type="button">
    <span>Open</span>
  </button>
</div>

另外,Nuxt UI 也提供 popperopen 等設定,其中 popper 可以設定 Popover 的位置、偏移、定位方式,open 允許我們透過外部資料決定 Popover 是否顯示。

綜合以上並結合自身經驗,我們統整出 <AtomicPopover> 的功能:

  • 可以使用 referencedefault slots 分別提供觸發 Popover 的元素與 Popover 的內容。
  • 可以透過 placement 來決定 Popover 的位置。
  • 可以透過 trigger 來決定 Popover 的觸發事件。
  • 可以透過 offset 來調整 Popover 的位置偏移。
  • 可以透過 modelValue 來決定 Popover 是否顯示,這個參數要是可選的,也就是說使用者如果沒有傳入,元件內部要有自己管理的顯示狀態。

使用結構如下:

<template>
  <AtomicPopover
    :placement="placement"
    :trigger="trigger"
    :offset="offset"
  >
    <template #reference>
      <AtomicButton>Click to activate</AtomicButton>
    </template>
    
    <div>
      Popover content
    </div>
  </AtomicPopover>
</template>

元件實作

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

Props

名稱 型別 預設值 說明
modelValue boolean false Popover 是否顯示
trigger MaybeArray<click | hover | focus | touch> click 觸發 Popover 的事件
placement top, right, bottom, left, top-start, top-end, right-start, right-end, bottom-start, bottom-end, left-start, left-end bottom Popover 出現的位置
offset number, Partial<{ mainAxis: number; crossAxis: number; }> 8 Popover 的位置偏移

Emits

名稱 型別 說明
update:modelValue (value: boolean) => void 更新 Popover 顯示狀態

將 Props 與 Emits 定義在 <script setup> 中,如下:

type Trigger = 'click' | 'hover' | 'focus' | 'touch';

type Side = 'top' | 'right' | 'bottom' | 'left';
type Alignment = 'start' | 'end';
type Placement = `${Side}-${Alignment}`;

interface AtomicPopoverProps {
  modelValue: boolean;
  trigger?: Trigger | Trigger[];
  placement?: Side | Placement;
  offset?:
    | number
    | Partial<{
        mainAxis: number;
        crossAxis: number;
      }>;
}

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

const props = withDefaults(defineProps<AtomicPopoverProps>(), {
  modelValue: undefined,
  trigger: 'click',
  placement: 'bottom',
  offset: 8,
});

const emit = defineEmits<AtomicPopoverEmits>();

基礎架構

為了讓元件可以有雙向綁定的效果我們將 modelValue 使用 computed 包裝成 modelValueWritable

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

接著,我們先從最單純的 <template> 開始,把我們需要的 slot 放到裡面。

<template>
  <template v-if="$slots.reference">
    <span
      class="atomic-popover__reference"
      role="button"
      tabindex="0"
      @click="emit('update:modelValue', !modelValue)"
      @keydown.enter="emit('update:modelValue', !modelValue)"
      @keydown.space="emit('update:modelValue', !modelValue)"
    >
      <slot name="reference" />
    </span>
  </template>
  <template v-if="modelValueWritable">
    <Teleport to="body">
      <!-- 以下簡稱 Popper -->
      <div class="atomic-popover">
        <slot name="default" />
      </div>
    </Teleport>
  </template>
</template>

這時我們就可以在專案中這樣使用:

<template>
  <AtomicPopover v-model="active">
    <template #reference>
      <AtomicButton>
        按鈕內容
      </AtomicButton>
    </template>

    這裡是彈出視窗的內容
  </AtomicPopover>
</template>

計算 Placement 定位

在開始進行計算前,我們先來簡單認識 DOM Element 上的 getBoundingClientRect 方法。

getBoundingClientRect 方法會回傳一個 DOMRect 物件,裡面包含了元素的位置與大小資訊,例如:topleftrightbottomwidthheight 等等。另外還有一個 toJSON 方法,可以將 DOMRect 物件轉換成 JSON 格式。

interface DOMRect {
  readonly bottom: number;
  readonly height: number;
  readonly left: number;
  readonly right: number;
  readonly top: number;
  readonly width: number;
  readonly x: number;
  readonly y: number;
  toJSON(): any;
}
  • top / y:元素上邊緣到視窗上邊緣的距離。
  • left / x:元素左邊緣到視窗左邊緣的距離。
  • right:元素右邊緣到視窗左邊緣的距離。
  • bottom:元素下邊緣到視窗上邊緣的距離。
  • height:元素的高度。
  • width:元素的寬度。

下面這張圖可以很清楚地標示每個回傳值的意義:

DOMRect object that is the smallest rectangle containing the entire element.

為了方便計算,我們需要使用 ref 取得 Reference 與 Popover 的 DOM 元素,並且記錄它們的位置與大小資訊。

const referenceRef = ref<HTMLElement>();
const popoverRef = ref<HTMLElement>();
<template>
  <template v-if="$slots.reference">
    <span
      ref="referenceRef"
      class="atomic-popover__reference"
    >
      <slot name="reference" />
    </span>
  </template>
  <template v-if="modelValueWritable">
    <Teleport to="body">
      <div 
        ref="popoverRef"
        class="atomic-popover"
      >
        <slot name="default" />
      </div>
    </Teleport>
  </template>
</template>

在 Popover 元素渲染後取得 Reference 與 Popover 的 DOMRect

interface DomRectLike {
  bottom: number;
  height: number;
  left: number;
  right: number;
  top: number;
  width: number;
  x: number;
  y: number;
}

const referenceRect = ref<DOMRectLike>();
const popoverRect = ref<DOMRectLike>();

const parseDOMRect = (node: HTMLElement): DOMRectLike => {
  const rect = node.getBoundingClientRect();
  return rect.toJSON() as DOMRectLike;
}

watch([referenceRef, popoverRef], () => {
  const [reference, floating] = [
    referenceRef.value,
    popoverRef.value,
  ] as const;
  
  if (!reference || !floating) return;

  referenceRect.value = parseDOMRect(reference);
  popoverRect.value = parseDOMRect(floating);
})

有了元素的位置與大小資訊,我們就可以正式開始計算 Popover 的位置了。

假設我們要將 Popover 放在 Reference 元素的下方靠左對齊(placement: 'bottom-start')。

placement: 'bottom-start'

由示意圖我們可以知道,Popover 的 ytop) 位置是 Reference 元素的 bottom 位置,xleft) 位置是 Reference 元素的 left 位置。這樣我們就可以寫出計算 Popover 位置的方法:

const coords = {
  x: reference.left,
  y: reference.bottom,
};

再來我們試試看將 Popover 放在 Reference 元素的下方置中對齊(placement: 'bottom')。

placement: 'bottom'

由示意圖我們可以知道,Popover 的 y 位置一樣是 Reference 元素的 bottom 位置,x 位置是 Reference 元素的 left 位置加上 Reference 元素寬的一半減去 Popover 元素寬的一半。這樣我們就可以寫出計算 Popover 位置的方法:

const coords = {
  x: reference.left + reference.width / 2 - popover.width / 2,
  y: reference.bottom,
};

最後我們來算算看 Popover 放在 Reference 元素的左側置中對齊(placement: 'left')。

placement: 'left'

由示意圖我們可以知道,Popover 的 y 位置是 Reference 元素的 top 位置加上 Reference 元素高的一半減去 Popover 元素高的一半,x 位置是 Reference 元素的 left 位置減去 Popover 元素寬。這樣我們就可以寫出計算 Popover 位置的方法:

const coords = {
  x: reference.left - popover.width,
  y: reference.top + reference.height / 2 - popover.height / 2,
};

按照我們的 placement 設定,一共有 12 種不同的組合,這邊先以這三種作為代表,其他都大同小異。

接下來我們把這些計算的邏輯整理到 floatingStyles 裡面:

const floatingStyles = computed(() => {
  const reference = referenceRect.value;
  const popover = popoverRect.value;

  const style: StyleValue = {
    position: 'absolute',
  };

  if (!reference || !popover) return style;

  let coords: { x: number; y: number };

  switch (props.placement) {
    case 'bottom-start':
      coords = {
        x: reference.left,
        y: reference.bottom,
      };
      break;
    case 'bottom':
      coords = {
        x: reference.left + reference.width / 2 - popover.width / 2,
        y: reference.bottom,
      };
      break;
    case 'left':
      coords = {
        x: reference.left - popover.width,
        y: reference.top + reference.height / 2 - popover.height / 2,
      };
      break;
  }

  return Object.assign(style, {
    top: 0,
    left: 0,
    transform: `translate(${coords.x}px, ${coords.y}px)`,
  });
})

在這裡我們使用 transform 來控制 Popover 的位置而不是 topleft。根據瀏覽器渲染的機制,topleft 的變動會觸發瀏覽器重排(Reflow)與重繪(Repaint),而 transform 只會觸發合成(Compositing),所以在這裡使用 transform 會有比較好的效能表現。

計算完的結果我們綁定到 Popover 元素上就可以正確定位了。

<div 
  ref="popoverRef"
  class="atomic-popover"
  :style="floatingStyles"
>
  <slot name="default" />
</div>

計算 Offset 定位

我們現在的定位幾乎都是貼著 Reference 元素的邊緣,但是有時候我們會希望 Popover 與 Reference 有一點距離,這時候我們就可以使用 offset 來調整 Popover 的位置。

offset 的型別定義中它可能是一個數字,也可能是一個物件。我們先來看看物件的部分:

type Offset = Partial<{
  mainAxis: number;
  crossAxis: number;
}>;

物件裡面有 mainAxiscrossAxis 兩個屬性,我們先了解一下這兩個屬性的意義。

mainAxis

主軸,也就是 Popover 與 Reference 元素的邊緣的距離。

<template>
  <AtomicPopover :offset="{ mainAxis: 20 }">
    <!--  -->
  </AtomicPopover>
</template>

mainAxis

crossAxis

交錯軸,也就是 Popover 與 Reference 元素之間的滑動(錯位)。

<template>
  <AtomicPopover :offset="{ crossAxis: 20 }">
    <!--  -->
  </AtomicPopover>
</template>

crossAxis

而如果 offset 是一個數字,我們可以將這個數字設定給 mainAxis

const { mainAxis, crossAxis } = isNumber(props.offset)
  ? { mainAxis: props.offset, crossAxis: 0 }
  : { mainAxis: 0, crossAxis: 0, ...props.offset };

接著我們結合計算 placement 的範例算算看:

元素的下方靠左對齊(placement: 'bottom-start')。

placement: 'bottom-start' with offset

由示意圖我們可以知道,Popover 的 y 位置是原本計算的結果加上 mainAxisx 位置是原本計算的結果加上 crossAxis。這樣我們就可以寫出計算 Popover 位置的方法:

const coords = {
  x: reference.left + crossAxis,
  y: reference.bottom + mainAxis,
};

Popover 放在 Reference 元素的左側置中對齊(placement: 'left')。

placement: 'left' with offset

由示意圖我們可以知道,Popover 的 y 位置是原本計算的結果加上 crossAxisx 位置是原本計算的結果減掉 mainAxis。這樣我們就可以寫出計算 Popover 位置的方法:

const coords = {
  x: reference.left - popover.width - mainAxis,
  y: reference.top + reference.height / 2 - popover.height / 2 + crossAxis,
};

以此類推,我們可以將這些計算的邏輯整理到 floatingStyles 裡面:

const floatingStyles = computed(() => {
  const reference = referenceRect.value;
  const popover = popoverRect.value;

  const style: StyleValue = {
    position: 'absolute',
  };

  if (!reference || !popover) return style;

  const { mainAxis, crossAxis } = isNumber(props.offset)
    ? { mainAxis: props.offset, crossAxis: 0 }
    : { mainAxis: 0, crossAxis: 0, ...props.offset };

  let coords: { x: number; y: number };

  switch (props.placement) {
    case 'bottom-start':
      coords = {
        x: reference.left + crossAxis,
        y: reference.bottom + mainAxis,
      };
      break;
    case 'bottom':
      coords = {
        x: reference.left + reference.width / 2 - popover.width / 2 + crossAxis,
        y: reference.bottom + mainAxis,
      };
      break;
    case 'left':
      coords = {
        x: reference.left - popover.width - mainAxis,
        y: reference.top + reference.height / 2 - popover.height / 2 + crossAxis,
      };
      break;
  }

  return Object.assign(style, {
    top: 0,
    left: 0,
    transform: `translate(${coords.x}px, ${coords.y}px)`,
  });
})

計算視窗滾動距離

我們計算了 placementoffset 的各種情況,但因為 getBoundingClientRect 回傳的是相對於視窗的位置,所以當視窗滾動時 Popover 的位置就會不準確。

const floatingStyles = computed(() => {
  // 略

  coords.x += window.scrollX
  coords.y += window.scrollY

  return Object.assign(style, {
    top: 0,
    left: 0,
    transform: `translate(${coords.x}px, ${coords.y}px)`,
  });
})

觸發(Trigger)事件

接下來處理事件的部分,我們支援 clickhoverfocustouch 四種設定對應的事件,分別是:

  • click:onClickonKeydown
  • hoveronMouseenteronMouseleave
  • focusonFocusonBlur
  • touchonTouchstartonTouchend
<span
  ref="referenceRef"
  class="atomic-popover__reference"
  @blur="onBlur"
  @click="onClick"
  @focus="onFocus"
  @keydown="onKeydown"
  @mouseenter="onMouseenter"
  @mouseleave="onMouseleave"
  @touchend="onTouchend"
  @touchstart.passive="onTouchstart"
>
  <slot name="reference" />
</span>

接下來我們一個一個事件來處理:

trigger 的型別定義中,它可能是一個字串(string),也可能是一個陣列(string[])。為了方便操作,我們先把它統一成陣列。

click

我們需要檢查陣列中是否包含 click,如果不包含則不執行後續的邏輯。

const onClick = () => {
  if (!toArray(props.trigger).includes('click')) return;
  modelValueWritable.value = !modelValueWritable.value;
}

const onKeydown = (event: KeyboardEvent) => {
  if (!toArray(props.trigger).includes('click')) return;
  if (event.target.tagName === 'BUTTON') return;
  if (event.key !== 'Enter' && event.key !== ' ') return;
  modelValueWritable.value = !modelValueWritable.value;
}

<AtomicButton> 那篇有提到,如果需要使用 <div><span> 這類的元素來觸發 click 事件,我們除了要加上 click 的事件監聽外,還要加上 keydown 的事件監聽並且將 tabindex="0" 來讓這個元素可以被鍵盤操作。

hover

DOM 本身沒有 hover 事件,我們要使用 mouseentermouseleave 來模擬 hover 事件。

const onMouseenter = () => {
  if (!toArray(props.trigger).includes('hover')) return;
  modelValueWritable.value = true;
}

const onMouseleave = () => {
  if (!toArray(props.trigger).includes('hover')) return;
  modelValueWritable.value = false;
}

focus

const onFocus = () => {
  if (!toArray(props.trigger).includes('focus')) return;
  modelValueWritable.value = true;
}

const onBlur = () => {
  if (!toArray(props.trigger).includes('focus')) return;
  modelValueWritable.value = false;
}

touch

const onTouchstart = () => {
  if (!toArray(props.trigger).includes('touch')) return;
  modelValueWritable.value = true;
}

const onTouchend = () => {
  if (!toArray(props.trigger).includes('touch')) return;
  modelValueWritable.value = false;
}

Resize 事件

我們算好了 Popover 的位置與偏移,現在能夠精準定位了,但是萬一使用者在 Popover 顯示的時候改變了視窗大小,這時候 Popover 的位置就會不準確了。

Resize event

要解決這個問題我們需要監聽視窗的 resize 事件,當事件觸發時重新計算 Popover 的位置。

const updateDOMRect = () => {
  const [reference, floating] = [
    referenceRef.value,
    popoverRef.value,
  ] as const;

  if (!reference || !floating) return;

  referenceRect.value = parseDOMRect(reference);
  popoverRect.value = parseDOMRect(floating);
};

watch([referenceRef, popoverRef], updateDOMRect);

onMounted(() => {
  window.addEventListener('resize', updateDOMRect);
});

onUnmounted(() => {
  window.removeEventListener('resize', updateDOMRect);
});

ClickOutside

現在還有一個小細節沒有完成,當 Popover 顯示的時候點擊 Popover 以外的地方 Popover 應該要消失。

要解決這個問題很簡單,我們只需要監聽 click 事件,當事件觸發時檢查點擊的元素是否在 Popover 元素內,如果不在 Popover 元素內 Popover 就消失。

我們可以透過 event.composedPath().includes(floating) 檢查點擊到的元素是否在 Popover 元素內。

const onClickOutside = (event: Event) => {
  if (!modelValueWritable.value) return;

  const reference = referenceRef.value!;
  const floating = popoverRef.value!;

  if (
    event.target === reference ||
    event.composedPath().includes(reference) ||
    event.target === floating ||
    event.composedPath().includes(floating)
  ) {
    return;
  }

  modelValueWritable.value = false;
};

onMounted(() => {
  document.addEventListener('click', onClickOutside);
});

onUnmounted(() => {
  document.removeEventListener('click', onClickOutside);
});

AtomicPopover ClickOutside

Floating UI

在前面我們花了很多篇幅在計算 Popover 的位置與偏移,但還有很多細節邊緣案例沒有處理,例如 Popover 超出視窗、箭頭 UI 定位等等。

我們還是可以把所有情境攤開來,一步一步計算,力求覆蓋所有可能性。但在時間有限的情況下找個工具幫我們處理這些繁雜的計算對提高效率是很有幫助的。Floating UI 是一個專門用於定位浮動元素的 JavaScript Library,它提供了一個簡單的 API 來幫助我們計算 Popover 的位置與偏移。

import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';

const { floatingStyles } = useFloating(referenceRef, popoverRef, {
  open: modelValueWritable,
  placement: () => props.placement,
  whileElementsMounted: autoUpdate,
  middleware: () => [
    offset(props.offset), 
    flip(), 
    shift()
  ],
});

這樣就可以完成 Popover 的位置與偏移計算,而且還支援了 Popover 超出視窗的處理。省下來的時間我們可以把精力放在 <AtomicPopover> 的其他功能處理上,加速開發。

進階功能

非受控元件(Uncontrolled Component)

大多數時候,元件本身不會有自己的狀態,元件本身啟動與否是由外部資料控制的,這樣的元件我們稱之為受控元件(Controlled Component)。

<template>
  <AtomicPopover v-model="active">
    <!--  -->
  </AtomicPopover>
</template>

範例中 <AtomicPopover>modelValue 是由外部資料控制的。

但有些時候我們並不在意元件的狀態,像是 <AtomicTooltip><AtomicDropdown><AtomicSelect>,大多數時候在元件本身以外我們並不需要知道他們的狀態,這時我們就可以以非受控元件(Uncontrolled Component)的方式來設計它。

目前的 <AtomicPopover> 開啟與否的狀態是由我們傳入的 modelValue 控制的,我希望當我們沒有傳入 modelValue 的時候,元件內部有自己的狀態來控制 Popover 的顯示。

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

自動綁定事件到 Reference 上

在 Element Plus 的範例中,<ElPopover>reference slot 裡面的元素本身沒有綁定任何事件,但是它還是可以觸發 Popover 的顯示。Nuxt UI 的 <UPopover> 也是一樣的功能。但分別檢查他們所生成出來的 HTML 後發現 Nuxt UI 其實是在 default slot 外層多包了一層 <div>,而 Element Plus 則沒有額外的元素出現。

現在我們的做法與 Nuxt UI 一樣,在 reference slot 外面多包了一層 <span> 元素而所有的事件與屬性都加在這個元素上面。

翻了一下 Element Plus 原始碼發現要做到這點其實有點不容易,Element Plus 對 slots 做了一些特別的處理,所以事件其實是綁定在我們傳入的 reference slot 上面,我們嘗試理解並讓 <AtomicPopover> 也能夠做到這點。

首先我們可以觀察一下我們可以拿到的 slots 物件:

interface AtomicPopoverSlots {
  reference?: () => ReturnType<Slot>;
  default?: () => ReturnType<Slot>;
}

const slots = defineSlots<AtomicPopoverSlots>();

如果沒有型別需求的話,上面這段完全等同於:

import { useSlots } from 'vue'

const slots = useSlots();

接下來我們要在 <AtomicPopover> 內建立一個新的元件,<ReferenceComponent>,這個元件要處理並渲染 <AtomicPopover> 的 reference slot,用來代替原本 reference slot。

<template>
  <template v-if="$slots.reference">
    <ReferenceComponent class="atomic-popover__reference" />
  </template>
</template>

我們來處理 <ReferenceComponent> 這個元件:

const ReferenceComponent = defineComponent({
  name: 'ReferenceComponent',
  setup() {
    return () => {
      const child = slots.reference?.()[0];
      return child;
    }
  }
})

我們間單的設計了一個 <ReferenceComponent> 元件,再來我們要拿到 Reference 元素的 DOM:

沿用原本的作法,使用 referenceRef

<template>
  <template v-if="$slots.reference">
    <ReferenceComponent
      ref="referenceRef"
      class="atomic-popover__reference"
    />
  </template>
</template>

這方法可行,但這裡的 referenceRef 會變成元件而不是 HTMLElement,實作上需要調整一下。例如:

// 原本
referenceRect.value = parseDOMRect(referenceRef.value);

// 調整後
referenceRect.value = parseDOMRect(referenceRef.value.$el);

可以試試看 render function。這是更進階的用法,但原本寫好的地方不用作任何修改。

import { withDirectives } from 'vue'

const ReferenceComponent = defineComponent({
  name: 'ReferenceComponent',
  setup() {
    return () => {
      const child = slots.reference?.()[0];
      if (!child) return;

      return withDirectives(child, [
        [
          {
            mounted(el: HTMLElement) {
              referenceRef.value = el;
            },
            updated(el: HTMLElement) {
              referenceRef.value = el;
            },
            unmounted() {
              referenceRef.value = undefined;
            },
          },
        ],
      ]);
    };
  },
});

withDirectives 是 Vue 的 render function API,它可以讓我們在 VNode 上加上指令,這樣我們就可以在 mountedupdatedunmounted 這些生命週期裡面取得 Reference 的 DOM。

這樣我們就可以在 updateDOMRect 裡面正確取得 Reference 的 DOM 了。

不過當前做法僅限於使用時在 reference slot 裡面只有一個元素,並且有一個根節點。如果遇到第一個元素是註解、沒有根元素等情況,這個方法就無法正確取得 Reference 的 DOM 了。

<template>
  <AtomicPopover>
    <template #reference>
        <!-- 註解 -->
       <AtomicButton>Click to activate</AtomicButton>
    </template>
  </AtomicPopover>

  <AtomicPopover>
    <template #reference>
       Click to activate(沒有根元素)
    </template>
  </AtomicPopover>
</template>

上面取的 reference slot 的作法是單純只取第一個節點,我們需要一個 function 幫我們排除註解並且在在 reference slot 裡面找到的第一個有效節點沒有根元素(純文字)時做一些包裝處理。

import { Comment, Fragment, h, Text } from 'vue';

function findFirstLegitChild(nodes: VNode[] | undefined): VNode | null {
  if (!nodes) return null;

  for (const node of nodes) {
    if (typeof node === 'object') {
      switch (node.type) {
        case Comment:
          continue;
        case Text:
        case 'svg':
          return wrapTextContent(node);
        case Fragment:
          return findFirstLegitChild(node.children as VNode[]);
        default:
          return node;
      }
    }

    return wrapTextContent(node);
  }
  return null;
}

function wrapTextContent (content: string | VNode) {
  return h('span', { role: 'button', tabindex: 0 }, content)
}

CommentFragmentText 分別表示各種節點的類型,每個類型我們都有不同的處理方式,像是如果遇到的是文字(Text),我們就會在它外層包裝成一個 <span> 元素,這樣就可以確保每次都會有一個根節點。

這樣我們就完成了一個盡可能涵蓋各種情境的 <ReferenceComponent> 元件了。

const ReferenceComponent = defineComponent({
  name: 'ReferenceComponent',
  setup() {
    return () => {
      const child = findFirstLegitChild(slots.reference?.());
      if (!child) return;

      return withDirectives(child, [
        [
          {
            mounted(el: HTMLElement) {
              referenceRef.value = el;
            },
            updated(el: HTMLElement) {
              referenceRef.value = el;
            },
            unmounted() {
              referenceRef.value = undefined;
            },
          },
        ],
      ]);
    };
  },
});

無障礙

ARIA 屬性

  • aria-controls 用來表示被控制元素是哪一個。
  • aria-expanded 來表示元素是否展開。
const id = `popover-${Math.round(Math.random() * 1e5)}`;
<template>
  <template v-if="$slots.reference">
    <ReferenceComponent
      :aria-controls="id"
      :aria-expanded="modelValueWritable"
      class="atomic-popover__reference"
    />
  </template>
  <template v-if="$slots.reference && modelValueWritable">
    <Teleport to="body">
      <div
        :id="id"
        ref="popoverRef"
        class="atomic-popover"
        :style="floatingStyles"
      >
        <slot name="default" />
      </div>
    </Teleport>
  </template>
</template>

總結

<AtomicPopover> 定位為各種基礎元件的底層元件,因此除了定位計算外沒有任何的樣式設定。我們花了很大量的精力在逐步計算 Popover 的位置與偏移上,計算 Popover 的位置與偏移是一個不困難但非常繁雜的過程,在實際開發中我自己大多會選用 Floating UI 這個工具來加速開發的流程。

不過作為軟體工程師,有時間的話還是很推薦自己從頭實作一次,不僅可以更深入了解這些工具背後的運作原理,也可以更好地感受這些工具的設計巧思。

在進階需求部分,我們實作了非受控元件,這在元件設計上是一個很實用的思考方向,歸根結底就是在設計元件時思考究竟哪些是元件自己管理的狀態,哪些不該自己擁有狀態,以及哪些可以受控與非受控兼並存。

另外我們也實作了自動綁定事件到 Reference 上的功能,這是一個蠻進階的作法,有點難,但可以幫助我們的元件渲染成 HTML 時有更漂亮的結構。

參考資料


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

尚未有邦友留言

立即登入留言