<input>
作為表單控制元素,是網頁開發中最常見的元素之一。<input>
的不同 type
設定會影響顯示的 UI,以及它所代表的含義與功能。
在這裡,我們將使用先前建立的 <AtomicFormField>
作為基礎,來實作 <AtomicTextField>
。
如同開頭所提到的,在網頁中不同 type
的 <input>
不論在 UI 或功能上都會不同。<input>
可以是文字輸入框、日期選擇器、顏色選擇器,也可以是檔案上傳或按鈕等。因此在這裡選用 <AtomicTextField>
更強調了這是一個專為「文字輸入框」設計的元件。
在開始實作前,我們先研究各個 UI Library 的 TextField / Input 元件是如何設計的。
Element Plus
<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-icon
與 suffix-icon
這兩個 props,讓開發人員可以在 <ElInput>
的前後放入 Icon 元件。另外,<ElInput>
還提供了 prefix
、suffix
、prepend
、append
等 slot 來讓開發人員依照需求調整元件的外觀。
但在使用 <ElInput>
時有一些限制必須注意:
文件表示 <ElInput>
為受控元件,必須與 Vue 的資料綁定才能正常操作。
文件表示 <ElInput>
不支援修飾符(modifiers),但實際使用後目前僅不支援 v-model.lazy
功能。
除了 v-model.number
之外,當 type
設定為 number
時,Vue 也會將資料自動轉換成數字,但 <ElInput>
不支援這種使用方式。
<input type="number" v-model="value" />
上面的範例會將 value
轉換成數字。
<ElInput type="number" v-model="value" />
上面的範例不會將 value
轉換成數字。
Vuetify
<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>
提供了 label
、prepend-icon
、prepend-inner-icon
、append-icon
、append-inner-icon
等 props,讓開發人員可以在 <VTextField>
的前後放入 Icon 名稱。此外,還提供了 prepend
、prepend-inner
、append
與 append-inner
等 slot,讓開發人員依照需求調整元件的外觀。
在使用 <VTextField>
時也有一些限制必須注意:
<VTextField>
不支援 lazy
修飾符(modifiers)。<VTextField>
不支援在 type
設定為 number
時自動將資料轉換成數字,這點與 Element Plus 相同。Nuxt UI
<template>
<UInput
v-model="value"
type="text"
leadingIcon="i-heroicons-magnifying-glass-20-solid"
trailingIcon="i-heroicons-light-bulb"
/>
</template>
Nuxt UI 的 <UInput>
提供了 leadingIcon
與 trailingIcon
這兩個 props,讓開發人員可以在 <UInput>
的前後放入 Icon 名稱。此外,還提供了 leading
與 trailing
slot,讓開發人員依照需求調整元件的外觀。
與 Element Plus 和 Vuetify 一樣,每個對應的 icon props 都有對應的 slot,但不同的是,在 Element Plus 和 Vuetify 中 slot 與 props 是同時存在的,而 Nuxt UI 中 slot 的權重大於 props,也就是說如果兩者都有設定,則只會顯示 slot 的內容。
另外,Nuxt UI 的 <UInput>
的 v-model
沒有前面兩個 UI Library 上的使用限制,所有的修飾符:trim
、number
跟 lazy
都完全支援。不過對於非拉丁語系,如繁體中文,在選字時的處理不夠完整。
Nuxt UI 對於非拉丁語系選字時的處理沒有判斷是否正在選字:
Vue 原生在 <input>
上的 v-model
有支援判斷是否正在選字:
關於非拉丁語系的判斷的問題在這裡列出的三個 UI Library 僅僅只有 Element Plus 有特別處理,另外除了上面列出的功能外,Element Plus 與 Vuetify 都提供字數統計功能,這對於需要限制使用者輸入字數的場景非常有用。
綜合以上並結合自身經驗,我們統整出 <AtomicTextField>
的功能:
v-model
來綁定輸入的值。modelModifiers
,如果 v-model
使用了修飾符,則會收集在這個 props 裡面,如 trim
、number
與 lazy
修飾符。type
設定,可接受的設定有:text
、password
、email
、number
、tel
、url
、search
。showCount
,開啟顯示目前輸入的字數,但只對輸入為字串時有效。<input>
的各種設定,如:placeholder
、disabled
、readonly
、required
、maxlength
、minlength
。append
與 prepend
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>
的所有 props
與 slots
,這裡就不一一列出。
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
內部已經至少實現了 number
與 trim
這兩個修飾符,這表示 v-model.number
與 v-model.trim
這兩個用法不需要額外處理,但 v-model.lazy
我們還是需要自己實現。
這可能會是個略大的工程,為了實現 lazy
功能,我們不能在 <AtomicTextField>
內部的 <input>
上使用 v-model
,我們得像 Vue 底層在處理 v-model
一樣,監聽 input
與 change
事件來實作這個功能。
<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-model
的 trim
、number
與 lazy
修飾符。
接著我們要處理當 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
。
在這裡,我們使用了 compositionstart
與 compositionend
事件。如果只需支援現代瀏覽器,我們可以在 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
不僅支援了 trim
、number
、lazy
修飾符,在 type
為 number
時也會轉換為數字,並且支援了非拉丁語系的選字功能。
接著我們來處理 showCount
功能,這個功能主要是在 <input>
下方顯示目前輸入的字數,這對於需要限制使用者輸入字數的場景非常有用。
計算字數的方式非常簡單,我們只要在 modelModifiers.number
為 false
並且 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.toarray
、char-regex
或 string-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
的 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;
}
}
像這樣,我們的 height
與 border-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">
的行為,就得像前面那樣,處理 trim
、number
、lazy
這些修飾符,考慮 type
為 number
的情境,還得處理 compositionstart
與 compositionend
事件,這樣才能完全對齊 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
的情境,處理 compositionstart
與 compositionend
事件,只要使用 Vue 的 vModelText
搭配 render function 就可以完成這個功能。
<AtomicTextField>
使用了 <AtomicFormField>
元件的 UI 架構與外觀,我們只需專注於處理 v-model
的雙向綁定與修飾符,就可以完成這個元件。如果要更貼近 Vue 的 <input v-model="value">
,還需要考量到非拉丁語系拼音與選字的問題。
字數統計上,最簡單的方法就是直接使用字串的長度作為字數,但如果想要更精確地計算視覺上看到的字數,我們可以使用瀏覽器內建的 Intl.Segmenter
或 string-length
這些工具來幫助我們。
最後,我們拆解了 <input v-model="value">
的行為,發現這時的 v-model
並不是一個語法糖,而是一個 Vue 的指令。幸運的是,Vue 提供了 vModelText
這個指令的實作給我們使用,這讓我們不需要再自己實現 v-model
的修飾符,處理 type
為 number
的情境,處理 compositionstart
與 compositionend
事件,只需使用 Vue 的 vModelText
搭配 render function 就可以完成這個功能。
<AtomicTextField>
原始碼:AtomicTextField.vue
<AtomicFormField>
實作回顧:[為你自己寫 Vue Component] AtomicFormField