iT邦幫忙

2024 iThome 鐵人賽

DAY 22
2
Modern Web

為你自己寫 Vue Component系列 第 22

[為你自己寫 Vue Component] AtomicTextField

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicTextField

<input> 作為表單控制元素,是網頁開發中最常見的元素之一。<input> 的不同 type 設定會影響顯示的 UI,以及它所代表的含義與功能。

HTML Input Types

在這裡,我們將使用先前建立的 <AtomicFormField> 作為基礎,來實作 <AtomicTextField>

元件分析

元件命名

如同開頭所提到的,在網頁中不同 type<input> 不論在 UI 或功能上都會不同。<input> 可以是文字輸入框、日期選擇器、顏色選擇器,也可以是檔案上傳或按鈕等。因此在這裡選用 <AtomicTextField> 更強調了這是一個專為「文字輸入框」設計的元件。

元件架構

AtomicTextField 元件架構

  1. Label:TextField 的標籤。
  2. Prepend:TextField 的前置 Icon。
  3. Input:文字輸入框,用來輸入文字。
  4. Append:TextField 的後置 Icon,不存在時不渲染。
  5. Message:TextField 的說明文字,可以用來顯示欄位的說明或是錯誤訊息。
  6. Count:顯示目前輸入的字數。

功能設計

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

Element Plus

Element Plus Input

<template>
  <ElInput 
    v-model="input"
    style="width: 640px"
  >
    <template #prefix>
      prefix
    </template>
    <template #suffix>
      suffix
    </template>
    <template #prepend>
      prepend
    </template>
    <template #append>
      append
    </template>
  </ElInput>
</template>

Element Plus 的 <ElInput> 提供了大部分 <input> 重要的功能設定,也提供了 prefix-iconsuffix-icon 這兩個 props,讓開發人員可以在 <ElInput> 的前後放入 Icon 元件。另外,<ElInput> 還提供了 prefixsuffixprependappend 等 slot 來讓開發人員依照需求調整元件的外觀。

但在使用 <ElInput> 時有一些限制必須注意:

  1. 文件表示 <ElInput> 為受控元件,必須與 Vue 的資料綁定才能正常操作。

  2. 文件表示 <ElInput> 不支援修飾符(modifiers),但實際使用後目前僅不支援 v-model.lazy 功能。

  3. 除了 v-model.number 之外,當 type 設定為 number 時,Vue 也會將資料自動轉換成數字,但 <ElInput> 不支援這種使用方式。

    <input type="number" v-model="value" />
    

    上面的範例會將 value 轉換成數字。

    <ElInput type="number" v-model="value" />
    

    上面的範例不會value 轉換成數字。

Vuetify

Vuetify Input

<template>
  <VTextField
    label="Label"
    prepend-icon="mdi-account"
    prepend-inner-icon="mdi-account-box-outline"
    append-icon="mdi-text"
    append-inner-icon="mdi-close"
  >
    <template #prepend> <VICon icon="$vuetify" /></template>
    <template #append> <VICon icon="$vuetify" /></template>
  </VTextField>
</template>

Vuetify 的 <VTextField> 提供了 labelprepend-iconprepend-inner-iconappend-iconappend-inner-icon 等 props,讓開發人員可以在 <VTextField> 的前後放入 Icon 名稱。此外,還提供了 prependprepend-innerappendappend-inner 等 slot,讓開發人員依照需求調整元件的外觀。

在使用 <VTextField> 時也有一些限制必須注意:

  1. <VTextField> 不支援 lazy 修飾符(modifiers)。
  2. <VTextField> 不支援在 type 設定為 number 時自動將資料轉換成數字,這點與 Element Plus 相同。

Nuxt UI

Nuxt UI Input

<template>
  <UInput
    v-model="value"
    type="text"
    leadingIcon="i-heroicons-magnifying-glass-20-solid"
    trailingIcon="i-heroicons-light-bulb"
  />
</template>

Nuxt UI 的 <UInput> 提供了 leadingIcontrailingIcon 這兩個 props,讓開發人員可以在 <UInput> 的前後放入 Icon 名稱。此外,還提供了 leadingtrailing slot,讓開發人員依照需求調整元件的外觀。

與 Element Plus 和 Vuetify 一樣,每個對應的 icon props 都有對應的 slot,但不同的是,在 Element Plus 和 Vuetify 中 slot 與 props 是同時存在的,而 Nuxt UI 中 slot 的權重大於 props,也就是說如果兩者都有設定,則只會顯示 slot 的內容。

另外,Nuxt UI 的 <UInput>v-model 沒有前面兩個 UI Library 上的使用限制,所有的修飾符:trimnumberlazy 都完全支援。不過對於非拉丁語系,如繁體中文,在選字時的處理不夠完整。

Nuxt UI 對於非拉丁語系選字時的處理沒有判斷是否正在選字:

Nuxt UI 尚未支援 composing 的判斷

Vue 原生在 <input> 上的 v-model 有支援判斷是否正在選字:

Vue 的 v-model 原生支援 composing 的判斷

關於非拉丁語系的判斷的問題在這裡列出的三個 UI Library 僅僅只有 Element Plus 有特別處理,另外除了上面列出的功能外,Element Plus 與 Vuetify 都提供字數統計功能,這對於需要限制使用者輸入字數的場景非常有用。

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

  • 使用 v-model 來綁定輸入的值。
  • 支援 modelModifiers,如果 v-model 使用了修飾符,則會收集在這個 props 裡面,如 trimnumberlazy 修飾符。
  • 支援 type 設定,可接受的設定有:textpasswordemailnumbertelurlsearch
  • 支援 showCount,開啟顯示目前輸入的字數,但只對輸入為字串時有效。
  • 支援 <input> 的各種設定,如:placeholderdisabledreadonlyrequiredmaxlengthminlength
  • 支援 appendprepend props 與 slots,讓開發人員可以在 <input> 前後加入需要的 UI。
  • 支援 <AtomicFormField> 的 props 與 slots。

使用結構如下:

<template>
  <AtomicTextField
    v-model="input"
    type="text"
    placeholder="請輸入標題"
    maxlength="10"
    showCount
  />
</template>

元件實作

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

屬性 型別 預設值 說明
modelValue string, number 雙向綁定的值
modelModifiers Record<'trim' | 'number' | 'lazy', boolean> {} v-model 的修飾符
type text, password, email, number, tel, url, search text <input>type 設定
placeholder string <input>placeholder 設定
disabled boolean false <input>disabled 設定
readonly boolean false <input>readonly 設定
required boolean false <input>required 設定
maxlength number <input>maxlength 設定
minlength number <input>minlength 設定
showCount boolean false 顯示目前輸入的字數
append string, Component <input> 後加入的文字
prepend string, Component <input> 前加入的文字

同時我們支援 <AtomicFormField> 的所有 propsslots,這裡就不一一列出。

interface AtomicTextFieldProps {
  modelValue?: string | number;
  modelModifiers?: Record<'trim' | 'number' | 'lazy', boolean>;
  type?: 'text' | 'password' | 'email' | 'tel' | 'number';
  placeholder?: string;
  label?: string;
  labelPlacement?: 'top' | 'left';
  labelWidth?: string | number;
  hideLabel?: boolean;
  prepend?: string | Component
  append?: string | Component
  name?: string;
  maxlength?: InputHTMLAttributes['maxlength']
  minlength?: InputHTMLAttributes['minlength']
  showCount?: boolean;
  message?: string;
  error?: boolean;
  required?: boolean;
  disabled?: boolean;
  readonly?: boolean;
}

interface AtomicTextFieldEmits {
  (event: 'update:modelValue', value: string | number | undefined): void;
}

const props = withDefaults(defineProps<AtomicTextFieldProps>(), {
  modelValue: undefined,
  modelModifiers: () => ({}),
  type: 'text',
  placeholder: undefined,
  label: undefined,
  labelPlacement: 'left',
  labelWidth: 'fit-content',
  prepend: undefined,
  append: undefined,
  name: undefined,
  maxlength: undefined,
  minlength: undefined,
  message: undefined,
});

const emit = defineEmits<AtomicTextFieldEmits>();

雙向綁定

為了讓雙向綁定處理起來更加方便,我們先用 computed 來處理 modelValue 的資料。

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

這裡加上了一個 modelValueLocal 來同步 modelValue 的資料,主要是為了讓開發人員沒有在 <AtomicTextField> 上使用 v-model 時,元件會退而求其次使用內部資料,確保的功能也能正常運作。這也是我們前幾篇有提過的:非受控元件。

接著我們來解決 v-model 雙向綁定與 modelModifiers 設定問題,因為現在的 <input> 被包在 <AtomicTextField> 中,如果我們需要讓所有的 modelModifiers 都能正常運作,看起來我們得自己手動實現 modelModifiers 裡面的每一個修飾符。

我們要處理的有:

  • v-model.trim:如果字串前後有空白,則會自動去除。
  • v-model.number:如果字串可以轉換成數字,則會自動轉換。
  • v-model.lazy:在 <input> 上的雙向綁定原本是 input 事件,但 v-model.lazy 會改成 change 事件。

幸運的是,Vue 3 的 emit 內部已經至少實現了 numbertrim 這兩個修飾符,這表示 v-model.numberv-model.trim 這兩個用法不需要額外處理,但 v-model.lazy 我們還是需要自己實現。

這可能會是個略大的工程,為了實現 lazy 功能,我們不能在 <AtomicTextField> 內部的 <input> 上使用 v-model,我們得像 Vue 底層在處理 v-model 一樣,監聽 inputchange 事件來實作這個功能。

<input
  class="atomic-text-field__input"
  :value="modelValue"
  @input="onInput"
  @change="onChange"
>

接著我們處理遇到 v-model.lazy 的情境。

const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;

  const value = (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};

const onChange = (event: Event) => {
  if (!props.modelModifiers.lazy) return;

  const value = (event.target as HTMLInputElement).value;
  emit('update:modelValue', value);
};

這樣我們的 <AtomicTextField> 就同時支援了 v-modeltrimnumberlazy 修飾符。

接著我們要處理當 type 設定為 number 時,modelValue 需要轉換成數字的功能。我們採用比較簡單的做法,使用 event 物件中的 valueAsNumber 來轉換成數字。

const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;

  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;

  emit('update:modelValue', value);
};

const onChange = (event: Event) => {
  if (!props.modelModifiers.lazy) return;

  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;

  emit('update:modelValue', value);
};

這樣一來,當 <AtomicTextField>type 設定為 number 時,modelValue 就會在輸入的過程中自動轉換成數字。

接著我們還有一個問題需要解決,就是當使用者的語系不是拉丁語系時,如繁體中文,在確定輸入前不應該觸發 input 事件更新 modelValue

要解決這個問題,我們可以使用 Composition 事件,當使用者正在拼字、選字時,compositionstart 事件會觸發,當使用者確定輸入時,compositionend 事件會觸發。所以當 compositionstart 觸發時,我們開啟一個 composing 狀態,當 compositionend 觸發時,我們關閉這個狀態,而在 composing 狀態下的 input 事件不會更新 modelValue

let composing = false;

const onCompositionstart = () => {
  composing = true;
};

const onCompositionend = () => {
  if (!composing) return;

  composing = false;
  event.target.dispatchEvent(new Event('input'));
};

const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  if (composing) return;

  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;

  emit('update:modelValue', value);
};

這樣在繁體中文輸入,拼音、選字的過程中,modelValue 就不會更新,只有在確定輸入時才會更新。

compositionend 事件會發生在 input 事件之後,因此我們需要手動觸發 input 事件,這樣才能順利更新 modelValue

在這裡,我們使用了 compositionstartcompositionend 事件。如果只需支援現代瀏覽器,我們可以在 input 事件上使用 isComposing 來判斷是否正在選字。

const onInput = (event: Event) => {
  if (props.modelModifiers.lazy) return;
  if ((event as InputEvent).isComposing) return

  const number = props.type === 'number';
  const value = number 
    ? (event.target as HTMLInputElement).valueAsNumber
    : (event.target as HTMLInputElement).value;

  emit('update:modelValue', value);
};

到這裡,我們的 modelValue 不僅支援了 trimnumberlazy 修飾符,在 typenumber 時也會轉換為數字,並且支援了非拉丁語系的選字功能。

字數統計

接著我們來處理 showCount 功能,這個功能主要是在 <input> 下方顯示目前輸入的字數,這對於需要限制使用者輸入字數的場景非常有用。

計算字數的方式非常簡單,我們只要在 modelModifiers.numberfalse 並且 type 不等於 number 時計算字數即可。

const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;
  return `${props.modelValue}`.length;
});

這樣的算法有效又簡單,但遇到生僻字或 Emoji 時,可能會有問題。

'𡘙'.length; // length 為 2
'👩‍👩‍👧‍👧'.length; // length 為 11

我們肉眼看到的是一個字,但使用 length 計算的長度卻是 11。如果產品要求這應該視為一個字,我們可以使用 Intl.Segmenter 來幫助我們。

const segmenter = new Intl.Segmenter();

const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;

  const string = props.modelValue;

  if (string === '') return 0;

  let length = 0;
  for (const _ of segmenter.segment(props.modelValue)) { 
    length++;
  }

  return length;
});

不過,Intl.Segmenter 在 Firefox 125.0.1(2024 年 4 月 16 日發布)之前的版本不適用。如果需要支援度較高的作法,我們可以使用像是 lodash.toarraychar-regexstring-length@5.0.1 來實現這個功能。

我們使用 string-length 來實現這個功能。

import stringLength from 'string-length';

const stringCount = computed(() => {
  if (props.modelModifiers.number || props.type === 'number') return 0;
  return stringLength(props.modelValue);
});

這樣一來,如果需求是要計算視覺上看到的字數,我們就可以使用這些方法來完成。

Prepend 與 Append

最後,我們來處理 prependappend 的 props 和 slots。slots 的在模板中我們可以這樣處理,以 prepend 為例:

<span
  v-if="prepend || $slots.prepend"
  class="atomic-text-field__prepend"
>
  <slot name="prepend">
    <template v-if="isString(prepend)">
      {{ prepend }}
    </template>
    <template v-else>
      <component :is="prepend" />
    </template>
  </slot>
</span>

主要的功能都已經完成,我們接著將這些功能整合到 <AtomicTextField> 的模板中。

<template>
  <AtomicFormField
    class="atomic-text-field"
    v-bind="fieldProps"
  >
    <template
      v-if="$slots.label"
      #label
    >
      <slot name="label" />
    </template>
    <template #default="{ id, describedby }">
      <div class="atomic-text-field__container">
        <span
          v-if="prepend || $slots.prepend"
          class="atomic-text-field__prepend"
        >
          <slot name="prepend">
            <template v-if="isString(prepend)">
              {{ prepend }}
            </template>
            <template v-else>
              <component :is="prepend" />
            </template>
          </slot>
        </span>
        <input
          :id="id"
          :aria-describedby="describedby"
          class="atomic-text-field__input"
          :disabled="disabled"
          :maxlength="maxlength"
          :minlength="minlength"
          :name="name"
          :placeholder="placeholder"
          :readonly="readonly"
          :type="type"
          :value="modelValue"
          @change="onChange"
          @compositionend="onCompositionend"
          @compositionstart="onCompositionstart"
          @input="onInput"
        />
        <span
          v-if="append || $slots.append"
          class="atomic-text-field__append"
        >
          <slot name="append">
            <template v-if="isString(append)">
              {{ append }}
            </template>
            <template v-else>
              <component :is="append" />
            </template>
          </slot>
        </span>
      </div>
    </template>
    <template
      v-if="message || $slots.message || shouldShowCount"
      #message
    >
      <span class="atomic-text-field__message">
        <slot name="message">
          {{ message }}
        </slot>
      </span>
      <span v-if="shouldShowCount">
        {{ stringCount }}/{{ maxlength }}
      </span>
    </template>
  </AtomicFormField>
</template>

最後,依照專案需求加上 CSS 後,我們就完成了 <AtomicTextField> 元件。

處理 CSS 時有一個小技巧,我們的 <AtomicTextField> 使用了 <AtomicFormFiled> 作為包裝元件,在 <AtomicFormFiled> 中我們有設定了一些 CSS 變數,在這裡我們可以使用這些 CSS 變數來設定 <AtomicTextField> 的樣式。

.atomic-text-field {
  &__container {
    display: flex;
    align-items: center;
    overflow: hidden;
    width: 100%;
    height: 36px;
    height: var(--field-height);
    border-style: solid;
    border-width: 1px;
    border-color: var(--field-color);
    border-radius: 6px;
    column-gap: 6px;
  }
}

像這樣,我們的 heightborder-color 就可以使用 <AtomicFormField> 的 CSS 變數來設定。如果未來需要調整不同的高度或顏色,可以直接在 <AtomicFormFiled> 中調整,這樣不論是現在的 <AtomicTextField> 或是未來的 <AtomicTextarea><AtomicSelect> 都可以一併調整完成。

進階功能

前面我們花了一些篇幅來處理 v-model 的修飾符,除了讓元件更好用之外,也要對齊 Vue 對 <input v-model> 的處理。

這裡我們先對標一個小小的觀念,v-model 是一個語法糖,這是很常見的說法,但這並不完全正確。

在元件上,這確實是語法糖:

<CustomComponent v-model="value">

上面的寫法等同於

<CustomComponent
  :modelValue="value"
  @update:modelValue="value = $event"
>

但如果是 <input v-model="value"> 卻完全是另外一回事。

它接近但不等於下列這種寫法:

<input
  :value="value"
  @input="value = $event.target.value"
>

如果要完整模擬 <input v-model="value"> 的行為,就得像前面那樣,處理 trimnumberlazy 這些修飾符,考慮 typenumber 的情境,還得處理 compositionstartcompositionend 事件,這樣才能完全對齊 Vue 的行為。

更正確地說,在 <input v-model="value"> 這個用法中的 v-model 是個「指令」(Directive),而不是語法糖。我們可以直接從 Vue SFC Playground 中看到,<input v-model="value"> 會被轉換成下列程式碼:

import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createElementBlock("input", {
    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.value) = $event))
  }, null, 512 /* NEED_PATCH */)), [
    [_vModelText, $setup.value]
  ])
}

這一大坨程式碼看起來有點可怕,但冷靜下來仔細觀察,我們可以抓到幾個重點。withDirectives 在我們實作 <AtomicPopover> 中出現過,這就是指令在 render function 中的寫法,而 vModelText 就是處理 v-model 的指令物件。

我們再看看有修飾符的 v-model

<input v-model.number.lazy="value">

會被轉換成:

import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createElementBlock("input", {
    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (($setup.value) = $event))
  }, null, 512 /* NEED_PATCH */)), [
    [
      _vModelText,
      $setup.value,
      void 0,
      {
        number: true,
        lazy: true
      }
    ]
  ])
}

既然這是一個 Vue 的指令,那我們是不是可以依樣畫葫蘆,直接使用 Vue 的 vModelText 來幫助我們實現這個功能呢?

import {
  createElementBlock,
  defineComponent,
  vModelText,
  withDirectives,
} from 'vue';

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

const InputComponent = defineComponent({
  render() {
    return withDirectives(
      createElementBlock(
        'input',
        {
          'onUpdate:modelValue': (event: string | number) =>
            (modelValueWritable.value = event),
        },
        null,
        512 /* NEED_PATCH */
      ),
      [[vModelText, modelValueWritable.value, '', props.modelModifiers]]
    );
  },
});

這樣,我們在模板裡面的 <input> 只要換成 <InputComponent> 就大功告成了。

<InputComponent
  :id="id"
  :aria-describedby="describedby"
  class="atomic-text-field__input"
  :disabled="disabled"
  :maxlength="maxlength"
  :minlength="minlength"
  :name="name"
  :placeholder="placeholder"
  :readonly="readonly"
  :type="type"
/>

這樣一來,我們就不需要再自己實現 v-model 的修飾符,處理 type 等於 number 的情境,處理 compositionstartcompositionend 事件,只要使用 Vue 的 vModelText 搭配 render function 就可以完成這個功能。

總結

<AtomicTextField> 使用了 <AtomicFormField> 元件的 UI 架構與外觀,我們只需專注於處理 v-model 的雙向綁定與修飾符,就可以完成這個元件。如果要更貼近 Vue 的 <input v-model="value">,還需要考量到非拉丁語系拼音與選字的問題。

字數統計上,最簡單的方法就是直接使用字串的長度作為字數,但如果想要更精確地計算視覺上看到的字數,我們可以使用瀏覽器內建的 Intl.Segmenterstring-length 這些工具來幫助我們。

最後,我們拆解了 <input v-model="value"> 的行為,發現這時的 v-model 並不是一個語法糖,而是一個 Vue 的指令。幸運的是,Vue 提供了 vModelText 這個指令的實作給我們使用,這讓我們不需要再自己實現 v-model 的修飾符,處理 typenumber 的情境,處理 compositionstartcompositionend 事件,只需使用 Vue 的 vModelText 搭配 render function 就可以完成這個功能。

參考資料


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

尚未有邦友留言

立即登入留言