iT邦幫忙

2024 iThome 鐵人賽

DAY 24
3
Modern Web

為你自己寫 Vue Component系列 第 24

[為你自己寫 Vue Component] AtomicSelect

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicSelect

下拉選單(Select)在表單操作中非常常見。使用下拉選單可以在有限的空間內顯示大量選項,透過點擊來展開選單,使用者可以輕鬆瀏覽並選擇其中一個或多個選項。無論是在註冊表單還是搜尋篩選,下拉選單都是非常好用的元件。

在這裡,我們的 <AtomicSelect> 基於 <AtomicPopover> 來實作。<AtomicSelect> 的實作與 <AtomicDropdown> 有許多相似之處,但在使用上會有一些不同。<AtomicDropdown> 需要開發人員自行定義觸發按鈕,而 <AtomicSelect> 則內建了這個功能,開發人員在使用時只需定義選項即可。

AtomicSelect

元件分析

元件架構

AtomicSelect 元件架構

  1. Label:Select 的標籤。
  2. Display / Placeholder:顯示選中的值或是 placeholder。
  3. Arrow:Select 的開(關)指示箭頭。
  4. Option(s):Select 的選項。

功能設計

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

Element Plus

Element Plus Select

<template>
  <ElSelect
    v-model="value"
    placeholder="Select"
    size="large"
    style="width: 240px"
  >
    <ElOption
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </ElSelect>
</template>

Element Plus 的 <ElSelect> 使用方式與原生的 <select> 類似,透過 v-model 來綁定選擇的值,並透過 <ElOption> 來定義選項。

除此之外,<ElSelect> 涵蓋的情境非常多,像是多選、選項分組、篩選等功能都有支援。不過在使用上也有一些限制。文件提到,如果 <ElSelect> 需要在 SSR 情境下使用,則需要把元件包在 <ClientOnly> 裡面使用。

此外,如果遇到選項數量極大的下拉選單,Element Plus 也提供了 <ElSelectV2> 元件,它內建虛擬滾動(Virtual Scroll)功能,可以有效提升效能。

Vuetify

Vuetify Select

<template>
  <VSelect
    :items="items"
    label="Default"
  />
</template>

Vuetify 的 <VSelect> 功能相對簡單,延續其一貫風格,下拉選單的處理選用了 items 這個 prop,如果需要調整每個選項的 UI,可以使用 item slot 來達成。

PrimeVue

PrimeVue Select

<Select
  v-model="selectedCity"
  :options="cities" 
  optionLabel="name"
  placeholder="Select a City" 
  class="w-full md:w-56" 
/>

PrimeVue 的 <Select> 接受一個 options 陣列作為選項清單,此外它與 Element Plus 一樣支援了多種不同的功能,像是多選、分組、篩選等功能。遇到大量選項的情境,PrimeVue 在 <Select> 元件中也實作了 Virtual Scroll 的功能,不需要額外使用其他元件。

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

  • 使用 v-model 來綁定選擇的值。
  • 透過 options 來定義選項,每個選項的型別為 { label: string, value: any, disabled?: boolean }
  • 可以設定 placeholder 來顯示在選擇的值為空時的提示文字。
  • 可以設定 disabled 來禁用下拉選單。
  • 可以設定 multiple 來開啟多選功能。
  • 支援 <AtomicFormField> 的 props 與 slots。

使用結構如下:

<template>
  <AtomicSelect
    v-model="selected"
    :options="options"
    placeholder="請選擇"
    :disabled="disabled"
    :multiple="multiple"
  />
</template>

Virtual Scroll 這個功能很酷,但在這裡不會實作。在這些 UI Library 的描述中,他們想解決的場景幾乎都是在破萬的選項以上才會需要用到,這是相對罕見的情況,我們可以等真的有需要時再來實作。

元件實作

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

屬性 型別 預設值 說明
modelValue any[], Set<any> 下拉選單選中的值
options { label: string, value: any, disabled?: boolean; }[] [] 下拉選單選項的清單
placeholder string 下拉選單選中的值為空時的提示文字
disabled boolean false 是否禁用下拉選單
multiple boolean false 是否開啟多選功能
interface AtomicSelectOption {
  value: any;
  label: string;
  disabled?: boolean;
}

interface AtomicSelectProps {
  modelValue?: any;
  options?: AtomicSelectOption[];
  placeholder?: string;
  label?: string;
  labelPlacement?: 'top' | 'left';
  labelWidth?: string | number;
  hideLabel?: boolean;
  name?: string;
  multiple?: boolean;
  message?: string;
  error?: boolean;
  required?: boolean;
  disabled?: boolean;
  filterable?: boolean;
}

interface AtomicSelectEmits {
  (event: 'update:modelValue', value: any): void;
}

const props = withDefaults(defineProps<AtomicSelectProps>(), {
  modelValue: undefined,
  options: undefined,
  placeholder: undefined,
  label: undefined,
  labelPlacement: 'left',
  labelWidth: 'fit-content',
  name: undefined,
  multiple: false,
  message: undefined,
});

const emit = defineEmits<AtomicSelectEmits>();

單選下拉選單

我們分幾個階段逐步完成,先聚焦在單選的 <AtomicSelect>

一開始有提到,<AtomicSelect><AtomicDropdown> 其中一個差異是 <AtomicSelect> 不需要開發人員自行定義 Popover 的觸發按鈕,因此我們需要直接在 <AtomicSelect> 的 template 中實作這個按鈕。

<AtomicFormField
  class="atomic-select"
  :class="{
    'atomic-select--active': active,
    'atomic-select--disabled': props.disabled,
  }"
>
  <AtomicPopover
    v-model="active"
    :disabled="disabled"
    placement="bottom-start"
    trigger="click"
  >
    <template #reference>
      <div
        class="atomic-select__button"
        :tabindex="disabled ? -1 : 0"
      >
        <!-- 按鈕內部 -->
      </div>
    </template>
  </AtomicPopover>
</AtomicFormField>

按鈕部分與 <AtomicTextField> 一樣,但因為 append slot 位置保留用來顯示 <AtomicSelect> 的指示箭頭,所以這裡只保留 prepend slot 這個插槽。

<div
  class="atomic-select__button"
  :tabindex="disabled ? -1 : 0"
>
  <span
    v-if="$slots.prepend"
    class="atomic-select__prepend"
  >
    <slot name="prepend" />
  </span>

  <span class="atomic-select__selection">
    <!-- 顯示選中的值或是 placeholder -->
  </span>

  <ArrowSvg class="atomic-select__arrow" />
</div>

我們需要一個 display 用於顯示選中的值,如果沒有選中的值則顯示 placeholder

const selected = computed(() => {
  const value = modelValueWritable.value;
  return props.options.find(options => options.value === value);
})

const display = computed(() => {
  return selected.value?.label;
});
<span class="atomic-select__selection">
  <span
    :class="{
      'atomic-select__selected': display,
      'atomic-select__placeholder': !display,
    }"
  >
    <slot
      v-if="display"
      name="display"
    >
      {{ display }}
    </slot>
    <template v-else>
      {{ placeholder }}
    </template>
  </span>
</span>

這樣一來,我們就獲得了一個可以點擊按鈕展開的 <AtomicSelect>,接著我們要處理選項渲染與事件綁定的部分。

在每個選項上,除了使用者傳入的資訊外,我們需要有以下資料:

  • selected:該選項是否被選中。
  • onClick:點擊事件,點擊選項後選擇該選項。
  • onKeydown:鍵盤事件,模擬按鈕的行為,選項可以使用鍵盤來選取。

我們使用 computed 來對 props.options 做一些處理:

const optionsComputed = computed(() => {
  if (!props.options) return [];

  return props.options.map((option, index) => {
    const current = modelValueWritable.value;
    const selected = current === option.value;

    return {
      ...option,
      selected,
      attrs: {
        tabindex: '0',
        onClick: onOptionClick(option),
        onKeydown: onOptionKeydown(option),
      },
    };
  });
});
const onOptionClick = 
  (option: AtomicSelectOption) => (event: MouseEvent) => {
    event.preventDefault();
    handleEmitChange(option.value);
  }

const onOptionKeydown =
  (option: AtomicSelectOption) => (event: KeyboardEvent) => {
    if (event.key !== 'Enter') return;

    event.preventDefault();
    handleEmitChange(option.value);
  }

const handleEmitChange = (value: any) => {
  modelValueWritable.value = value;
  active.value = false;
}

接著將 optionsComputed 套入模板中:

<AtomicPopover>
  <template #reference>
    <!-- 按鈕內容 -->
  </template>

  <!-- 選單 menu -->
  <ul
    ref="menuRef"
    class="atomic-select__menu"
  >
    <li 
      v-for="option in optionsComputed"
      :key="option.value"
      class="atomic-select__option"
      :class="{
        'atomic-select__option--selected': option.selected,
        'atomic-select__option--disabled': option.disabled,
      }"
      v-bind="option.attrs"
    >
      {{ option.label }}
    </li>
  </ul>
</AtomicPopover>

鍵盤操作

接著加上 <AtomicDropdown> 中實作過的鍵盤操作功能,這樣我們的 <AtomicSelect> 就幾乎完成了。

  • 按下 Tab 會關閉 <AtomicSelect>
  • 按下 Down Arrow 焦點會移到下一個選項,如果在最後一個則移到第一個。
  • 按下 Up Arrow 焦點會移到上一個選項,如果在第一個則移到最後一個。
  • 按下 Home 焦點會移到第一個選項。
  • 按下 End 焦點會移到最後一個選項。
const onMenuKeydown = (event: KeyboardEvent) => {
  const container = menuRef.value as HTMLElement;
  const currentFocus = document.activeElement as HTMLElement;

  if (!container) return;

  switch (event.key) {
    case ' ':
      event.preventDefault();
      break;
    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;
  }
};
<ul
  ref="menuRef"
  class="atomic-select__menu"
  @keydown="onMenuKeydown"
>
  <!-- 選項 -->
</ul>

moveFocus 中,需要依賴 disabled 屬性或是 aria-disabled 來判斷選項是否可以聚焦,所以我們的 optionsComputed 需要補上是否禁用的資料。

const optionsComputed = computed(() => {
  if (!props.options) return [];

  return props.options.map((option, index) => {
    const current = modelValueWritable.value;
    const selected = current === option.value;

    return {
      ...option,
      selected,
      attrs: {
        tabindex: '0',
        'aria-disabled': option.disabled || false,
        onClick: onOptionClick(option),
        onKeydown: onOptionKeydown(option),
      },
    };
  });
});

📝 NOTE
moveFocusnextItempreviousItem 這三個 function 的實作在 <AtomicTabs> 文章中有提到。

AtomicSelect 鍵盤操作

這樣一來,我們就完成了 <AtomicSelect> 的單選功能。

多選的下拉選單

接下來加入多選功能,多選與單選在資料與操作上有一些差異:

  • 在 Vue 中, <select multiple>modelValue 可以是一個陣列或是一個 Set 物件。
  • 在 Vue 中, <select multiple>modelValue 會紀錄所有選中的選項,並且會按照 <option> 元素的順序排列。
  • 點擊選項後不會關閉 <AtomicSelect>,而是保持開啟狀態。

所有關於 modelValueWritable 的部分我們都要考慮是否為多選,以及開發人員綁定的是陣列還是 Set 物件。

optionsComputed

在多選情境下,我們要找到所有選中的選項,並且將它們的 selected 設為 true

const optionsComputed = computed(() => {
  if (!props.options) return [];

  const current = modelValueWritable.value;
  return props.options.map(option => {
    const selected = props.multiple
      ? isSet(current)
        ? current.has(option.value)
        : Array.isArray(current)
          ? current.some(value => value === option.value)
          : current === option.value
      : current === option.value;

    return {
      ...option,
      selected,
      attrs: {
        // 略
      },
    };
  });
});

handleEmitChange

為了讓 modelValueWritable 在多選的情況下能夠正確排序,新增時不能使用 push 的方式將新選中的資料推到陣列中。最簡單的方式是將 optionsComputed 裡的資料一個一個比對,確認每一個選項是否有選中。

const handleEmitChange = (value: any) => {
  if (props.multiple) {
    const selected = modelValueWritable.value;
    const method = isSet(selected) ? 'has' : 'includes';

    const values = optionsComputed.value
      .filter(option => {
        return option.value === value
          ? !selected?.[method](value)
          : option.selected;
      })
      .map(option => option.value);

    if (method === 'has') {
      modelValueWritable.value = new Set(values);
    } else {
      modelValueWritable.value = values;
    }

    return;
  }

  // 略
}

display

在多選的情境下,我們要顯示所有選中的選項,並且串成一個字串。

const selected = computed(() => {
  const value = modelValueWritable.value;

  if (
    (isSet(value) && !value.size) ||
    (Array.isArray(value) && !value.length) ||
    isNullOrUndefined(value)
  ) {
    return;
  }

  return props.multiple
    ? optionsComputed.value.filter(option => option.selected)
    : optionsComputed.value.find(option => option.selected);
});

const display = computed(() => {
  if (props.multiple) {
    const values = selected.value as AtomicSelectOption[];
    if (!values || values.length === 0) return null;
    return values.map(value => value.label).join(', ');
  }

  return (selected.value as AtomicSelectOption)?.label;
});

到這裡,我們的多選 <AtomicSelect> 也完成了。

點擊 Label 聚焦

為了讓操作體驗更好一些。在 <AtomicTextField><AtomicTextarea> 中,我們點擊 <label> 時可以 focus 到 <input><textarea> 上,我們可以嘗試在 <AtomicSelect> 也加上這個功能。

我們可以在 <AtomicSelect> 中加入一個看不見的 <input> 元素並加上 id,這樣點擊 <label> 時就可以 focus 到 <input> 上。

<template>
  <AtomicFormField>
    <template #default="{ id }">
      <AtomicPopover>
        <template #reference>
          <div class="atomic-select__button">
            <span class="atomic-select__selection">
              <!-- 略 -->
            </span>

            <!-- 加入一個看不見也點不到的 input -->
            <input
              :id="id"
              class="atomic-select__input"
              :disabled="disabled"
              tabindex="-1"
              type="text"
            >
          </div>
        </template>
      </AtomicPopover>
    </template>
  </AtomicFormField>
</template>

加上 id 後,我們預期現在會像之前做的元件一樣,點擊 <label> 後會自動 focus 到那個看不見的 <input> 上。

AtomicSelect 點擊 label 卻展開了選單

實作的結果是,當我們點擊了 <label> 卻展開了下拉選單。

根據 HTML <label>Spec,點擊 <label> 會觸發 <input> 的點擊事件。而這個點擊事件會隨著事件冒泡機制一路往上傳遞,最後觸發了 <div class="atomic-select__button"> 上的點擊事件(由 <AtomicPopover> 加上去的),導致了下拉選單展開。

知道是冒泡事件造成 <AtomicSelect> 被打開就好處理了,我們可以在這個看不見的 <input> 上加上 @click.stopevent.stopPropagation())來阻止事件繼續冒泡。

<input
  :id="id"
  class="atomic-select__input"
  :disabled="disabled"
  tabindex="-1"
  type="text"
  @click.stop
>

AtomicSelect 點擊 label 成功 focus 到 input

現在我們點擊 <label> 後會自動 focus 到看不見的 <input> 上,並且當我們可以按下 Enter 或是 Space 展開選單,儘管當前對焦是在看不見的 <input> 上,而不是按鈕上,這也是仰賴事件冒泡的機制。

📝 NOTE
如果跟我一樣有強迫症,可以對看不見的 <input> 加上 focus 事件,當它被 focus 時自動將焦點轉移到按鈕上。

最後還有一個小細節可以讓使用體驗變得更好。當選單展開時,我們希望已選中的選項可以自動出現在可視範圍內並且 focus,這樣使用者就不需要自己滾動選單來找到自己選中的選項。

我們先用 optionRefs 收集所有的 <li> 元素,方便之後 focus 操作。

const optionRefs = shallowRef<HTMLElement[]>([]);
<li
  v-for="option in optionsComputed"
  :key="option.value"
  ref="optionRefs"
  class="atomic-select__option"
  :class="{
    'atomic-select__option--selected': option.selected,
    'atomic-select__option--disabled': option.disabled,
  }"
  v-bind="option.attrs"
>
  {{ option.label }}
</li>

📝 NOTE
optionRefs 資料我們使用 shallowRef 收集,這樣遇到選項數量偏大的時候,效能上會有很明顯的提升。

在選單展開後,我們找到最後一個選中的選項,並且 focus 到該選項上面。

watch(active, value => {
  if (!value) return;

  const target = props.multiple
    ? (selected.value as AtomicSelectOption[])?.at(-1)
    : selected.value;

  if (!target) return;

  const index = optionsComputed.value.findIndex(item => item === target);
  if (index === -1) return;

  nextTick(() => {
    optionRefs.value[index]?.focus();
  });
});

進階功能

filterable

有時候下拉選單的選項過多,不論使用者是透過滑鼠滾輪逐一尋找,還是透過鍵盤上下鍵選擇,都會覺得不夠方便。這時我們可以考慮加入 filterable 設定,讓使用者可以透過輸入文字來篩選選項,提高使用體驗。

<template>
  <AtomicSelect
    v-model="selected"
    filterable
    label="縣市"
    :options="options"
    placeholder="請選擇"
  />
</template>

AtomicSelect Filterable

UI 部分這裡使用比較簡單的方式處理,當啟用 filterable 模式時,打開 <AtomicSelect> 後原本顯示選項的區塊會變成一個輸入框,使用者可以在這裡輸入文字,下方會顯示符合條件的選項。

<div class="atomic-select__button">
  <span class="atomic-select__selection">
    <!-- 略 -->
  </span>

  <input
    v-if="filterable"
    :id="id"
    ref="filterRef"
    v-model="filter"
    :aria-activedescendant="activedescendant"
    class="atomic-select__filter"
    :placeholder="placeholder"
    type="text"
    @click.stop
  >
  <input
    v-else
    :id="id"
    class="atomic-select__input"
    :disabled="disabled"
    tabindex="-1"
    type="text"
    @click.stop
  >
</div>

這裡我們讓篩選用的 <input><AtomicSelect> 未展開的時候使用與前面處理看不見的 <input> 一樣的隱藏方式,展開選單時再顯示出來。

接著處理選單篩選後的結果,上面篩選用的 <input> 已經雙向綁定了 filter,我們只需要再新增一個 filteredOptionscomputed 來處理過濾後的選項。

const filter = ref('');

const filteredOptions = computed(() => {
  const options = optionsComputed.value;
  if (!options.length || !props.filterable || !filter.value) return options;

  const value = toLowerCase(filter.value);
  return options?.filter(option => {
    return toLowerCase(option.label).includes(value);
  });
});

filteredOptions 承接 optionsComputed 的結果,如果 filter 有值,則將 optionsComputed 中的選項逐一比對,回傳符合條件的選項。

我們的模板改綁定 filteredOptions

<ul
  ref="menuRef"
  class="atomic-select__menu"
>
  <li
    v-for="option in filteredOptions"
    :key="option.value"
    class="atomic-select__option"
    :class="{
      'atomic-select__option--selected': option.selected,
      'atomic-select__option--disabled': option.disabled,
    }"
    v-bind="option.attrs"
  >
    {{ option.label }}
  </li>
</ul>

現在選單選項會跟著我們輸入的文字進行篩選。

在一般的 <AtomicSelect> 裡,我們有處理鍵盤操作的功能,但在 filterable 的情況下,這些操作無法直接套用。我們必須針對這個情況重新設計鍵盤操作的功能。

仔細觀察上面的範例,在 filterable 模式下的 <AtomicSelect>,透過上下鍵按鈕切換選項以及按下 Enter 確認選項的過程中,焦點從來沒有離開篩選用的 <input>。既然焦點沒有離開篩選用的 <input>,要怎麼做到在選項間切換的效果,以及按下 Enter 鍵要怎麼選中選項呢?

我們一步一步處理這些問題。首先,基於 <AtomicPopover> 的設計,在 <AtomicPopover> 展開時,會有 Focus trap 將焦點限制在 Popover 中可以對焦的元素上。現在我們每個 <li> 的選項 tabindex 都設定為 1,所以即使我們什麼都不做,焦點也會自動移動到第一個選項上。

為此,我們要在 filterable 模式下讓所有的選項變成不可對焦的元素。

const optionsComputed = computed(() => {
  if (!props.options) return [];

  // 在 filterable 模式下,選項不可對焦
  const tabindex = props.filterable ? undefined : '0';

  const current = modelValueWritable.value;
  return props.options.map((option, index) => {
    const selected = current === option.value;

    return {
      ...option,
      selected,
      attrs: {
        tabindex,
        'aria-disabled': option.disabled || false,
        onClick: onOptionClick(option),
        onKeydown: onOptionKeydown(option),
      },
    };
  });
});

這樣一來,當選單內沒有任何可對焦的元素,<AtomicPopover> 內的 Focus trap 就不會啟動。

接著我們要處理展開選單後,將焦點放到篩選用的 <input> 上,這樣使用者就不需要移動滑鼠自己將焦點放進去。

watch(active, value => {
  if (!props.filterable) return;
  if (!value) return;

  filterRef.value?.focus();
});

💡 TIP
儘管前面有一個 watch 觀測 active 的變化,但在這裡我會再用一個新的 watch 觀察 active,並且只針對 filterable 模式運作。

watch(active, value => {
  if (props.filterable) return;

  // 處理非 filterable 模式下的行為
}
watch(active, value => {
  if (!props.filterable) return;

  // 處理 filterable 模式下的行為
}

這兩種模式會有一些細節上的不同,混在一起處理固然有更好的效能表現,但在開發人員閱讀程式碼時會比較吃力一點,這點可以依據團隊的接受程度來做取捨。

在把焦點放到篩選用的 <input> 上後,我們要處理鍵盤操作的部分。在 filterable 模式下,我們要讓上下鍵可以在選項間切換,並且按下 Enter 鍵可以選擇選項。

const activeIndex = ref(-1);

const onSearchKeydown = (event: KeyboardEvent) => {
  if (!props.filterable || !active.value) return;

  event.stopPropagation();

  if (event.isComposing) return;

  let currentIndex = activeIndex.value;
  switch (event.key) {
    case 'Enter': {
      event.preventDefault();
      const option = filteredOptions.value[currentIndex];
      if (!option) break;

      handleEmitChange(option.value);
      if (!props.multiple) active.value = false;
      break;
    }
    case 'Tab':
    case 'Escape':
      event.preventDefault();
      active.value = false;
      break;
    case 'ArrowDown':
      event.preventDefault();
      moveIndex(currentIndex, 'next');

      break;
    case 'ArrowUp':
      event.preventDefault();
      moveIndex(currentIndex, 'prev');
      break;
    case 'Home':
      event.preventDefault();
      moveIndex(null, 'next');
      break;
    case 'End':
      event.preventDefault();
      moveIndex(null, 'prev');
      break;
  }
};

moveIndex 的實作:

const moveIndex = (currentIndex: number | null, direction: 'next' | 'prev') => {
  if (!props.filterable || !active.value) return;

  let nextIndex: number;
  const lastIndex = filteredOptions.value.length - 1;

  if (isNullOrUndefined(currentIndex)) {
    nextIndex = direction === 'next' ? lastIndex : 0;
  } else {
    nextIndex =
      direction === 'next'
        ? currentIndex !== lastIndex
          ? currentIndex + 1
          : 0
        : currentIndex !== 0
        ? currentIndex - 1
        : lastIndex;
  }

  const nextFocus = optionRefs.value[nextIndex];
  if (nextFocus?.getAttribute('aria-disabled') === 'true') {
    moveIndex(nextIndex, direction);
    return;
  }

  activeIndex.value = nextIndex;
};

最後將 onSearchKeydown 註冊到篩選用的 <input> 上。

<input
  v-if="filterable"
  :id="id"
  ref="filterRef"
  v-model="filter"
  class="atomic-select__filter"
  :placeholder="placeholder"
  type="text"
  @click.stop
  @keydown="onSearchKeydown"
>

處理方式跟我們在處理一般模式下的鍵盤操作很像,差異在於 filterable 模式下按上下鍵移動的不是焦點,而是改變 activeIndex 這個指標。這部分像是:按下 Enter 鍵時,會將當前指標指向的選項傳給前面寫好的 handleEmitChange 來處理選項的選擇,按下 Escape 鍵關閉選單。

不過現在只有指標會改變,我們視覺上看不出指標指向了哪一個選項,我們可以讓當前指標指向的選項滑動到可視範圍內,並且用 CSS 幫它加上一些樣式。

樣式的部分,我這邊在選項上加上 atomic-select__option--hover 這個 class,當指標指向這個選項時就會套用這個樣式。

<li
  v-for="(option, index) in filteredOptions"
  :key="option.value"
  ref="optionRefs"
  class="atomic-select__option"
  :class="{
    'atomic-select__option--selected': option.selected,
    'atomic-select__option--disabled': option.disabled,
    'atomic-select__option--hover': filterable && index === activeIndex,
  }"
  v-bind="option.attrs"
>
  {{ option.label }}
</li>
watch(
  activeIndex,
  index => {
    if (index === -1) return;
    scrollIntoView(optionRefs.value[index]);
  },
  { flush: 'post' }
);

scrollIntoView 這裡使用瀏覽器原生的 element.scrollIntoView() 方法實現,這個方法可以將元素滾動到可視範圍內。

function scrollIntoView(element: HTMLElement | undefined) {
  // 要等一下 `<AtomicPopover>` 定位完成後再滾動
  requestAnimationFrame(() => {
    element?.scrollIntoView({
      block: 'nearest',
      inline: 'nearest',
    });
  });
}

我們還可以像一般模式一樣,在打開選單後自動將已選取的最後一個選項滾動到可視範圍內。

watch(active, value => {
  if (!props.filterable) return;
  if (!value) return;

  filterRef.value?.focus();
  
  let lastIndex = optionsComputed.value.findLastIndex(item => item.selected);
  if (lastIndex === -1) lastIndex = 0;

  // 改變 activeIndex 來觸發滾動
  activeIndex.value = lastIndex;
});

最後還有兩個細節需要處理,當篩選文字被清除時,我們要將焦點放回到第一個可選取的選項上。

watch(filter, () => {
  if (!props.filterable || !active.value) return;
  const newIndex = filteredOptions.value.findIndex(
    options => !options.disabled
  );

  activeIndex.value = newIndex;
});

以及當關閉選單時,我們要將 activeIndexfilter 重置。

watch(active, value => {
  if (!props.filterable) return;
  if (!value) {
    activeIndex.value = -1;
    filter.value = '';
  }

  // 略
});

試用看看篩選的功能,以及鍵盤操作是否正常運作。

AtomicSelect 可篩選模式鍵盤操作異常

經過幾次篩選操作,我們的鍵盤操作功能就像喝醉酒一樣開始亂跑。檢查了指標的資料,都很正常,那為什麼會這樣呢?

如果有很仔細看 Vue 官方文件,可能已經知道原因了!

Refs inside v-for
It should be noted that the ref array does not guarantee the same order as the source array.

這裡的意思是,Vue 不保證 optionRefs 的順序與 filteredOptions 的順序一致,這也間接地表示 optionRefs 我們 DOM 渲染的順序可能不一致,這就是為什麼我們的指標會亂跑的原因。

一開始 optionRefs 的 DOM 順序與 filteredOptions 一致。

原始的選單順序

幾次篩選過後 optionRefs 的 DOM 順序明顯亂掉了!

幾次篩選過後 optionRefs 的 DOM 順序明顯亂掉了!

既然現在 Vue 無法保證 optionRefs 的順序,我們得另尋他法。我們可以直接透過 DOM 的方法,直接選取選單中的選項。

const queryOptionElements = () => {
  return popoverRef.value?.querySelectorAll('li');
};

這樣一來我們可以捨棄掉 optionRefs,直接使用 queryOptionElements 來取得選單中的選項。

optionRefs.value 改成 queryOptionElements(),以其中一個為例:

watch(activeIndex, index => {
  if (index === -1) return;
  nextTick(() => {
    scrollIntoView(queryOptionElements()?.[index]);
  })
});

因為現在我們的 filteredOptions 與渲染到畫面上的 <li> 順序是一致的,所以這個方法可以讓我們的指標找到正確的選項,我們也就解決了篩選過後鍵盤操作亂跑的問題。

解決了篩選過後鍵盤操作亂跑的問題

無障礙

對於會涉及互動的元件,鍵盤操作是障礙很重要的一環,我們前面使用了很大的篇幅在探討如何處理鍵盤操作,接下來可以加上一些屬性,讓使用輔助技術的使用者也能清楚地知道現在的操作狀態。

角色 Role

這裡我們有幾個角色屬性可以使用:

role="combobox"

這個屬性可以讓輔助技術知道這個元件是一個組合框,使用者可以在這個組合框中輸入文字或選擇選項。

<div
  class="atomic-select__button"
  :tabindex="disabled ? -1 : 0"
  role="combobox"
>
  <!-- 略 -->
</div>

role="listbox"

這個屬性可以讓輔助技術知道這個元件是一個列表框,使用者可以在這個列表框中選擇選項。

<ul
  ref="menuRef"
  class="atomic-select__menu"
  role="listbox"
  tabindex="-1"
>
  <!-- 選項 -->
</ul>

role="option"

這個屬性可以讓輔助技術知道這個元件是一個選項,使用者可以在這個選項上進行選擇。

<li
  v-for="(option, index) in optionsComputed"
  :key="option.value"
  class="atomic-select__option"
  :class="{
    // 略
  }"
  role="option"
  v-bind="option.attrs"
>
  {{ option.label }}
</li>

role="searchbox"

這個屬性可以讓輔助技術知道這個元件是一個搜尋框,使用者可以在這個搜尋框中輸入文字。

<input
  v-if="filterable"
  :id="id"
  ref="filterRef"
  v-model="filter"
  class="atomic-select__filter"
  :placeholder="placeholder"
  role="searchbox"
  type="text"
>

ARIA 屬性

這裡我們有幾個 ARIA 屬性可以使用:

aria-selected

這個屬性可以讓輔助技術知道這個元件是否被選中。

我們前面將選項的 disabled 放在 optionsComputed 裡面,為了一致性,我們也將 aria-selected 放在這裡。

const optionsComputed = computed(() => {
  if (!props.options) return [];

  return props.options.map((option, index) => {
    // ...

    return {
      ...option,
      selected,
      attrs: {
        tabindex: '0',
        'aria-selected': selected,
        'aria-disabled': option.disabled || false,
        // ...
      },
    };
  });
});

aria-activedescendant

這個屬性可以讓輔助技術知道這個元件的焦點在哪裡。

如果是一般模式下,我們不需要特別加上這個屬性,因為在一般模式下我們是實際去移動焦點落在哪一個選項上,這時搭配前面的 role 與 ARIA 屬性,輔助技術就已經可以正確判斷出現在哪一個選項上,該唸(螢幕閱讀器)出什麼內容。

但在 filterable 模式下這就很重要,因為在這個模式下我們的焦點自始至終都在篩選用的 <input> 上,這時我們可以透過 aria-activedescendant 來告訴輔助技術現在的焦點在哪裡。

<input
  v-if="filterable"
  :id="id"
  ref="filterRef"
  v-model="filter"
  :aria-activedescendant="activedescendant"
  class="atomic-select__filter"
  :placeholder="placeholder"
  role="searchbox"
  type="text"
>

activedescendant 要傳入特定選項的 id,輔助技術就會知道它該唸出的內容是什麼。

我們得先幫每個選項都加上 id

const id = `field-${Math.round(Math.random() * 1e5)}`;

const optionsComputed = computed(() => {
  if (!props.options) return [];

  //...
  return props.options.map((option, index) => {
    // ...

    return {
      ...option,
      selected,
      attrs: {
        id: `${id}--option-${option.value}`,
        // ...
      },
    };
  });
});

再來我們計算出 activedescendant

const activedescendant = computed(() => {
  if (!props.filterable || activeIndex.value === -1) return;
  return filteredOptions.value[activeIndex.value]?.attrs.id;
});

這樣一來,當我們在 filterable 模式下,沒有真正 focus 到選項上,但輔助技術也知道現在該唸出來的選項是哪一個。

總結

今天我們實作了 <AtomicSelect> 元件,這個元件基於 <AtomicPopover> 的 Popover 功能與 <AtomicFormField> 的外觀進行設計。<AtomicSelect> 的實作中,許多觀念與方法在之前的文章中已被提及,例如鍵盤操作與移動焦點的方法。

在進階功能部分,我們實作了 filterable 功能,讓使用者可以透過輸入文字來篩選選項,並且透過鍵盤操作來選擇選項。這個功能在選項過多的情況下可以有效提高使用者的操作體驗。

實作 filterable 功能最大的挑戰在於鍵盤操作的部分,因為在這個模式下,焦點自始至終都停留在篩選用的 <input> 上,這時我們要另外處理鍵盤操作,讓使用者可以在選項間切換,並且選擇選項。

最後,我們完善了無障礙的設定。在一般模式下,我們使用了 rolearia 屬性來告知輔助技術每個元素扮演的角色與狀態,而當前 focus 到的選項會被輔助技術正確辨識出來並告訴使用者。而在 filterable 模式下,由於沒有真正的移動焦點,所以我們使用了 aria-activedescendant 來告訴輔助技術當前活躍的選項是哪一個,讓使用輔助技術的使用者也能良好體驗篩選功能。

<AtomicSelect> 的實作真的非常複雜,我真心這麼覺得。儘管我們已經使用了很多過去就完成的元件與方法為基礎,要將 <AtomicSelect> 這樣常見的元件實作得好,確實不容易。

參考資料


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

尚未有邦友留言

立即登入留言