按鈕在網頁中是最常見的元件之一,這個元件通常在使用者點擊後會觸發程式上的操作,可能是關閉或打開 Modal,也可能是送出表單,又或是刪除某些重要資料等等。
在 UI 表現上,按鈕會依照不同的情境而有不同的樣貌。主要按鈕通常會選用填色的實心按鈕,重要程度次之的可能會選用空心透明的按鈕,再次之的按鈕 UI 可能就會是像文字一樣的形式。
在開始實作前,我們先研究各個 UI Library 的 Button 元件設計。
Element Plus
<template>
<ElButton>Default</ElButton>
<ElButton type="primary">Primary</ElButton>
<ElButton type="success">Success</ElButton>
<ElButton type="info">Info</ElButton>
<ElButton type="warning">Warning</ElButton>
<ElButton type="danger">Danger</ElButton>
</template>
Element Plus 的 <ElButton>
提供了非常多樣化的 UI 變化設定,像是可以透過 type
來設定按鈕的顏色,plain
可以設定成 Element 定義的「樸素」按鈕,round
可以設定成圓角按鈕,circle
可以設定成圓形按鈕,size
可以調整按鈕的大小,icon
可以在按鈕前加入 Icon。
Vuetify
<template>
<VBtn variant="elevated"> Button </VBtn>
<VBtn variant="flat"> Button </VBtn>
<VBtn variant="tonal"> Button </VBtn>
<VBtn variant="outlined"> Button </VBtn>
<VBtn variant="text"> Button </VBtn>
<VBtn variant="plain"> Button </VBtn>
</template>
Vuetify 的 <VBtn>
提供了更多的樣式變化,像是 variant
可以設定成 elevated
、flat
、tonal
、outlined
、text
與 plain
等等,color
可以設定按鈕的顏色,elevation
的部分可以在 0
到 24
之間設定陰影的深度。
PrimeVue
<template>
<Button label="Primary" raised />
<Button label="Secondary" severity="secondary" raised />
<Button label="Success" severity="success" raised />
<Button label="Info" severity="info" raised />
<Button label="Warn" severity="warn" raised />
<Button label="Help" severity="help" raised />
<Button label="Danger" severity="danger" raised />
<Button label="Contrast" severity="contrast" raised />
</template>
PrimeVue 的 <Button>
提供了 severity
來設定按鈕的顏色,raised
可以設定成有陰影的按鈕,rounded
可以將按鈕的外觀設定成圓角較大的按鈕,如果需要的是文字外觀的按鈕,則可以使用 text
做設定。
按鈕的功能很單純,但樣式變化卻非常豐富,幾乎所有的功能都落在 UI 的設定上。在 Element UI 與 PrimeVue 中,要設定按鈕的樣式都是透過特定的屬性來完成。
<!-- Element Plus -->
<ElButton type="primary" round>Primary</ElButton>
<ElButton type="primary" circle>Primary</ElButton>
<ElButton type="primary" text>Primary</ElButton>
<!-- PrimeVue -->
<Button label="Primary" raised />
<Button label="Primary" outlined />
<Button label="Primary" text />
不這些屬性如果同時出現,就要看那個設定的權重比較大了!
<!-- 這會長什麼樣子呢? -->
<ElButton type="primary" round circle />
<Button label="Primary" text outlined />
Vuetify 在這個部分比較不會有兩個外觀設定誰的權重比誰大的問題,因為它的設計是透過 variant
來設定按鈕的樣式,這樣一來就不會有兩個屬性的設定衝突問題。
綜合以上並結合自身經驗,我們統整出 <AtomicButton>
的功能:
type
、disabled
等等。variant
設定按鈕的「樣式」,像是:實心按鈕(contained)、外框按鈕(outlined)與文字按鈕(text)。color
設定按鈕的「顏色」,常見的有:primary
、success
、warning
、danger
與 info
等等。shape
設定按鈕的「形狀」,預設為有圓角的長方形(rectangle)按鈕,其他還有 circle
與 square
共三種模式。size
依照需求設定按鈕的「大小」,像是:normal
與 small
。使用結構如下:
<template>
<AtomicButton
variant="contained"
color="primary"
size="normal"
type="button"
disabled
>
按鈕
</AtomicButton>
</template>
首先,我們將需求中提到的功能整理成 props
的介面,我們會需要下列屬性:
屬性 | 型別 | 預設值 | 說明 |
---|---|---|---|
variant | contained , outlined , text |
contained |
按鈕的樣式 |
color | primary , success , warning , danger , info |
primary |
按鈕的顏色 |
shape | rectangle , circle , square |
rectangle |
按鈕的形狀 |
size | normal , small |
normal |
按鈕的大小 |
type | button , submit , reset |
button |
原生按鈕的 type 設定 |
disabled | boolean |
按鈕是否禁用 |
interface AtomicButtonProps {
type?: 'button' | 'submit' | 'reset'
variant?: 'contained' | 'outlined' | 'text'
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
size?: 'normal' | 'small'
shape?: 'rectangle' | 'circle' | 'square'
disabled?: boolean
}
const props = withDefaults(defineProps<AtomicButtonProps>(), {
type: 'button',
variant: 'contained',
color: 'primary',
size: 'normal',
})
<AtomicButton>
說起來,除了樣式非常多元外,它是一個實作上相當簡單的元件。
<template>
<button
class="atomic-button"
:disabled="disabled"
:type="type"
>
<span>
<slot name="default" />
</span>
</button>
</template>
先給 <button>
一個基本的樣式,後面我們會透過 props
來設定按鈕的 UI 變化。
$name: '.atomic-button';
#{$name} {
height: var(--button-size);
font-size: 0.875rem;
text-align: center;
border-style: solid;
border-width: 1px;
border-color: transparent;
outline: none;
line-height: 1.25rem;
transition-property: color, background-color, border-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 0.15s;
&:disabled {
cursor: not-allowed;
}
}
這裡我們寫一個 computed
將 UI 設定添加到 <button>
上。
const rootClass = computed(() => {
const BASIC_CLASS = 'atomic-button';
return [
`${BASIC_CLASS}--${props.variant}`,
`${BASIC_CLASS}--${props.color}`,
`${BASIC_CLASS}--${props.shape}`,
`${BASIC_CLASS}--${props.size}`,
];
});
所以我們可以列出所有需要的 class 名稱
variant
: atomic-button--contained
, atomic-button--outlined
, atomic-button--text
color
: atomic-button--primary
, atomic-button--success
, atomic-button--warning
, atomic-button--danger
, atomic-button--info
shape
: atomic-button--rectangle
, atomic-button--circle
, atomic-button--square
size
: atomic-button--normal
, atomic-button--small
這麼多樣式與組合,寫起來應該會很驚人吧!尤其是 color
與 variant
的組合,總共就有 15 種組合,shape
與 size
的組合也有 6 種。
還好我們可以透過 SCSS 的 @each
與 CSS 變數來處理這些變化的組合。
variant
與 color
這裡我們使用 SCSS 的 @each
來處理 variant
與 color
的組合。
$name: '.atomic-button';
#{$name} {
// variant
&--contained {
color: white;
@each $color, $value in $color-map {
&#{$name}--#{$color} {
background-color: rgba($value, 1);
&:not(:disabled):is(:hover, :focus) {
background-color: rgba($value, 0.8);
}
&:not(:disabled):active {
background-color: rgba($value, 0.6);
}
}
}
&:disabled {
background: lightgray;
}
}
}
這樣一來我們就完成了 variant
為 contained
時的 5 種 color
組合,下面是其中一種組合的結果。
.atomic-button--contained {
color: #fff;
}
.atomic-button--contained.atomic-button--primary {
background-color: #1976d2;
}
.atomic-button--contained.atomic-button--primary:not(:disabled):is(
:hover,
:focus
) {
background-color: #1976d2cc;
}
.atomic-button--contained.atomic-button--primary:not(:disabled):active {
background-color: #1976d299;
}
$color-map
是一個我們自定義的 SCSS 變數,裡面包含了各種顏色的名稱與對應的色碼。$color-map: ( primary: #1976D2, success: #72BF24, warning: #FFAD0F, danger: #E52D27, info: #909399 );
size
與 shape
這裡我們使用 CSS 的變數來處理 size
與 shape
的組合。
$name: '.atomic-button';
#{$name} {
height: var(--button-size);
// size
&--normal {
--button-size: 36px;
--button-padding: 20px;
}
&--small {
--button-size: 32px;
--button-padding: 10px;
}
// shape
&--rectangle {
padding-right: var(--button-padding);
padding-left: var(--button-padding);
border-radius: 6px;
}
&--square {
width: var(--button-size);
border-radius: 6px;
}
&--circle {
width: var(--button-size);
border-radius: 9999px;
}
}
應用了 CSS 的變數功能,我們只在 size
的 class 設定好變數,而在 shape
的 class 裡面就可以直接使用這些變數。原本要寫六種組合的 CSS 現在只需要寫五種就好了,size
或 shape
選擇越多,能省下的 CSS 就越多。
樣式搞定了,接下來我們處理 Icon 的部分。關於 Icon 我們有幾種設定方式可以考慮:
slot
設定 Icon 內容。使用 props 傳入 Icon 名稱的方式可以這樣做:
<AtomicButton icon="add">新增</AtomicButton>
這種做法在 <AtomicButton>
內部可以選擇使用 CSS 實作或是依照傳入的屬性去取得對應的 Icon 元件。前者我們需要在元件內部或另外維護一包 Icon 的 CSS,後者則是需要建立名稱與元件的對應表。
如果能讓 Icon 的設定與 <AtomicButton>
脫鉤那就更好了。這樣就不需要在元件內部維護 Icon 的 CSS 或建立對應表了。
使用 props 傳入 Icon 元件的方式可以這樣做:
<template>
<AtomicButton :icon="AddSvg">新增</AtomicButton>
</template>
這裏使用 vite-svg-loader 來載入 SVG 這樣我們就可以直接將 SVG 當作元件來使用。
這樣一來我們就讓 Icon 與 <AtomicButton>
完全解耦,而且這樣的設計讓我們可以更容易地擴充 Icon。但有些人可能覺得如果想要調整 Icon 的樣式就會變得比較麻煩,這時候我們可以再傳入 iconProps
作爲 Icon 元件的 props,或是我們還有其他選擇。
透過 slot
設定 Icon 的話我們可以這樣做:
<template>
<AtomicButton>
<template #prepend>
<AddSvg fill="currentColor" />
</template>
新增
</AtomicButton>
</template>
這樣一來我們就能夠更自由地設定 Icon 與其樣式,使用起來更加彈性與方便,缺點則是使用上可能會讓畫面稍微雜亂一點。
我自己偏好使用 slot 做設定,雖然使用上會略顯雜亂,但卻是最彈性的方式。
<template>
<button
class="atomic-button"
:class="rootClass"
:disabled="disabled"
:type="type"
>
<span v-if="$slots.prepend">
<slot name="prepend" />
</span>
<span>
<slot name="default" />
</span>
<span v-if="$slots.append">
<slot name="append" />
</span>
</button>
</template>
這裏我們還加上了 named slot 是否有被使用的判斷,像是上面的範例有使用到 prepend slot,我們才把該層的 <span>
給渲染出來。這一來可以避免渲染無用的結構,還可以幫助我們更方便地定義按鈕內元素的間距。
$name: '.atomic-button';
#{$name} {
display: inline-flex;
justify-content: center;
align-items: center;
column-gap: 10px;
}
這樣一來我們就完成了 <AtomicButton>
元件,現在可以依照不同的場景任意切換按鈕的樣式了。
我們經常看到一些 UI 呈現上與按鈕一樣,但實際上點擊後會像超連結一樣換頁,當然我們可以使用 router.push()
來完成這個功能,但如果考量到語意化標籤的使用、無障礙或是 SEO 的考量,<AtomicButton>
元件如果能支援渲染成 <a>
標籤那就更好了。
所以我們讓 <AtomicButton>
也可以接收 to
這個屬性,在使用時沒有傳入 to
的話就維持使用 <button>
,反之有傳入 to
時則改用 <AtomicLink>
。
interface AtomicButtonProps {
// 如果有傳入 `to`,內部會渲染 `<AtomicLink :to="to" />`
to?: RouteLocationRaw
}
const props = withDefaults(defineProps<AtomicButtonProps>(), {
// 略
to: undefined,
})
const rootComponent = computed(() => {
return props.to == null ? 'button' : AtomicLink
})
<template>
<component
:is="rootComponent"
class="atomic-button"
:class="rootClass"
:disabled="rootComponent === 'button' ? disabled : undefined"
:to="to"
:type="rootComponent === 'button' ? type : undefined"
>
<!-- 略 -->
</component>
</template>
這樣一來我們就可以在按鈕與連結之間切換,卻仍然擁有相同的 UI 表現了!
<a>
元素不支援disabled
,所以在這裡只有當rootComponent
為 button 時才加上disabled
的設定。如果是渲染成<AtomicLink>
則永遠傳入undefined
。
很多人習慣使用 <div>
或 <span>
並在上面綁定點擊事件作為按鈕使用,因為這樣不用處理跨瀏覽器上的按鈕樣式不統一問題。這乍看之下沒有什麼問題,一樣可以點擊、一樣可以結案,但其實使用這種方式做成的按鈕功能並不完整。
首先,當滑鼠滑到原生按鈕上方時,游標會從箭頭變成「手指頭」。另外,原生的按鈕元件除了可以點擊之外,還可以透過 tab 鍵做焦點的切換,並且可以透過按下 space 跟 enter 鍵觸發 click
事件。在無障礙方面,螢幕閱讀器會告訴使用者這是一個可以點擊(或是禁用)的按鈕。
所以在使用上能選用 <button>
作為按鈕時就盡量不要使用其他的元素替代。如有需要使用像是 <div>
等其他元素替代時,則也需要讓該元素符合上述的各種條件。
以下是如果需要用非 <button>
元素實作按鈕功能建議要加上的屬性,如果有需要可以作為參考使用。
<template>
<div
role="button"
tabindex="0"
@click="onButtonClick"
@keydown="onButtonKeydown"
>
我是一個按鈕
</div>
</template>
另外順帶一提,與 <button>
元素不同,<a>
元素只支援 enter 觸發 click
事件,在實作上不妨多留意兩者之間些微的不同。
<AtomicButton>
我們幾乎沒有處理關於「功能」方面的程式碼,而是聚焦在如何處理各種變化組合的按鈕。也因為我們是繼承 HTML 中的 <button>
元素,因此按鈕本身可以支援的屬性,元件也都支援。
我們唯一處理的功能是在傳入 to
的時候會自動切換使用 <AtomicLink>
但擁有一樣的 UI 樣式。在遇到長得像按鈕的連結時其實非常好用。
無障礙部分提供給需要用其他元素替代原生按鈕元素的讀者參考。按鈕是網頁開發中最常用的元件之一,雖然很簡單實作,但也是有一些小細節需要注意。如果能把無障礙的部分也照顧好,才能稱得上是使用者友善的網站吧!
<AtomicButton>
原始碼:AtomicButton.vue